Skip to main content

Command Palette

Search for a command to run...

Zero to Production: Deploying Next.js to AWS EC2 using Docker and GitHub Actions (Self-Hosted)

Updated
3 min read
Zero to Production: Deploying Next.js to AWS EC2 using Docker and GitHub Actions (Self-Hosted)

Deploying a modern Next.js app can feel overwhelming with all the choices available. In this guide, we’ll skip the "magic" of Vercel and build our own robust CI/CD pipeline using Docker, GitHub Container Registry (GHCR), and a Self-Hosted Runner on AWS EC2.

The Architecture

1. Prepare Next.js for Docker

To keep our Docker image tiny, we must enable Standalone Mode.

In your next.config.ts (or .mjs):

const nextConfig = {
  output: 'standalone',
};

2. The Dockerfile

We use a multi-stage build to ensure our production image is lightweight (~150MB).

FROM node:22-alpine AS deps
RUN apk add --no-cache libc6-compat
WORKDIR /app
COPY package*.json ./
RUN npm ci

FROM node:22-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build

FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
RUN addgroup --system --gid 1001 nodejs
RUN 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
CMD ["node", "server.js"]

3. Setting up the EC2 Runner

On your Amazon Linux 2023 instance, install Docker and the GitHub Runner.

Fixing Dependencies: Amazon Linux 2023 is minimal. You'll need libicu for the runner to work:

sudo dnf install -y libicu perl-Digest-SHA

Install Docker:

sudo dnf install -y docker
sudo systemctl enable --now docker
sudo usermod -aG docker ec2-user

Note: Log out and back in for group changes to take effect.

4. The GitHub Actions Workflow

The "Gotcha": Docker requires lowercase image names. We'll handle that in our script using tr.

Create .github/workflows/deploy.yml:

name: Build and Deploy
on:
  push:
    branches: [ "main" ]

env:
  REGISTRY: ghcr.io
  IMAGE_NAME: ${{ github.repository }}

jobs:
  build:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write
    steps:
      - uses: actions/checkout@v4
      - name: Log into GHCR
        uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
      - name: Build and push
        uses: docker/build-push-action@v5
        with:
          context: .
          push: true
          tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest

  deploy:
    needs: build
    runs-on: self-hosted
    steps:
      - name: Pull and Restart
        run: |
          # Lowercase the image name for Docker
          IMAGE_ID=$(echo ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} | tr '[:upper:]' '[:lower:]')

          docker pull $IMAGE_ID:latest
          docker stop nextjs-app || true
          docker rm nextjs-app || true
          docker run -d --name nextjs-app -p 80:3000 $IMAGE_ID:latest

Troubleshooting Common Errors

  • "Permission Denied" on Docker socket: Ensure your runner user is in the docker group and you've restarted the runner service (sudo ./svc.sh restart).
  • Invalid reference format: Always remember to lowercase your ${{ github.repository }} string before passing it to Docker.
  • Missing standalone folder: Ensure output: 'standalone' is in your Next config.

Conclusion

You now have a fully automated pipeline! Every time you push to main, GitHub builds your image, stores it in GHCR, and your EC2 instance pulls and deploys it instantly.