Back to Blog

A CI/CD Pipeline with GitHub Actions, Docker, and EC2

From git push to production with zero manual steps

16 min read
  • GitHub Actions
  • Docker
  • EC2
  • CI/CD
  • Next.js
  • DevOps
A CI/CD Pipeline with GitHub Actions, Docker, and EC2

Every time a dev push to main, a fully automated pipeline starts: it lints and type-checks the code, builds the Next.js app, packages it into Docker images, pushes those images to Docker Hub, and finally deploys them onto a live EC2 instance — with zero manual steps in between.

In this post I’ll walk through exactly how that pipeline works, using the real configuration from BookFetch — a full-stack Next.js bookstore — including the GitHub Actions workflow, a multi-stage Dockerfile, two docker-compose files (one for local dev, one for production), and a small deployment script that runs on the server.

The big picture

The pipeline has four stages that run in sequence, each gating the next:

CI/CD pipeline stages: quality checks, build, Docker build and push, deploy to EC2

If any stage fails, the pipeline stops there — a broken build or a failing lint check never makes it anywhere near Docker Hub or the production server. The docker and deploy jobs also only run on pushes to main (not on pull requests), so opening a PR gives you fast feedback without ever touching production infrastructure.

Here’s the whole thing, defined in .github/workflows/ci-cd.yml:

name: CI/CD

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

concurrency:
  group: ci-cd-${{ github.ref }}
  cancel-in-progress: true

Two small but important details up front:

  • It triggers on both push and pull_request. PRs get the quality and build jobs so you catch problems before merging, but docker and deploy are gated with if: github.event_name == 'push' && github.ref == 'refs/heads/main'.
  • concurrency cancels in-flight runs. If you push twice in quick succession, the older run for the same branch is cancelled rather than racing the newer one to deploy — which matters a lot once you get to the “SSH into the server and run docker compose up” stage.

Let’s go through each job.

Stage 1: Quality checks

jobs:
  quality:
    name: Quality checks
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: npm
      - run: npm ci
      - run: npm run type-check
      - run: npm run lint
      - run: npm run format:check

This is the cheapest, fastest feedback loop: TypeScript type-checking, ESLint, and a Prettier format check. npm ci (rather than npm install) is used because it installs exactly what’s in package-lock.json, which keeps CI runs reproducible and noticeably faster thanks to npm’s caching.

Note the HUSKY: 0 environment variable set at the workflow level — this disables Husky git hooks during npm ci, since there’s no .git hook context to run them against in CI, and no .env in the runner either, so a placeholder DATABASE_URL is provided for anything that needs one to boot (like Prisma’s client generation).

Stage 2: Build

build:
  name: Build
  needs: quality
  runs-on: ubuntu-latest
  steps:
    - uses: actions/checkout@v4
    - uses: actions/setup-node@v4
      with:
        node-version: 22
        cache: npm
    - run: npm ci
    - run: npm run build

With needs: quality, this job won’t even start until linting and type-checking pass. It runs a full next build to make sure the application actually compiles in a clean environment — separate from whatever might be cached on a developer’s laptop. This is deliberately kept separate from the Docker stage: it’s a fast sanity check that fails loudly (and cheaply, without spinning up Buildx) before we invest time in building container images.

Stage 3: Docker build and push

This is where the pipeline starts producing real deployable artifacts.

docker:
  name: Docker build and push
  needs: build
  runs-on: ubuntu-latest
  if: github.event_name == 'push' && github.ref == 'refs/heads/main'
  steps:
    - uses: actions/checkout@v4
    - uses: docker/setup-buildx-action@v3
    - uses: docker/login-action@v3
      with:
        username: ${{ secrets.DOCKERHUB_USERNAME }}
        password: ${{ secrets.DOCKERHUB_TOKEN }}

    - name: Build and push app image
      uses: docker/build-push-action@v6
      with:
        context: .
        file: Dockerfile
        target: runner
        push: true
        tags: |
          ${{ secrets.DOCKERHUB_USERNAME }}/bookstore-app:${{ github.sha }}
          ${{ secrets.DOCKERHUB_USERNAME }}/bookstore-app:latest
        cache-from: type=gha
        cache-to: type=gha,mode=max

    - name: Build and push migrate image
      uses: docker/build-push-action@v6
      with:
        context: .
        file: Dockerfile
        target: migrator
        push: true
        tags: |
          ${{ secrets.DOCKERHUB_USERNAME }}/bookstore-migrate:${{ github.sha }}
          ${{ secrets.DOCKERHUB_USERNAME }}/bookstore-migrate:latest
        cache-from: type=gha
        cache-to: type=gha,mode=max

A few design choices worth calling out:

  • Two images, one Dockerfile. The target field tells Buildx which stage of the multi-stage Dockerfile to build: runner produces the slim app image that actually serves traffic, and migrator produces a separate image whose only job is to run prisma migrate deploy against the production database. Splitting these means the app container never needs the full build toolchain or migration CLI baked in.
  • Dual tagging. Every image is pushed as both :${{ github.sha }} (an immutable, traceable tag) and :latest. The SHA tag is what actually gets deployed — it’s exact and reproducible. :latest is kept around mostly for convenience (e.g., pulling manually to debug).
  • GitHub Actions cache (type=gha). Docker layer caching is persisted across workflow runs using GitHub’s own cache backend, so unchanged layers (like npm ci in the deps stage) don’t get rebuilt on every push. This is a big win for build time as the dependency tree grows.

Inside the Dockerfile

The multi-stage Dockerfile this job targets looks like this:

FROM node:22-alpine AS base
RUN apk add --no-cache libc6-compat openssl
WORKDIR /app

FROM base AS deps
COPY package.json package-lock.json ./
COPY prisma ./prisma
COPY prisma.config.ts ./
RUN npm ci

FROM base AS builder
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npx prisma generate && npm run build

FROM builder AS migrator
CMD ["node", "node_modules/prisma/build/index.js", "migrate", "deploy"]

FROM base AS runner
RUN addgroup --system --gid 1001 nodejs \
  && adduser --system --uid 1001 --ingroup nodejs nextjs
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
COPY --chmod=755 docker/entrypoint.sh ./docker-entrypoint.sh
USER nextjs
EXPOSE 3000
ENTRYPOINT ["./docker-entrypoint.sh"]
CMD ["node", "server.js"]

Each stage has a clear job:

  1. base — a shared Alpine Node.js image with the native libraries (libc6-compat, openssl) that Prisma’s engine needs to run on musl-based Alpine.
  2. deps — installs node_modules in isolation, keyed only on package.json/package-lock.json. Docker’s layer cache means this only re-runs when dependencies actually change, not on every code edit.
  3. builder — copies in the rest of the source, generates the Prisma client, and runs next build. Next.js’s standalone output mode is what makes the next stage possible.
  4. migrator — branches off builder and just overrides the CMD to run prisma migrate deploy. It reuses the exact same build output, so the migration always matches the schema the app was built against.
  5. runner — the actual production image, built from the lightweight base (not builder), copying in only the standalone server bundle, static assets, and public files. It also creates and switches to a non-root nextjs user before exposing port 3000 — a basic but important container security practice.

This “standalone + non-root final stage” pattern keeps the production image small (no node_modules, no source code, no dev dependencies) and reduces the attack surface.

Stage 4: Deploy to EC2

Once images are pushed, the final job ships them to the server:

deploy:
  name: Deploy to EC2
  needs: docker
  runs-on: ubuntu-latest
  if: github.event_name == 'push' && github.ref == 'refs/heads/main'
  steps:
    - uses: actions/checkout@v4

    - name: Prepare deploy bundle
      run: |
        mkdir -p deploy-bundle
        cp docker-compose.prod.yml deploy-bundle/
        cp scripts/ec2-deploy.sh deploy-bundle/

    - name: Ensure deploy directory exists on EC2
      uses: appleboy/ssh-action@v1.2.0
      with:
        host: ${{ secrets.EC2_HOST }}
        username: ${{ secrets.EC2_USER }}
        key: ${{ secrets.EC2_SSH_PRIVATE_KEY }}
        script: |
          sudo mkdir -p /opt/bookstore
          sudo chown -R "$USER":"$USER" /opt/bookstore

    - name: Copy deploy files to EC2
      uses: appleboy/scp-action@v0.1.7
      with:
        host: ${{ secrets.EC2_HOST }}
        username: ${{ secrets.EC2_USER }}
        key: ${{ secrets.EC2_SSH_PRIVATE_KEY }}
        source: deploy-bundle/*
        target: /opt/bookstore
        strip_components: 1

    - name: Deploy via SSH
      uses: appleboy/ssh-action@v1.2.0
      with:
        host: ${{ secrets.EC2_HOST }}
        username: ${{ secrets.EC2_USER }}
        key: ${{ secrets.EC2_SSH_PRIVATE_KEY }}
        script: |
          chmod +x /opt/bookstore/ec2-deploy.sh
          /opt/bookstore/ec2-deploy.sh ${{ github.sha }}

Notice what’s not in this job: there’s no source code checkout onto the server, no npm install, no build step running on the EC2 box at all. The server only ever receives two small files — docker-compose.prod.yml and ec2-deploy.sh — via scp. Everything else it needs (the actual application) is pulled as a pre-built image from Docker Hub. This keeps the EC2 instance dumb and stateless from a deployment perspective: it doesn’t need Node.js, a build toolchain, or even a copy of the repository.

Authentication to the server is handled entirely through GitHub Secrets (EC2_HOST, EC2_USER, EC2_SSH_PRIVATE_KEY), so no credentials ever touch the workflow file itself.

The final step hands off to a shell script on the server, passing the commit SHA as an argument — this is the same SHA that was used to tag the Docker images two jobs ago, which is what ties the whole pipeline together.

The deploy script

#!/bin/bash
set -euo pipefail

IMAGE_TAG="${1:?Usage: ec2-deploy.sh <git-sha>}"

cd /opt/bookstore

if [ ! -f .env ]; then
  echo "Missing /opt/bookstore/.env — create it before deploying." >&2
  exit 1
fi

set -a
source .env
set +a

echo "$DOCKERHUB_TOKEN" | docker login -u "$DOCKERHUB_USERNAME" --password-stdin

export DOCKER_IMAGE_TAG="$IMAGE_TAG"

docker compose -f docker-compose.prod.yml pull
docker compose -f docker-compose.prod.yml up -d --remove-orphans
docker image prune -f

echo "Deployed image tag: $DOCKER_IMAGE_TAG"

A few things make this script safe to run unattended, repeatedly, over SSH:

  • set -euo pipefail ensures any failure (missing file, failed command, unset variable) stops the script immediately instead of silently continuing with a half-broken deploy.
  • It refuses to run without a .env file. Secrets like JWT_ACCESS_SECRET, JWT_REFRESH_SECRET, and the Docker Hub credentials live only on the server in /opt/bookstore/.env — they’re never passed through GitHub Actions or committed anywhere.
  • DOCKER_IMAGE_TAG is exported, not hardcoded. This env var is what docker-compose.prod.yml interpolates into the image references, so the exact commit SHA built two stages earlier is what actually gets deployed — no ambiguity, no accidentally running a stale :latest.
  • docker compose pull before up -d guarantees the new images are fetched before containers are recreated, and --remove-orphans cleans up any containers from services that may have been removed from the compose file over time.
  • docker image prune -f at the end keeps old, now-unused image layers from slowly filling up the EC2 instance’s disk over months of deploys.

Local dev vs. production compose

It’s worth comparing the two compose files side by side, since the differences reveal the design intent:

# docker-compose.yml (local dev)
app:
  build:
    context: .
    dockerfile: Dockerfile
    target: runner
  ports:
    - "${APP_PORT:-3000}:3000"
# docker-compose.prod.yml (production)
app:
  image: "${DOCKERHUB_USERNAME}/bookstore-app:${DOCKER_IMAGE_TAG:-latest}"
  pull_policy: always
  ports:
    - "${APP_PORT:-3000}:3000"

Locally, docker-compose.yml builds the image from source on your machine (build: + target: runner) — convenient for iterating without needing to push anywhere. In production, docker-compose.prod.yml never builds anything; it only references pre-built images by tag (image: + pull_policy: always). This is exactly the separation you want: build once in CI, run everywhere else.

Both files share the same shape otherwise — a postgres service with a health check, a migrate service that runs prisma migrate deploy and must complete successfully (condition: service_completed_successfully) before the app service starts, and the same required environment variables (JWT_ACCESS_SECRET, JWT_REFRESH_SECRET) enforced via Compose’s ${VAR:?error message} syntax, which fails fast with a clear error if a secret is missing rather than booting the app in a broken state.

Putting it all together

Here’s the full lifecycle of a single commit to main:

  1. Push to main.
  2. quality runs type-checking, linting, and format checks against the new code.
  3. build compiles the app in a clean CI environment to catch build-time errors early.
  4. docker builds two images from the same Dockerfile (runner for the app, migrator for schema migrations), tags both with the commit SHA and latest, and pushes them to Docker Hub — using GitHub Actions’ cache to keep rebuilds fast.
  5. deploy copies docker-compose.prod.yml and ec2-deploy.sh to the EC2 instance, then runs the script remotely with the commit SHA as the target image tag.
  6. On the server, ec2-deploy.sh logs into Docker Hub, pulls the tagged images, runs migrate to bring the database schema up to date, starts the app container, and prunes old images.

The result is a deploy pipeline where:

  • Every deployed artifact is traceable back to an exact commit via its image tag.
  • The build happens exactly once, in CI — never on the production server.
  • Secrets stay where they belong: GitHub Secrets for CI/SSH access, and a server-local .env for runtime application secrets.
  • A single git push is the only manual action required to ship a change from a laptop to production.

That’s the whole pipeline — no external CD platform, no Kubernetes cluster, just GitHub Actions, Docker Hub, and a bash script over SSH. For a small-to-medium production app, it’s a surprisingly complete and low-maintenance setup.

If you want to explore the full setup, clone the repo, or run the project locally, everything lives in the BookFetch repository on GitHub.