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

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.

Frequently asked questions

How do you cut deployment time with GitHub Actions and Docker?

Containerise the application so builds are reproducible, use Docker layer caching so unchanged dependencies are not rebuilt, run pipelines on self-hosted runners close to the deployment target, and roll out with zero-downtime releases. This took our API deployments from 25 minutes to under 10.

How do you achieve zero-downtime deployments with Docker?

Build and health-check the new container before switching traffic to it, then keep the previous image available so a rollback is just re-pointing traffic, not rebuilding.

← All posts