URL Shortener Tutorial · Module 08 of 11

Containerization & CI/CD

By the end of this tutorial you'll have a small multi-stage Docker image, a GitHub Actions pipeline that lints, tests, builds, scans, and deploys preview + production environments, and an automatic rollback on smoke-test failure.

~4–6 hrsDockerGitHub ActionsFly.ioTrivy
← Back to Module 08 overview
Definition of Done

What You'll Have

  • A multi-stage Dockerfile producing a sub-200MB image; runs as non-root.
  • GitHub Actions workflow on every PR + push to main: lint → typecheck → test → build → scan → deploy.
  • Image published to GitHub Container Registry tagged with the commit SHA.
  • Production deploy from main to a real host (Fly.io / Render / Railway).
  • A post-deploy smoke test with auto-rollback.
The Steps

Build It

STEP 1

Write a multi-stage Dockerfile

Create Dockerfile:

FROM node:20-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN  npm ci
COPY tsconfig.json ./
COPY src ./src
COPY migrations ./migrations
RUN  npm run build

FROM node:20-alpine
WORKDIR /app
ENV NODE_ENV=production
COPY package*.json ./
RUN  npm ci --omit=dev && npm cache clean --force
COPY --from=build /app/dist        ./dist
COPY --from=build /app/migrations  ./migrations

USER node
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=3s \
  CMD wget -qO- http://localhost:3000/health || exit 1

CMD ["node", "dist/server.js"]

Create .dockerignore:

node_modules
dist
.git
.env
.env.*
!.env.example
coverage
**/*.test.ts
tests
docs
ops
STEP 2

Build and run locally

docker build -t url-shortener:dev .
docker run --rm -p 3000:3000 \
  -e DATABASE_URL=postgres://shortener:dev@host.docker.internal:5432/shortener \
  -e REDIS_URL=redis://host.docker.internal:6379 \
  -e JWT_SECRET=dev-only \
  url-shortener:dev
✓ Verify: curl http://localhost:3000/health{"ok":true}.
STEP 3

Write the GitHub Actions workflow

Create .github/workflows/ci.yml:

name: ci
on:
  pull_request:
  push: { branches: [main] }

jobs:
  test:
    runs-on: ubuntu-latest
    services:
      postgres: { image: postgres:16-alpine, env: { POSTGRES_PASSWORD: dev, POSTGRES_USER: shortener, POSTGRES_DB: shortener }, ports: ["5432:5432"], options: --health-cmd "pg_isready -U shortener" --health-interval 5s --health-retries 10 }
      redis:    { image: redis:7-alpine, ports: ["6379:6379"] }
    env:
      DATABASE_URL: postgres://shortener:dev@localhost:5432/shortener
      REDIS_URL:    redis://localhost:6379
      JWT_SECRET:   ci-only
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: 20, cache: npm }
      - run: npm ci
      - run: npm run lint
      - run: npx tsc --noEmit
      - run: npm run migrate:up
      - run: npm test
      - run: npm run test:integration

  build-and-scan:
    needs: test
    runs-on: ubuntu-latest
    permissions: { contents: read, packages: write, id-token: write }
    steps:
      - uses: actions/checkout@v4
      - uses: docker/setup-buildx-action@v3
      - uses: docker/login-action@v3
        with: { registry: ghcr.io, username: ${{ github.actor }}, password: ${{ secrets.GITHUB_TOKEN }} }
      - id: build
        uses: docker/build-push-action@v6
        with:
          push: true
          tags: ghcr.io/${{ github.repository }}:${{ github.sha }}
          cache-from: type=gha
          cache-to:   type=gha,mode=max
      - uses: aquasecurity/trivy-action@0.20.0
        with:
          image-ref: ghcr.io/${{ github.repository }}:${{ github.sha }}
          severity:  CRITICAL,HIGH
          exit-code: '1'

  deploy-prod:
    needs: build-and-scan
    if: github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    environment: production
    steps:
      - uses: actions/checkout@v4
      - uses: superfly/flyctl-actions/setup-flyctl@master
      - run: flyctl deploy --image ghcr.io/${{ github.repository }}:${{ github.sha }} --strategy rolling
        env: { FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }} }
      - name: smoke
        run: |
          for i in 1 2 3 4 5; do
            sleep 5
            curl -fsS https://<your-app>.fly.dev/health && exit 0
          done
          flyctl deploy --image ghcr.io/${{ github.repository }}:$(git rev-parse HEAD~1) --strategy rolling
          exit 1
        env: { FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }} }
Gotcha: Fly is one option among many. Render, Railway, Cloud Run, and a tiny self-hosted VM all work — replace the deploy step with the appropriate CLI.
STEP 4

Set up the host (Fly.io example)

fly auth login
fly launch --no-deploy --name <your-app>
fly secrets set JWT_SECRET=$(openssl rand -base64 32) \
                DATABASE_URL=<managed-postgres-url> \
                REDIS_URL=<managed-redis-url>
fly deploy

Then in GitHub → Settings → Secrets → Actions → add FLY_API_TOKEN from fly tokens create.

STEP 5

Open a PR and watch CI

git checkout -b module-08
git add .
git commit -m "module 08: dockerfile + github actions ci/cd"
git push -u origin module-08

Open the PR. Watch the Actions tab — every step should turn green.

✓ Verify: the GHCR package page shows an image with your commit SHA.
STEP 6

Test the auto-rollback

Introduce a bug deliberately on a branch (make /health return 500), merge to main, watch the deploy job fail and roll back to the previous SHA. Then revert the bad commit.

STEP 7

Add branch protection

GitHub → Settings → Branches → main:

  • Require PR before merging.
  • Require these checks: test, build-and-scan.
  • Require branches up to date.
Common Gotchas

If Something Goes Wrong

  • Trivy fails on a vendored CVE — pin or upgrade the base image; if the CVE is in a transitive dep with no fix, document a temporary waiver.
  • npm ci fails on Alpine — some native deps need build tools; switch to node:20-bookworm-slim if argon2 won't build.
  • Image larger than 500MB — you're including dev deps or source. Check your .dockerignore and the production stage's npm ci --omit=dev.
  • OIDC not configured — the workflow as written uses GITHUB_TOKEN for GHCR (works out of the box). For cloud deploys, set up OIDC federation per your provider's guide.
What's Next

Move On