How I Cut API Deployment Time by 60% Using GitHub Actions & Docker
When I joined the RDCD project at Orange Business Development, a deployment took about 25 minutes: SSH into the server, pull, install dependencies, run migrations, restart services, then manually smoke-test, with the API unavailable for part of that window. By the time I was done, a release took under 10 minutes end to end, with zero downtime. Here's the pipeline, step by step.
Step 1: Containerize the application
The first win had nothing to do with GitHub Actions. Moving the Laravel and NestJS services into Docker images meant "deploy" stopped being "mutate a server" and became "swap a container". A multi-stage Dockerfile keeps the final image small, since build dependencies stay in the builder stage:
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY --from=builder /app/dist ./dist
CMD ["node", "dist/main.js"]
Step 2: Build once, in CI
Every push to main triggers a workflow that tests, builds the image, and pushes it to the
registry tagged with the commit SHA, so the artifact that passed tests is byte-for-byte the artifact
that ships:
name: deploy
on:
push:
branches: [main]
jobs:
test-build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci && npm test
- uses: docker/build-push-action@v5
with:
push: true
tags: registry.example.com/api:${{ github.sha }}
cache-from: type=registry,ref=registry.example.com/api:buildcache
cache-to: type=registry,ref=registry.example.com/api:buildcache,mode=max
Registry-backed layer caching was the single biggest time saver: a typical build dropped from ~12 minutes to 3–4 because dependency layers are reused unless the lockfile changes.
Step 3: Deploy from a self-hosted runner
The production environment sat behind a restricted network, so instead of opening SSH from the internet, we registered a self-hosted runner inside it. The deploy job pulls the new image, runs migrations, and switches traffic. Secrets (database credentials, gateway keys) live in GitHub Environments with required reviewers on production, never in the repository.
Step 4: Zero-downtime cutover and rollback
The new container starts alongside the old one and must pass a health check
(/health verifying database connectivity) before the reverse proxy switches upstreams.
Only then does the old container stop. Rollback is the same mechanism in reverse: because every image
is tagged by commit SHA, rolling back is redeploying the previous tag: about two minutes, with no
"fix forward under pressure" panic. Migrations stay backward-compatible (expand, migrate, contract)
so the previous version can always run against the current schema.
Results
- Deployment time: ~25 minutes manual → under 10 minutes, fully automated (60%+ reduction).
- Downtime per release: a visible gap → zero, via health-checked container swap.
- Rollback: re-provisioning by hand → redeploying a known-good SHA in ~2 minutes.
- Release frequency went up, which was the real goal. Cheap deploys change how a team works: small PRs ship the day they're merged instead of waiting for a weekly release window.
None of this is exotic: containerize, build once, cache aggressively, health-check before cutover, keep rollback one command away. The 60% number gets attention, but the zero-downtime cutover is what the users actually noticed.