Migrating a NestJS Backend from Heroku to Scaleway. Part 2/3: CI/CD & HTTPS
A GitHub Actions CI/CD pipeline deploying a NestJS backend to Scaleway, with Traefik v3 for automatic HTTPS and Scaleway Secret Manager for secure environment variable management.
Construction of a GitHub Actions CI/CD pipeline to deploy a NestJS backend to Scaleway, with Traefik v3 for automatic HTTPS and Scaleway Secret Manager for secure environment variable management.
Part 2 of 3. Part 3: Zero-downtime & Monitoring (coming soon).
The nginx detour
The initial plan was nginx and certbot: reverse proxy config, certbot init script, and cron renewal job, all written and working.
Then, an afternoon spent debugging a certificate renewal that hadn't triggered, because the cron job ran as the wrong user and silently did nothing. After the fix, a manual nginx reload was still needed, since certbot doesn't know about the process manager. Writing glue code for a problem Traefik solves in three config lines: that's when the switch was made, before the first real deploy.
The nginx configs, certbot scripts, and renewal cron still exist in the repository. The cron file now contains one line:
# SSL renewal handled by Traefik.It's an empty stub. The CI pipeline still copies it to the VPS, harmless, but a reminder that infra migrations leave artifacts. Everything that follows uses Traefik v3.
GitHub Actions pipeline structure
Three sequential jobs, each gated on the previous:
test → build → deployTwo near-identical workflows handle production and staging. The trigger difference:
# Production
on:
push:
branches: [main]
workflow_dispatch: # manual trigger, prod only
# Staging
on:
push:
branches: [staging]workflow_dispatch on production allows a re-deploy without a commit: useful after rotating a secret or updating infra config without touching application code.
Job 1: test
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile --filter @myapp/api...
- run: pnpm build:shared-types
- run: pnpm test:api`--filter @myapp/api...`: the trailing ... is pnpm's syntax for "this package and its workspace dependencies." The project is a monorepo with a shared-types package consumed by the API. Without ..., pnpm installs only the API package itself and misses its local deps. With it, pnpm walks the dependency graph and installs exactly what's needed, not the entire monorepo including the frontend.
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true is set in the job env. This forces JS-based actions onto the Node 24 runtime, ahead of GitHub's default. Low impact now, avoids a forced migration later.Building and pushing the Docker image
Job 2: build
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/build-push-action@v6
with:
context: .
file: ./deploy/Dockerfile.scaleway
push: true
tags: |
ghcr.io/myorg/myapp-api:prod
ghcr.io/myorg/myapp-api:${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max
provenance: false
build-args: |
GIT_COMMIT=${{ github.sha }}Dual tagging. Every build produces :prod (moving pointer) and :<git-sha> (immutable). The deploy job uses :prod. The sha tag is there for a rollback to a specific commit.
`provenance: false` is non-obvious. By default, docker/build-push-action generates SLSA provenance attestations and pushes them to GHCR. This creates an extra unknown/unknown platform entry that breaks some docker pull flows. Disabling it is the right default, unless supply chain attestations are actively used.
`cache-from/cache-to: type=gha` stores Docker layer cache in GitHub Actions' own cache. Combined with the Dockerfile layer strategy below, most builds only rebuild changed layers.
The Dockerfile
deploy/Dockerfile.scaleway uses a two-stage build.
Stage 1: build
FROM node:22-bookworm-slim AS build
RUN corepack enable && corepack prepare pnpm@10.33.0 --activate
WORKDIR /app
# Manifests only — not source
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json tsconfig.base.json ./
COPY packages/shared-types/package.json ./packages/shared-types/
COPY packages/api/package.json ./packages/api/
RUN pnpm install --frozen-lockfile
# Source comes after — doesn't bust the deps layer
COPY packages/shared-types ./packages/shared-types
COPY packages/api ./packages/api
RUN pnpm --filter @myapp/shared-types build
RUN pnpm --filter @myapp/api buildCopying manifests before source is the standard Docker cache optimization. Putting COPY . . first would invalidate the node_modules layer on every source change. Separating them means pnpm install only reruns when a package.json or lockfile changes.
pnpm is pinned to `10.33.0` in this Dockerfile. The root Dockerfile (used for local builds) uses pnpm@latest. Unpinned works locally; in CI it means different builds can silently run different versions.
Stage 2: production
FROM node:22-bookworm-slim
RUN apt-get update && apt-get install -y openssl ca-certificates \
&& rm -rf /var/lib/apt/lists/*
RUN corepack enable && corepack prepare pnpm@10.33.0 --activate
ARG GIT_COMMIT
ENV GIT_COMMIT_SHA=$GIT_COMMIT
ENV NODE_ENV=production
WORKDIR /app
COPY --from=build /app .
EXPOSE 8080
CMD ["pnpm", "--filter", "@myapp/api", "start:prod"]opensslmust be installed explicitly on slim Node images. Prisma's query engine requires it at runtime. Without it, the container starts, Prisma fails to initialize, and the error surfaces as a cryptic message about missing native bindings. The rootDockerfiledoesn't install it, confirming this Dockerfile is the hardened production variant.
GIT_COMMIT_SHA bakes the deploying commit's sha into the running container as an environment variable, useful for correlating logs and error reports to a specific release.
Traefik v3: HTTPS without the ceremony
The entire Traefik configuration lives as CLI flags on the traefik service in docker-compose.prod.yml. No static YAML, no dynamic file provider: Docker labels on the backend service handle routing.
Traefik service
traefik:
image: traefik:v3
restart: always
ports:
- "80:80"
- "443:443"
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- letsencrypt:/letsencrypt
command:
- --providers.docker=true
- --providers.docker.exposedbydefault=false
- --entrypoints.web.address=:80
- --entrypoints.web.http.redirections.entrypoint.to=websecure
- --entrypoints.web.http.redirections.entrypoint.scheme=https
- --entrypoints.websecure.address=:443
- --certificatesresolvers.letsencrypt.acme.email=ops@example.com
- --certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json
- --certificatesresolvers.letsencrypt.acme.httpchallenge.entrypoint=web
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"`exposedbydefault=false` is required. Without it, Traefik creates routes for every container on the Docker network automatically: silent 404s and unintended exposure. With it, only containers carrying traefik.enable=true are routed.
Global HTTPS redirect. The redirections.entrypoint config on web sends all :80 traffic to :443, globally, for every service. No per-router middleware.
Let's Encrypt with HTTP-01 challenge. Traefik handles the full ACME lifecycle: initial issuance, automatic renewal, storage in acme.json on a named volume. The HTTP-01 challenge runs over port 80, which is why port 80 must stay open even in a TLS-only setup.
Compared to the certbot flow abandoned earlier: a separate container, a shared nginx volume, a cron job, a manual reload after renewal. Traefik handles all of that internally.
Backend service labels
labels:
- "traefik.enable=true"
- "traefik.http.routers.backend.rule=Host(`${API_DOMAIN:-api.example.com}`)"
- "traefik.http.routers.backend.entrypoints=websecure"
- "traefik.http.routers.backend.tls.certresolver=letsencrypt"
- "traefik.http.services.backend.loadbalancer.server.port=8080"
- "traefik.http.services.backend.loadbalancer.healthcheck.path=/health"
- "traefik.http.services.backend.loadbalancer.healthcheck.interval=10s"`${API_DOMAIN:-api.example.com}` is how one compose file serves both production and staging. The deploy job injects API_DOMAIN for the target environment; the default keeps the file functional without it. Same pattern applies to IMAGE_TAG, MEM_LIMIT, and COMPOSE_PROFILES.
Port exposure strategy
# Traefik — only service binding host ports
ports:
- "80:80"
- "443:443"
# Backend — internal only
expose:
- "8080"The backend uses expose, not ports. It's reachable from other containers on the Docker network, never from the host directly. The API is not internet-reachable: all traffic flows through Traefik. Strong isolation, worth the extra config line.
Two health checks, two purposes
There's a Docker-level healthcheck on the backend container and a Traefik-level health check in the labels. Both hit /health. They serve different functions:
- Docker's healthcheck: gates container status (
healthy/unhealthy). The deploy script uses this as its signal before removing the old container. - Traefik's healthcheck: gates traffic routing. Traefik only forwards requests to targets currently passing it.
A new container must pass both before it handles real traffic and before the old one is removed.
The Docker healthcheck:
healthcheck:
test: >
node -e "fetch('http://localhost:8080/health')
.then(r => process.exit(r.ok ? 0 : 1))
.catch(() => process.exit(1))"
interval: 30s
timeout: 5s
retries: 3
start_period: 60sThis uses Node's built-in fetch (Node 18+), no curl, no extra layer in the image. start_period: 60s gives the application time to boot and run pending migrations before failures count against the retry limit.
Secrets management with Scaleway Secret Manager
The rule is simple: application secrets do not live in GitHub.
GitHub holds only what is needed to authenticate to Scaleway. The actual application configuration, database URL, third-party API keys, Mailjet credentials, lives in Secret Manager. Rotating an app secret never touches GitHub.
How the .env is materialized
The deploy job fetches the current secret payload from Scaleway Secret Manager over its API, decodes it, and writes it to the application's .env file on the VPS at deploy time.
The fetch response is checked before anything is written. An early version of the script wrote the raw API response directly to.envregardless of status. A failed request produced a.envfile starting with{, and the application failed to connect to the database with no clear signal why. Now, any response other than a successful one aborts the deploy without touching the existing.env.
The deployment flow, end to end
git push main
│
▼
[test] pnpm install (api + workspace deps) → build shared-types → test:api
│ needs
▼
[build] buildx → GHCR login → build Dockerfile.scaleway
push :prod + :<sha> (GHA layer cache)
│ needs
▼
[deploy]
step 1: scp docker-compose.prod.yml + deploy.sh → /opt/app
step 2: ssh → fetch .env from Secret Manager, write to VPS
step 3: ssh → docker login ghcr.io → run deploy.shdocker login ghcr.io runs again inside the deploy step on the VPS, even though CI already authenticated during the build job. After a docker image prune or a fresh server state, the local credential store may not have a valid token. Re-running login on every deploy is cheap; a denied: access forbidden on docker pull in the middle of a production deploy is not.
The deploy.sh script handles the actual container replacement with a scale-up-then-drain pattern, the core of Part 3, along with Grafana Alloy and Scaleway Cockpit for monitoring.
Where things stand
- Three-job GitHub Actions pipeline: test, build, deploy, fully gated.
- Docker images in GHCR, dual-tagged (
:prod+:<sha>), layer-cached. - Traefik v3 handling HTTPS, the Let’s Encrypt lifecycle, and reverse proxying.
- Application secrets in Scaleway Secret Manager, never in GitHub.
- Backend isolated behind Traefik, not directly reachable from the internet.
Part 3/3, Zero-downtime & Monitoring: deploy.sh scale-up-drain, Docker and Traefik health check coordination, Grafana Alloy and Scaleway Cockpit (coming soon).
This article is part of a series documenting a production infrastructure migration from Heroku to Scaleway. Stack: NestJS, PostgreSQL, Elasticsearch, Docker, GitHub Actions, Traefik v3.