DEV Community

Cover image for Our first production release: a bus-ticket booking app on Docker Compose, nginx and one VM
Alex DevOps engineer
Alex DevOps engineer

Posted on

Our first production release: a bus-ticket booking app on Docker Compose, nginx and one VM

We are shipping our first production release. v0.1.0 of the Autolux Bus Station frontend is tagged — an app for booking and buying intercity bus tickets, with a Next.js storefront and admin panel, a NestJS API, Postgres and Redis. This is how it gets to production, and what we deliberately did not use.

What "GitOps" means in this project

The repository is literally called gitops, and a reader would expect ArgoCD, Helm and a cluster. There is none. No Kubernetes, no container registry, no pull-based reconciliation.

What the gitops repo actually holds is a disaster-recovery workflow: a GitHub Action that, once a day,

  1. clones every project repository (API, frontend, Android app, and itself) and tars them without .git,
  2. runs pg_dump against the production database,
  3. restores that dump into a throwaway postgres:17 service container — the job fails if the restore fails,
  4. uploads both files to Google Drive, mirrored into a second folder/account,
  5. on success pings the QA topic in Telegram; on failure creates an urgent ClickUp task and pings the DevOps topic.

The integrity check is the part worth stealing. A backup you have never restored is a hope, not a backup — so every run proves the dump is restorable before it is allowed to count.

Three Compose stacks, one network

The runtime is deliberately boring: one VM, Docker Compose, split into three independent stacks that share one external network:

acp-api-backend/           postgres + redis (+ backend, behind a compose profile)
acp-front/                 frontend
acp-api-backend/deploy/proxy/   nginx reverse proxy
        └── all three joined by: acp-shared-network
Enter fullscreen mode Exit fullscreen mode
  • postgres and redis live on a private internal network and are reachable only from the backend. Host-published ports are bound to 127.0.0.1.
  • Only backend, frontend and proxy sit on the shared network, and only the proxy is meant to be reachable from outside.
  • The backend service sits behind a stack compose profile, so a plain docker compose up -d on a laptop starts just Postgres and Redis for local development, while the VM uses --profile stack.

First-time bring-up is three commands, in this order:

./network-setup.sh                              # idempotent: creates acp-shared-network
docker compose --profile stack up -d --build    # postgres, redis, backend
cd ../acp-front && docker compose up -d --build # frontend
cd ../acp-api-backend/deploy/proxy && docker compose up -d   # nginx
Enter fullscreen mode Exit fullscreen mode

depends_on: condition: service_healthy only orders containers within one compose file, so cross-stack ordering is simply "run them in this sequence".

The frontend image

The Next.js Dockerfile is a three-stage build (deps → build → runtime) that ships only the standalone output, runs as a non-root user and has a healthcheck. Two details caused real pain:

  • NEXT_PUBLIC_* variables are baked into the client bundle at build time, so they are ARGs. Runtime-only values (the API proxy target, the Google client ID) come from the container environment instead. Mixing the two up gives you a production build that silently calls the wrong API.
  • A prebuild script refuses to build if NEXT_PUBLIC_ENABLE_DEV_AUTH=true. A dev-login backdoor reaching production is exactly the kind of mistake you want the build to make impossible, not merely unlikely.

The healthcheck probes 127.0.0.1, not localhost: busybox wget resolves localhost to ::1 first, the app binds only IPv4, and the container is reported unhealthy forever.

nginx: same origin on purpose

The proxy puts / (frontend) and /api/ + /uploads/ (backend) behind one origin. That is not cosmetic — it is what keeps cookie auth and Google OAuth working in Safari. With the same origin, the backend can issue Secure, SameSite=Lax cookies and enable FRONTEND_SAME_ORIGIN=true. We documented one rule loudly: don't change cookie flags to make plain HTTP work. Raw http://<vm-ip> is for infrastructure smoke tests only; real login QA needs TLS in front.

Two nginx gotchas:

resolver 127.0.0.11 valid=10s ipv6=off;   # Docker's embedded DNS
...
set $upstream_backend backend:3001;
proxy_pass http://$upstream_backend;      # variable => re-resolved per request
Enter fullscreen mode Exit fullscreen mode

Using a variable in proxy_pass makes nginx re-resolve the name on every request. Without it nginx caches the container IP at startup, and the first time you redeploy a service (new container, new IP) the proxy points at a dead address until you restart nginx too. It also means you can redeploy one service in isolation:

docker compose --profile stack up -d --build backend
Enter fullscreen mode Exit fullscreen mode

The second gotcha: don't strip the /api/ prefix (proxy_pass http://backend:3001/;). The NestJS app already serves under /api/v1, so stripping it 404s every call. And X-Forwarded-Proto must be forwarded correctly, otherwise Secure cookies and req.ip are wrong behind the proxy (TRUST_PROXY_HOPS=1 for a single nginx hop, not the 2 used on the Vercel → Render staging chain).

The release itself

v0.1.0 of the frontend rolled up about forty merged PRs from six contributors: admin routes and analytics, a tickets dashboard, auth fixes (OAuth callback, CSRF sync, email-OTP guest booking), profile and booking flows, and statistics export to XLSX. Task IDs from ClickUp are in commit and PR titles (fix(routes): ... (86cb1ycx5)), so every line of the changelog traces back to a ticket.

Release mechanics:

  1. Freeze the release. A vX.Y.Z tag on main plus a GitHub Release, so a deploy consumes a stable artefact instead of "whatever is on main today". v0.1.0 is the first one, and GitHub generates the "What's Changed" list from the merged PRs.
  2. Deploy from the tag. On the VM, check out the tagged commit and rebuild only what changed: docker compose up -d --build frontend for the storefront, the --profile stack variant for the backend. The proxy needs no restart thanks to per-request DNS resolution.
  3. Smoke-test, then QA. Infra smoke over HTTP (backend reachable from the frontend container by service name, proxy reaching both upstreams), then login and booking checks over HTTPS — never over the raw IP.
  4. Keep a way back. The previous image stays on the host until the new release is signed off, so rollback is re-running the same compose command on the previous tag. The nightly backup is the second line of defence for data.

Takeaways

  • Pick the least machinery that gives you repeatable deploys. One VM and three Compose files are easier to reason about at 3 a.m. than a cluster nobody on the team has operated.
  • Make failure loud and routed: green status to QA, red status to DevOps and a ticket.
  • Prove backups by restoring them in CI.
  • Same-origin proxying solves more auth problems than any cookie flag.

About the author

I'm Alex — a DevOps engineer building and shipping the Autolux Bus Station platform: a Next.js storefront, a NestJS API, and the Docker Compose + nginx stack described above. I write about shipping discipline and the production stories that come with it.

Would you run a production app on one VM and Compose, or go straight to an orchestrator? Tell me in the comments.

Top comments (1)

Collapse
 
davekurian profile image
Dave Kurian •

The restore-before-counting check is the right gate. Two gaps I'd look at. A successful restore only proves the dump loads, so add a cheap assertion after it (a row count on bookings, or max(created_at) within the last day) so a truncated or near-empty but valid dump fails the job. And since nginx also serves /uploads/ from the backend, are those files in the nightly archive? Repos and pg_dump are covered, but uploads live outside both, and that is what a lost VM would take with it.