Introduction
I built LifeOS because I wanted one place for my goals, the projects that move them forward, and the tasks I need to do today. Most apps I tried did one of those well and ignored the other two.
It started as a frontend app and it's now a React app having a FastAPI backend in one Docker container, with Postgres on Supabase, authentication through Firebase, and a GitHub Actions pipeline that tests everything and pushes the image to GitHub Container Registry. Render pulls that image and runs it.
In this post I'll walk through what the app does, how the pieces fit together, the Docker setup, the CI/CD pipeline, and the problems I hit on the way.
This project is a big part of my DevOps journey.
- Live app: life-os-vgei.onrender.com
What LifeOS does
LifeOS works on three levels. A goal is where you want to end up, a project is how you get there, and a task is what you do today. You set a goal, break it into projects, and add tasks to each project. Progress on goals and projects is worked out from their tasks.
What's in the app:
• Goals with a category (Career, Health, Finance, Learning and so on), a target date and a status
• Projects under each goal, with a priority and a target date
• Tasks under each project, with a due date, priority and notes
• Dashboard showing today's priorities, active goals and projects, and quick counts
• Tasks page with All / Today / Upcoming / Completed tabs, filters and search
• Onboarding for new accounts: pick focus areas and set a first goal
• Sign-in with Google or email and password, plus password reset
• Light and dark themes, and layouts for desktop and mobile
The stack and how it fits together
- Frontend : React, TypeScript, Tailwind, Vite
- Backend : FastAPI (Python 3.12), SQLAlchemy, Alembic migrations
- Database : Postgres on Supabase
- Sign-in : Firebase Authentication
- Packaging : Docker (multi-stage build)
- CI/CD : GitHub Actions, GitHub Container Registry (GHCR)
- Hosting : Render
App request flow:
Browser (React app)
| signs in with Firebase, gets an ID token
|
+-- /api/* with "Authorization: Bearer <token>"
|
LifeOS container (FastAPI)
| checks the token, finds or creates the user,
| only ever returns that user's data
|
+-- SQLAlchemy --> Supabase Postgres
One container does everything. FastAPI handles /api/..., and any other path gets the React app. That means a link like /app/goals/12 works when you open it directly or refresh the page.
I only use Supabase as a plain Postgres database. No Supabase Auth, no Supabase client in the browser. All data goes through my API.
Where it started: an AI Studio prototype
The front end started life as a Google AI Studio app. It was a fast way to get screens built, and get the UI set up. The catch was that it talked straight to Firestore from the browser.
I didn't want my data layer to be something I couldn't see or test, so I wrote a FastAPI back end and swapped every Firestore call for a call to my own API.
The React side now has a small fetch wrapper that adds the sign-in token to each request, plus one service file each for goals, projects, tasks and the user profile.
That swap was the turning point. Once the data went through my own API, I could test it, migrate it, and put it in a container.
Sign-in and data: Firebase + Supabase
The browser signs in with Firebase and gets an ID token. Every API call sends that token, and the server checks it on every request: the signature against Google's public keys, plus the audience, issuer, expiry and sign-in time. I followed Firebase's guide for checking tokens with a regular JWT library, so the server doesn't need a Firebase service account at all.
Once the token checks out, FastAPI finds or creates the user, and every database query is scoped to them. If you ask for someone else's goal, you get a 404. The tests check this for every endpoint.
A few other things I'm happy with:
• Supabase's auto-generated API is locked out. Row level security is turned on for every table with no policies, so Supabase's REST API returns nothing. My app connects as the table owner, so it isn't affected.
• Values are checked twice. The API rejects unknown categories, statuses and priorities, and the database has check constraints for the same lists.
• One image works with any Firebase project. The Firebase web config isn't a secret, so the browser fetches it from /api/config when it starts. Nothing is baked into the front end at build time.
• The app refuses to start half-configured. If Firebase auth is on but its settings are missing, the container exits instead of running in a broken state.
The database schema is managed with Alembic migrations, and they run automatically every time the container starts.
Packaging it with Docker
The Dockerfile has three stages:
- Front end: a Node 22 image installs the npm packages and builds the React app.
- Back end: a Python 3.12 image installs the Python packages into a virtualenv.
- Runtime: a clean, slim Python image that only gets the virtualenv, the back end code, the migrations and the built React files.
Three details I'd point out:
• It runs as a non-root user. If something goes wrong inside the container, it isn't running as root.
• The health check uses Python, not curl. Slim images don't ship curl, so a one-line Python call hits /healthz, which also checks the database connection.
• Migrations run before the app starts. The entrypoint script runs alembic upgrade head and then starts Uvicorn. You can skip that with LIFEOS_SKIP_MIGRATIONS=true if you'd rather run migrations as a separate step.
To run it locally, I copy .env.example to .env, fill in the Supabase URL and the Firebase values, and start it with Compose:

In the logs I look for the migration running and then "Application startup complete". Then the app is on http://localhost:8000.
CI/CD: GitHub Actions to GHCR to Render
This is the part I'm most proud of. Every push and pull request runs a GitHub Actions workflow with three jobs.
- Back end. It lints with ruff, then spins up a throwaway Postgres as a service container. Against that database it applies the migrations, checks they match the models, rolls everything back, and applies them again. Then it runs the tests twice: once on SQLite and once on Postgres.
- Front end.
npm ci, a TypeScript type check, and a production build. - Image. Only if both jobs pass, it builds the Docker image and smoke tests it before pushing anything:
• It starts the container without Firebase settings and makes sure it refuses to start
• It starts it again against Postgres, with demo data, and waits for
/healthz• It calls the API and checks the demo goals come back • It checks that a React route like/app/goalsreturns the app, and that the built JavaScript is actually served • It checks the container runs as theappuser, not root
On main and version tags, the image is pushed to ghcr.io/emmanuelbanjo/life-os, tagged with the branch, the short commit SHA and latest. Pull requests build and test the image but never push it.
Render then pulls the image from GHCR and runs it, with the Supabase and Firebase values set as environment variables in Render. Render doesn't build anything. It runs the exact image the pipeline already tested.
Dependabot keeps the Python packages, npm packages, base image and Actions up to date, and its pull requests go through the same pipeline.
Issues and how I fixed them
The app couldn't reach Supabase. Supabase's direct connection only works over IPv6 unless you pay for the IPv4 add-on, and Docker and Render don't play well with that. The fix was to use the Session pooler connection string instead, with
?sslmode=requireon the end.The database password broke the connection string. If the password has characters like
@,:or/, the URL gets parsed wrong. They have to be URL-encoded (@becomes%40).
CI failed on the migrations. Alembic was being handed plain postgresql:// URLs, which tried to use a driver I didn't have installed. The fix was making Alembic use the psycopg 3 driver. While I was in there, I pinned the runner version and grouped Dependabot's updates so they stop flooding me with pull requests.
What's Next?
- Try running it on Azure Container Apps to put my Azure certifications into practice.
- Add features like Group goal tracker, Chat feature, AI integration
LifeOS started as a way to organise my own days and ended up teaching me how an app goes from a prototype to a tested, containerised service: a real API, auth checked on the server, migrations, a multi-stage Docker build, a CI pipeline that tests the image before it ships, and a live deployment.
If you're also moving from support or ops toward DevOps, my advice is to build something you'll actually use, then put it through a proper pipeline. You'll learn far more from the deployment than from any tutorial.
Thanks for reading. If you have feedback on the app or the setup, drop it in the comments.









Top comments (0)