DEV Community

Hemant Sharma
Hemant Sharma

Posted on • Originally published at hemantsharma.tech on

Building a Production-Grade CI/CD Pipeline with GitHub Actions, GHCR, Docker, and a VPS

Building a Production-Grade CI/CD Pipeline with GitHub Actions, GHCR, Docker, and a VPS

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

Enter fullscreen mode Exit fullscreen mode

And here is the same pipeline as a full diagram — Actions through GHCR into blue-green on the VPS:

Backend CI/CD pipeline: GitHub Actions, GHCR, Docker, and zero-downtime blue-green on a VPS

A few rules I treated as non-negotiable:

  1. If tests fail → nothing gets built, migrated, or deployed at all.
  2. Database connectivity is checked, and only then are the migrations run, before containers swap.
  3. The thing we deploy is an image tagged with the git SHA — not a vague :latest tag to ensure uniqueness of the image.
  4. The new container starts on a free port and has to pass a DB-aware /health check through the exposed API route.
  5. 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.
  • HEALTHCHECK hits /health."Process is up" is not the same as "can talk to Postgres."
  • Exec-form CMD. Node is PID 1, so SIGTERM reaches 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"]

Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

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: write so GITHUB_TOKEN can push to GHCR without a long-lived PAT for the normal path.
  • workflow_dispatch for 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:

  1. Images tagged by commit SHA
  2. Migrate before cutover, with a real connectivity check
  3. Health-gated traffic flip, abort if unhealthy
  4. Secrets on the runtime side, never in the image
  5. 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)