Introduction
I had a NestJS backend that needed to go live, and I did not want the deploy process to be me SSHing into the VPS every time. I also did not want to outsource the whole thing to a PaaS "Deploy" button. I wanted a proper pipeline, so I can simply push code, run tests, ship a container, cut traffic over safely, and deploy the latest version.
So I built one. GitHub Actions, GHCR, Docker, nginx, and a DigitalOcean VPS. This post is about that story — what I set up, what I got wrong along the way, and what still matters after the host changed later.
The backend sat in a pnpm monorepo next to a Next.js frontend. Vercel took care of the frontend. Postgres lived on a managed provider. The part I spent real time on was getting the API from git push to a healthy process behind TLS.
Throughout the blog, I will be referring to the project codebase, to refer the codebase you can click here to see the full repo.
What I needed from the pipeline
Before I touched the YAML files, I wrote down what "good" looked like to me:
- A push to the right branch should deploy. I should not be copy-pasting commands on the server for normal releases. Rather, it should be automated.
- The same commit should produce the same image, so rollback is "run an older SHA", not "try to rebuild what we think we shipped."
- If the new version is broken, the old one should keep serving.
- Database Migrations should happen before the new backend business logic takes traffic.
- Cost should stay low — I had DigitalOcean credits, so a small VPS was fine.
- Dev and prod should be separate environments, but the same pipeline shape.
A managed platform would have been less work. I wanted the loop in my own hands instead: build, registry, host, cutover.
How a release works
Here is the flow I had in plain terms.
git push (main | dev branch on GitHub)
│
▼
GitHub Actions
┌──────────────────────────────┐
│ 1. pnpm test (backend) │
│ 2. verify DB connectivity │
│ 3. drizzle-kit migrate │
│ 4. build → push GHCR (:sha) │
│ 5. scp deploy.sh │
│ 6. SSH → blue-green swap │
└──────────────────────────────┘
│
▼
VPS (nginx + Let's Encrypt SSL)
idle port → health gate → upstream rewrite → SIGTERM drain
And here is the same pipeline as a full diagram — Actions through GHCR into blue-green on the VPS:
A few rules I treated as non-negotiable:
- If tests fail → nothing gets built, migrated, or deployed at all.
- Database connectivity is checked, and only then are the migrations run, before containers swap.
- The thing we deploy is an image tagged with the git SHA — not a vague
:latesttag to ensure uniqueness of the image. - The new container starts on a free port and has to pass a DB-aware
/healthcheck through the exposed API route. - If that check fails, we simply abort moving to the latest image container. The previous container stays alive and handles the traffic as usual.
The API container only listened on localhost within the VPS, and nginx terminated TLS and was the only thing exposed to the internet as what a reverse proxy should be.
Building the image (and not lying to Docker)
I build a multi-stage image from the repo root , not from apps/backend alone. In a pnpm workspace the backend depends on a shared package. If the Docker context ignores that, you get a build that looks fine until it is not.
The Dockerfile installs with --filter backend..., builds packages/shared then apps/backend, and uses pnpm deploy --prod so the runtime stage is a pruned tree instead of the whole monorepo.
A few decisions that saved me pain:
- bookworm-slim (glibc), not Alpine. bcrypt and musl have bitten enough people. I picked the boring base.
- Run as
node, not root. -
HEALTHCHECKhits/health."Process is up" is not the same as "can talk to Postgres." -
Exec-form
CMD. Node is PID 1, soSIGTERMreaches Nest's shutdown hooks and the pool can drain cleanly.
FROM node:22-bookworm-slim AS runtime
ENV NODE_ENV=production PORT=3001
WORKDIR /app
COPY --from=build --chown=node:node /out ./
USER node
EXPOSE 3001
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
CMD node -e "fetch('http://127.0.0.1:'+(process.env.PORT||3001)+'/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
CMD ["node", "dist/main"]
Images go to GHCR as <image>:<git-sha>, with an env *-latest tag only as a convenience. The deploy script always gets the SHA. Buildx cache (type=gha) keeps rebuilds from starting from zero every push.
Migrate first, then cut traffic
This one is easy to get backwards.
If you swap the container and then migrate, the new code can boot against an old schema and fall over in front of users. If you migrate first (and keep schema changes expand/contract-friendly), the old container keeps serving while the database moves forward. Then the new container takes over.
"Migration failed" often means "we never connected." drizzle-kit can sit behind a spinner for a long time without telling you much. So the workflow prints host/port/user (password stays out of the logs) and runs a quick select 1 with a 10s timeout before migrate.
On GitHub-hosted runners, some managed Postgres hosts prefer IPv6 so, forcing NODE_OPTIONS=--dns-result-order=ipv4first helped clear a set of hangs that looked like flaky CI and were really DNS.
- name: Verify DB connectivity
working-directory: apps/backend
env:
DATABASE_URL: ${{ secrets.DATABASE_URL }}
run: |
# Log host/port/user (never the password), then:
# new Client({ connectionString, connectionTimeoutMillis: 10000 }).query('select 1')
- name: Run DB migrations
working-directory: apps/backend
env:
DATABASE_URL: ${{ secrets.DATABASE_URL }}
run: pnpm exec drizzle-kit migrate
Blue-green on the VPS
I did not want a 502 window where the old process is gone, and the new one is still starting creating a time space where user requests might hit a wall, and they simply see a broken application. So each environment has two ports (prod 3001 / 3002, dev 3101 / 3102). The new container always starts on the idle one.
Only after /health succeeds do we rewrite nginx's upstream and reload. Then we stop the old container with a 30s grace period so in-flight work can finish.
infra/deploy.sh lives in the repo and gets scp'd on every deployment, so the script on the VPS cannot silently drift from git:
# Health gate: up to ~60s for the new container to report DB-ready.
OK=0
for _ in $(seq 1 30); do
if curl -fsS "http://127.0.0.1:$IDLE/health" >/dev/null 2>&1; then OK=1; break; fi
sleep 2
done
if ["$OK" != 1]; then
echo "!! health check FAILED — aborting, leaving $OLD live" >&2
docker logs --tail 80 "$NEW" || true
docker rm -f "$NEW" || true
exit 1
fi
echo "upstream backend_$ENV { server 127.0.0.1:$IDLE; }" \
| sudo tee "/etc/nginx/conf.d/upstream-$ENV.conf" >/dev/null
sudo nginx -t
sudo systemctl reload nginx
Containers bind to 127.0.0.1 (localhost) only. nginx handles HTTPS with Let's Encrypt as the reverse proxy.
One small debugging habit that paid off: the SSH step prints hostname, whoami, and a directory listing first. If a secret points at the wrong machine, you see it immediately instead of "deploy succeeded" on a host you did not mean to touch.
The alternative I refused was the classic pull → stop → start on a single port. That leaves a gap where nothing healthy is listening, and there is no automatic "keep the old one."
The host itself
I provisioned the VPS with an idempotent infra/setup-vps.sh: Docker, nginx, Certbot, ufw (22/80/443), fail2ban, swap, key-only SSH, and a deploy user whose sudo is limited to reloading nginx.
Secrets stay on the host in env files (/opt/<app>/<env>/backend.env) and get passed in with --env-file. They do not get baked into the image, and they do not get committed.
More scripting up front than clicking through a panel. Everything about the host is in git and can be re-run.
A few CI details that mattered in a monorepo
- There were path filters on
apps/backend/ **,packages/shared/**, and the workflow file — so, a frontend-only change should not redeploy the backend or vice versa. - GitHub Environments were set for both Production vs. Development, so secrets and rules differ by branch without copying the whole workflow.
- Concurrency per branch with
cancel-in-progress: false— so overlapping deploys to the same env should wait, not interleave mid-swap. -
packages: writesoGITHUB_TOKENcan push to GHCR without a long-lived PAT for the normal path. -
workflow_dispatchfor the days when I want to ship by hand without relying on a push trigger.
What I considered instead
| Option | Why it was tempting | Why I did not start there |
|---|---|---|
| Manual SSH + Compose | Fast on day one | Easy to drift, easy to mess up prod, no clean audit trail |
| Build on the VPS from git | Simple to picture | Ties the host to your build toolchain; weaker "same commit = same backend" story |
| PaaS (e.g. Render) from day one | Less nginx and SSH | Less control over cutover and health gating |
| Something like Coolify | Quick UI setup | It would consume more VPS resources just for one single project, which I mostly wouldn't need since deployment is fully automated through GHA |
I wanted those rules sitting in the repo where I could read them.
When the host changed
Later the live backend moved off the always-on VPS to a cheaper managed host. The important part: I did not have to redesign the release model. The GHA workflow is still there and can still builds the image, GHCR still can stores it by SHA, migrations still run before the new revision serves traffic. The VPS workflow is still in the repo if I want self-hosting again.
That was the part I was happiest about. The backend travels. The host is replaceable.
Wrapping up
If I do this again, I am keeping the same core:
- Images tagged by commit SHA
- Migrate before cutover, with a real connectivity check
- Health-gated traffic flip, abort if unhealthy
- Secrets on the runtime side, never in the image
- Path-filtered CI so the monorepo does not waste deploy runs
That is the pipeline I built and ran. It shipped for AmbitiousYou — workflows and host scripts are under .github/workflows/ and infra/ if you want the full files.
Originally published at https://hemantsharma.tech/blog/7-building-production-grade-cicd-pipeline.


Top comments (0)