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.

App's Dashboard

Today I wanted it to grow up. Two goals:

  1. Run it in my homelab as a real, always-on local app — not a script I launch by hand.
  2. 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 login to GHCR first.
  • The GITHUB_TOKEN already has the right scope, as long as you declare packages: write in permissions.

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

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

  • localhost and 0.0.0.0 are not the same thing, and containers will remind you.
  • Keep secrets and personal data out of images and repos — .gitignore and .dockerignore are 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

Last Update: June 10, 2026

Tagged in:

HomeLab, Docker