CodingNic

Capstone: Deploying a Production-Ready API

A Complete Pipeline

Capstone: Deploying a Production-Ready API 25 min read

A Complete Pipeline

Objectives

By the end of this lesson, you should be able to:

  • Write a Dockerfile for the combined application from the last lesson
  • Write a CI/CD workflow that tests it with real environment configuration and publishes it
  • Explain how deployment secrets map to the validated environment schema from Module 1

💡 Why this matters: Lesson 1 built the application, this lesson builds everything around it, a container, and a pipeline that tests and publishes that container automatically, exactly Module 3 and Module 4 of this course, applied directly to this specific, complete application.

⚠️ A note on verification: Docker and GitHub Actions can’t run inside this course’s own sandboxed tooling. The YAML below was validated with a real parser for correctness, both reflect stable, current, documented behavior. Run this yourself, on a real repository with Docker installed, to see it work end to end.

The Dockerfile

text
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .

FROM node:20-alpine
WORKDIR /app
COPY --from=builder /app/package*.json ./
RUN npm ci --omit=dev
COPY --from=builder /app ./

EXPOSE 3000
CMD ["node", "server.js"]

Exactly Module 3’s multi-stage pattern, server.js (not shown here) would be the small entry point that calls validateEnv(), then createApp(env), then app.listen(env.port), the same shape as every application built throughout this course.

The CI/CD Workflow

text
name: CI/CD

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'
      - run: npm ci
      - run: npm test
        env:
          NODE_ENV: test
          JWT_SECRET: ci-test-secret-at-least-16-chars
          DATABASE_URL: postgresql://localhost/test

  build-and-push:
    needs: test
    if: github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
      - uses: docker/build-push-action@v5
        with:
          context: .
          push: true
          tags: |
            ghcr.io/${{ github.repository }}:${{ github.sha }}
            ghcr.io/${{ github.repository }}:latest

The single new detail, compared to Module 4’s version, is the env: block on the test step, JWT_SECRET and DATABASE_URL set directly for the CI run itself, this is exactly Module 1’s validateEnv() in action, the test suite genuinely can’t run without them, since the application refuses to even start, CI needs to supply real (if fake, test-only) values, precisely as Module 1 intended.

What Real Secrets Look Like in Production

In an actual deployment, none of these values are hardcoded anywhere, JWT_SECRET and DATABASE_URL for the CI run above are test-only, safe to be unremarkable, the real production JWT_SECRET, DATABASE_URL, and REDIS_URL (Module 1’s schema extended in Lesson 1) are set directly in the hosting platform’s environment variable configuration (Module 5), the same validateEnv() function runs in every environment, refusing to start if any of them are missing, whether that’s a CI run with a forgotten env: block or a real deployment with a forgotten platform configuration.

The Complete Path, End to End

A developer pushes a commit, CI runs the full test suite (Lesson 1’s combined app, exercised by Course 3’s Jest and Supertest suite) against real, validated test configuration, if it passes, and the push was to main, a Docker image is built and published, tagged both by commit and as latest, a connected hosting platform (Module 5) picks up the new image and deploys it, replacing the old instance gracefully (Module 2’s SIGTERM handling), behind a reverse proxy terminating HTTPS (Module 6), this is the complete, automated path this entire course has been building toward, one piece at a time.

Try It

  1. Write server.js, the small entry point tying validateEnv(), createApp(), and app.listen() together, and confirm it starts correctly with valid environment variables, and refuses to start without them.
  2. Write the Dockerfile above, and build it locally, on your own machine, confirming the resulting container runs the application correctly.
  3. Set up the CI/CD workflow on a real repository, and confirm the test job fails if the env: block is removed, exactly reproducing Module 1’s “refuses to start without configuration” behavior, now inside CI specifically.
  4. Explain, in one or two sentences, why the CI job needs its own JWT_SECRET and DATABASE_URL, distinct from whatever values production actually uses.

Recap

  • A multi-stage Dockerfile (Module 3) packages the combined application from Lesson 1 into a small, production-ready image.
  • A CI/CD workflow (Module 4) tests it with real, validated environment configuration, then builds and publishes the image, only for code that already passed.
  • The same validateEnv() function, and the same refusal to start without proper configuration, applies identically in CI, in a container, and in a real production deployment.

Next lesson: a final production readiness review, and where this course, and this entire track, leaves off.