A CI/CD Pipeline with GitHub Actions, Docker, and EC2
From git push to production with zero manual steps
Shipping should be boring. Every time you deploy by hand you invent a slightly different sequence of steps, and one of those variations eventually takes production down. The fix is to encode the whole path — lint, build, image, push, restart — in a pipeline that runs the same way on every commit.
This post builds that pipeline for a Next.js app running on a single EC2 instance with Docker. It's the setup I reach for when a project is too small for Kubernetes but too important to deploy over SSH by hand.
What we're building
The flow has four stages:
- Verify — install dependencies, typecheck, lint, and run tests.
- Build — produce a multi-stage Docker image tagged with the commit SHA.
- Publish — push that image to GitHub Container Registry (GHCR).
- Deploy — SSH into EC2, pull the new tag, and restart via Docker Compose.
Tagging with the commit SHA matters. latest tells you nothing about what is
running, and rolling back becomes guesswork. With SHA tags a rollback is one
docker compose up away.
A production Dockerfile for Next.js
Next.js has a standalone output mode that copies only the files the server actually needs. Enable it first:
// next.config.ts
const nextConfig = {
output: "standalone",
};
export default nextConfig;
Then build in stages so the final image never carries the toolchain:
# syntax=docker/dockerfile:1
FROM node:22-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
FROM node:22-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
RUN npm run build
FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
RUN addgroup --system --gid 1001 nodejs \
&& adduser --system --uid 1001 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
USER nextjs
EXPOSE 3000
ENV PORT=3000 HOSTNAME=0.0.0.0
CMD ["node", "server.js"]
Three details that save pain later:
npm ciwith only the lockfile copied first means dependency layers stay cached until dependencies actually change.- The image runs as a non-root user, so a container escape doesn't hand over the host.
HOSTNAME=0.0.0.0is required — the standalone server binds to localhost otherwise and nginx can't reach it.
Keeping the build context small
A .dockerignore is not optional. Without it you ship node_modules and
.next from your laptop into the build context:
node_modules
.next
.git
.env*.local
npm-debug.log*
Dockerfile
.dockerignore
Compose on the server
The server only needs to know how to run the image, not how to build it:
# docker-compose.yml
services:
web:
image: ghcr.io/${GITHUB_REPOSITORY}:${IMAGE_TAG}
restart: unless-stopped
ports:
- "127.0.0.1:3000:3000"
env_file:
- .env.production
healthcheck:
test: ["CMD", "wget", "--spider", "-q", "http://localhost:3000/api/health"]
interval: 30s
timeout: 5s
retries: 3
start_period: 20s
Binding to 127.0.0.1:3000 instead of 0.0.0.0:3000 keeps the app off the
public internet. Only nginx talks to it directly.
The healthcheck needs an endpoint to hit:
// app/api/health/route.ts
export const dynamic = "force-dynamic";
export function GET() {
return Response.json({ status: "ok", uptime: process.uptime() });
}
The verify job
Fail fast and cheap. If lint breaks, don't spend three minutes building an image:
# .github/workflows/deploy.yml
name: Deploy
on:
push:
branches: [main]
workflow_dispatch:
concurrency:
group: deploy-${{ github.ref }}
cancel-in-progress: true
jobs:
verify:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- run: npx tsc --noEmit
- run: npm run lint
- run: npm test --if-present
The concurrency block is the piece people skip. Push twice in a minute
without it and two deploy jobs race to restart the same container.
Build and publish the image
GHCR authenticates with the built-in GITHUB_TOKEN, so there's no extra secret
to manage:
build:
needs: verify
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
outputs:
image: ${{ steps.meta.outputs.image }}
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: meta
run: |
IMAGE="ghcr.io/${GITHUB_REPOSITORY,,}"
echo "image=$IMAGE" >> "$GITHUB_OUTPUT"
- uses: docker/build-push-action@v6
with:
context: .
push: true
tags: |
${{ steps.meta.outputs.image }}:${{ github.sha }}
${{ steps.meta.outputs.image }}:latest
cache-from: type=gha
cache-to: type=gha,mode=max
cache-from/cache-to with the GitHub Actions cache backend is what turns a
four-minute build into a forty-second one on dependency-stable commits.
Note ${GITHUB_REPOSITORY,,} — the bash lowercase expansion. GHCR rejects
uppercase image names, and repository names often contain them.
Deploying to EC2
Create a dedicated deploy user on the instance rather than reusing ubuntu,
add it to the docker group, and put the workflow's public key in its
authorized_keys. Then the deploy job is a short SSH session:
deploy:
needs: build
runs-on: ubuntu-latest
environment: production
steps:
- uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.EC2_HOST }}
username: ${{ secrets.EC2_USER }}
key: ${{ secrets.EC2_SSH_KEY }}
script: |
set -euo pipefail
cd /srv/app
echo "${{ secrets.GHCR_TOKEN }}" \
| docker login ghcr.io -u ${{ github.actor }} --password-stdin
export GITHUB_REPOSITORY="${{ needs.build.outputs.image }}"
export IMAGE_TAG="${{ github.sha }}"
docker compose pull web
docker compose up -d --no-deps web
docker image prune -f --filter "until=168h"
set -euo pipefail is the difference between a failed deploy that reports
failure and one that silently reports success. Without -e, a failing
docker compose pull still lets the script exit 0.
Verifying the rollout
Restarting is not deploying. Confirm the new container is actually healthy before calling the job green:
for i in $(seq 1 20); do
status=$(docker inspect --format='{{.State.Health.Status}}' app-web-1)
[ "$status" = "healthy" ] && exit 0
sleep 3
done
echo "Container failed to become healthy" >&2
docker compose logs --tail 80 web
exit 1
Dumping the last 80 log lines on failure means the Actions run itself tells you what went wrong, instead of sending you to SSH in and look.
Nginx in front
Nginx terminates TLS and proxies to the container:
server {
listen 443 ssl http2;
server_name example.com;
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
location / {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /_next/static/ {
proxy_pass http://127.0.0.1:3000;
proxy_cache_valid 200 60m;
add_header Cache-Control "public, max-age=31536000, immutable";
}
}
Forwarding X-Forwarded-Proto matters more than it looks: without it, code
that builds absolute URLs will happily generate http:// links on an HTTPS
site.
Rolling back
Because every image is tagged with its commit, a rollback is a one-liner on the box:
IMAGE_TAG=<previous-sha> docker compose up -d --no-deps web
Keep it in a script called rollback.sh in the repo. During an incident nobody
remembers flags.
What to add next
The pipeline above is production-adequate for a single instance. When traffic justifies it, the natural next steps are:
- Zero-downtime restarts — run two replicas behind nginx and cycle them one at a time, or move to ECS/Fargate where rolling deploys are built in.
- Migrations as a separate step — run them before the new image starts, and keep them backwards compatible so old and new containers can coexist.
- Deployment notifications — a Slack message with the SHA and author turns "who broke prod" into a five-second lookup.
The important part is already done: nobody has to remember the steps, and every deploy is identical to the last one.