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,
- clones every project repository (API, frontend, Android app, and itself) and tars them without
.git, - runs
pg_dumpagainst the production database, -
restores that dump into a throwaway
postgres:17service container — the job fails if the restore fails, - uploads both files to Google Drive, mirrored into a second folder/account,
- 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
-
postgresandredislive on a privateinternalnetwork and are reachable only from the backend. Host-published ports are bound to127.0.0.1. - Only
backend,frontendandproxysit on the shared network, and only the proxy is meant to be reachable from outside. - The backend service sits behind a
stackcompose profile, so a plaindocker compose up -don 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
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 areARGs. 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
prebuildscript refuses to build ifNEXT_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
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
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:
-
Freeze the release. A
vX.Y.Ztag onmainplus a GitHub Release, so a deploy consumes a stable artefact instead of "whatever is on main today".v0.1.0is the first one, and GitHub generates the "What's Changed" list from the merged PRs. -
Deploy from the tag. On the VM, check out the tagged commit and rebuild only what changed:
docker compose up -d --build frontendfor the storefront, the--profile stackvariant for the backend. The proxy needs no restart thanks to per-request DNS resolution. - 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.
- 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.
- 🐙 Project on GitHub: autolux-cherkasy
- 💼 LinkedIn: alex-d
- 🐦 X: @mainoceanm
- 📣 Telegram — DevOps notes: join the channel
- ✉️ mainoceanm@gmail.com
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)
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.