Self-Hosted Deployment

The same five stack-agnostic containers that run on AWS, deployed locally through one script, self-host.sh, a CI/CD-lite entrypoint built for a single developer running a real home server.

Docker ComposeSelf-Hosted

The five containers here (Postgres, backend, Redis, import worker, frontend) are the same stack-agnostic containers from the main page, the same images run locally and on AWS, with environment variables doing all the work of telling each one which mode it’s in. Self-hosting is the more interesting half of that story. Building images, loading env files, and running compose up, in the right order, was easy to get wrong by hand, skip loading the env file and the containers come up without a DB password.

        Browser

           ▼ (host:3000)
     ┌─────────────┐
     │  frontend   │  nginx :80
     │  (nginx)    │  proxies /api/ → backend:8000
     └──────┬──────┘


     ┌─────────────┐        ┌───────────┐
     │   backend   │◄──────►│    db     │  Postgres :5432
     │  (FastAPI)  │        └───────────┘
     └──────┬──────┘
            │ send_task()

     ┌─────────────┐
     │    redis    │  broker + result backend
     └──────┬──────┘


     ┌─────────────┐        ┌───────────┐
     │import-worker│◄──────►│    db     │
     │  (Celery)   │        └───────────┘
     └─────────────┘
     shared volume: csv-uploads (backend writes, worker reads)

Only frontend is published to the host. Everything else talks over the internal Docker network and is unreachable from outside it.

One Script, One Entrypoint

self-host.sh wraps the whole deploy into a single script driven by an ACTION env var (up, down, restart, logs, build, migrate, default up):

  • It refuses to run up/build/restart if .env.production is missing, or if DB_PASSWORD/JWT_SECRET are still the placeholder values from the example file, catching a misconfigured deploy before containers boot instead of after a cryptic Postgres or JWT error at runtime.
  • migrate runs alembic upgrade head via docker compose exec backend rather than from the host, because Postgres’s port isn’t published to the host at all.
  • It resolves the Docker Compose CLI itself, preferring the docker compose v2 plugin and falling back to legacy docker-compose if that’s what’s installed.

I didn’t need a full CI/CD stack for a single developer on one self-hosted box, but doing all of this by hand invited exactly the kind of mistake a pipeline exists to prevent. self-host.sh is the CI/CD-lite middle ground, one script, one entrypoint, that does the same thing every time.

Reverse Proxy Gap

Right now the frontend container is still the one thing published directly to the host (port 3000), even though its own nginx already proxies /api/ requests through to the backend over the Docker network. The next step, planned but not done yet, is a dedicated nginx reverse proxy in front of the whole stack as the only entrypoint, so no individual container needs a host-published port at all. That closes off the last bit of host exposure, matches standard reverse-proxy practice, and means the stack won’t compete for ports with other self-hosted services running on the same box.