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.
← Back to Module 08 overviewDockerfile producing a sub-200MB image; runs as non-root.main: lint → typecheck → test → build → scan → deploy.main to a real host (Fly.io / Render / Railway).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
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
curl http://localhost:3000/health → {"ok":true}.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 }} }
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.
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.
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.
GitHub → Settings → Branches → main:
test, build-and-scan.node:20-bookworm-slim if argon2 won't build..dockerignore and the production stage's npm ci --omit=dev.GITHUB_TOKEN for GHCR (works out of the box). For cloud deploys, set up OIDC federation per your provider's guide.