A while ago I built my own personal finance app. Nothing fancy: a single-page frontend (React over a CDN, JSX transpiled in the browser) served by a tiny Python server with zero dependencies, persisting everything to a local JSON file. It lived on my laptop, started with a .bat file, and worked fine.

Today I wanted it to grow up. Two goals:
- Run it in my homelab as a real, always-on local app — not a script I launch by hand.
- Set up a pipeline so that every time I
git push, a fresh Docker image gets built automatically.
By the end of the day I had exactly that: push to GitHub → image builds itself in the cloud → my homelab pulls it and runs it. Here's how it went, gotchas included.
The repo, if you want to poke around: github.com/leonardorh0612MX/finanzas-web
The starting point
The app is deliberately simple. The whole backend is one server.py using nothing but the Python standard library. It serves the static files and exposes a tiny API: GET /api/db returns the saved data, POST /api/db writes it back to finanzas_data.json.
That simplicity is great for containerizing — no npm install, no build step, no dependency tree. But it came with two traps I only found once it was inside a container.
Containerizing it
The Dockerfile is almost embarrassingly short, because the app has no dependencies:
FROM python:3.12-slim
WORKDIR /app
COPY . .
EXPOSE 8765
ENV NO_BROWSER=1
CMD ["python", "server.py"]
I also added a .dockerignore. This part matters: my finance data is personal, and I never want it baked into an image — or pushed to GitHub:
finanzas_data.json
.git
.github
__pycache__/
.venv/
screenshots/
The data file is already in .gitignore, so it never reaches GitHub. The .dockerignore makes sure it never reaches the image either. Defense in depth for one small JSON file, but it's the file I care about most.
Gotcha #1: localhost is a lie inside a container
My server bound to localhost:
server = HTTPServer(("localhost", PORT), Handler)
On my laptop, perfect. Inside a container, useless. Binding to localhost (127.0.0.1) means the server only listens inside the container's own network namespace — so even with the port mapped, nothing from my LAN could reach it. The fix is to listen on all interfaces:
server = HTTPServer(("0.0.0.0", PORT), Handler)
This one bites a lot of people the first time they containerize something they wrote for localhost.
Gotcha #2: a server that opens a browser
My server.py was helpful: on startup it opened a browser tab so I'd see the app immediately. Lovely on a desktop. On a headless server with no display, that call just errors out in a background thread. So I gated it behind an environment variable:
if os.environ.get("NO_BROWSER") != "1":
threading.Timer(0.8, open_browser).start()
Local behavior stays identical; in the container I set NO_BROWSER=1 and it skips the browser entirely.
The CI/CD pipeline: GitHub Actions + GHCR
Here's the part that turns "I built an image" into "my images build themselves."
The flow is:
git push to main
→ GitHub Actions builds the image
→ pushes it to GHCR (GitHub Container Registry)
→ my homelab pulls it and runs it
Two pieces of vocabulary worth pinning down, because they confused me at first:
- GHCR is a container registry — a place to store and distribute Docker images, like Docker Hub but baked into GitHub. It's where the built image lands so my homelab can pull it. Think "npm, but for Docker images."
- GitHub Actions is the CI: it runs on every push, builds the image, and shoves it into GHCR.
The workflow lives at .github/workflows/docker-publish.yml:
name: Build and push Docker image
on:
push:
branches: [ main ]
workflow_dispatch:
env:
IMAGE: ghcr.io/leonardorh0612mx/finanzas-web
permissions:
contents: read
packages: write
jobs:
build-and-push:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: |
${{ env.IMAGE }}:latest
${{ env.IMAGE }}:${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max
A couple of things that tripped me:
- GHCR demands lowercase image names. My GitHub username has uppercase letters, so I couldn't just reuse
${{ github.repository }}— I hardcoded the lowercase path. - The image is private by default. For my homelab to pull it without authenticating, I made the package public (the image contains only my app code, never my data). If you want it private, your host has to
docker loginto GHCR first. - The
GITHUB_TOKENalready has the right scope, as long as you declarepackages: writeinpermissions.
First push, the Actions tab lit up green, and the image was sitting in GHCR a minute later.
Running it in the homelab
On the homelab I don't build anything — I just pull the image GitHub built for me. The compose file:
services:
finanzas:
image: ghcr.io/leonardorh0612mx/finanzas-web:latest
container_name: finanzas
restart: always
ports:
- "8770:8765"
volumes:
- ./finanzas_data.json:/app/finanzas_data.json
The volume line is the whole point of persistence: my real data lives in that file on the host, mounted into the container. It survives every rebuild and redeploy, and it's trivially backup-able.
One subtlety: bind-mounting a single file requires the file to already exist on the host. If it doesn't, Docker happily creates a directory with that name and your app breaks in a confusing way. So I copied my real data file over first:
scp finanzas_data.json user@homelab:~/docker/finanzas/
Then brought it up:
docker compose up -d
docker ps
curl -s -o /dev/null -w "%{http_code}\n" http://localhost:8770
A 200, and the app was live on my LAN.
A deliberate choice: keep it local
I did not expose this through Cloudflare. It's my personal finances — that data has no business being reachable from the public internet. It stays local-only, same posture I use for n8n. If I ever want it remotely, that's a job for Cloudflare Tunnel behind an access login, never an open port.
Auto-updates: the honest version
I looked at Watchtower — a little container that watches your registry and auto-pulls new images. It's popular in homelabs and genuinely convenient. But for a single app where I control every push, I decided I'd rather keep the human in the loop. Updating is one command whenever I want it:
docker compose pull && docker compose up -d
The "grown-up" version of this isn't Watchtower at all — it's GitOps (ArgoCD or Flux) on a Kubernetes cluster, where the cluster continuously reconciles itself against Git. That's a future-me problem, and a future post.
What I learned today
localhostand0.0.0.0are not the same thing, and containers will remind you.- Keep secrets and personal data out of images and repos —
.gitignoreand.dockerignoreare both your friends. - A registry (GHCR) is the missing link between "build" (CI) and "run" (your server).
- Build once, in the cloud; pull everywhere. Don't rebuild on the box.
- Just because you can auto-deploy doesn't mean you should — sometimes one manual command is the right amount of automation.
What's next
A real Kubernetes cluster — and with it, GitOps via ArgoCD, and probably a Proxmox host to run the whole thing on proper hardware. The homelab is starting to look less like a laptop and more like a small data center.
But for tonight, I have an app I wrote, building itself in the cloud and running on my own hardware in my room. That loop — push, build, pull, run — is the same one that ships software at companies a thousand times my size. It just happens to be running in Guadalajara.
— Leo RH