A step-by-step guide for students to build a Next.js app, containerize it with Docker, and ship it automatically using GitHub Actions.
By the end you will have:
- A Next.js static site served by Nginx inside Docker
- A CI/CD pipeline that tests every PR and publishes a Docker image to GHCR on every push to
main - Slack notifications on each deployment
- What You Will Build
- Tech Stack
- Prerequisites
- Step 1 — Create a Next.js App
- Step 2 — Configure Static Export
- Step 3 — Dockerize the App
- Step 4 — Run Locally with Docker
- Step 5 — Set Up GitHub Actions CI/CD
- Step 6 — Set Up Slack Notifications
- Project Structure
A single-page Next.js app that is exported as pure static HTML/CSS/JS and served by a rootless Nginx container. No Node.js runtime is needed after the build. GitHub Actions handles testing, building, and pushing the Docker image to GitHub Container Registry automatically.
| Layer | Technology |
|---|---|
| Framework | Next.js 16 (App Router, static export) |
| Styling | Tailwind CSS v4 |
| Language | TypeScript |
| Package manager | pnpm |
| Container runtime | Docker (multi-stage build) |
| Web server | Nginx (nginx-unprivileged, rootless) |
| Registry | GitHub Container Registry (GHCR) |
| CI/CD | GitHub Actions |
| Notifications | Slack Incoming Webhooks |
Install these before you begin:
- Node.js 24+
- pnpm —
npm install -g pnpm - Docker
- A GitHub account with a new empty repository created
Scaffold a new Next.js project with pnpm:
pnpm create next-app@latest my-app --typescript --tailwind --eslint --app --src-dir no --import-alias "@/*"
cd my-appStart the dev server to verify everything works:
pnpm devOpen http://localhost:3000.
| Command | What it does |
|---|---|
pnpm dev |
Dev server with hot reload |
pnpm build |
Generates static export to /out |
pnpm lint |
Runs ESLint |
pnpm test:ci |
Lint + build (used by CI) |
Add the CI script to your package.json:
"scripts": {
"test:ci": "pnpm lint && pnpm build"
}Open next.config.ts and set:
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: "export",
images: {
unoptimized: true,
},
};
export default nextConfig;output: "export" tells Next.js to write plain HTML/CSS/JS to /out instead of starting a Node server.
Create a Dockerfile in the project root with three stages:
ARG NODE_VERSION=24.13.0-slim
ARG NGINXINC_IMAGE_TAG=alpine3.22
# Stage 1: install dependencies
FROM node:${NODE_VERSION} AS dependencies
WORKDIR /app
COPY package.json pnpm-lock.yaml* ./
RUN --mount=type=cache,target=/root/.local/share/pnpm/store \
corepack enable pnpm && pnpm install --frozen-lockfile
# Stage 2: build
FROM node:${NODE_VERSION} AS builder
WORKDIR /app
COPY --from=dependencies /app/node_modules ./node_modules
COPY . .
ENV NODE_ENV=production
RUN --mount=type=cache,target=/app/.next/cache \
corepack enable pnpm && pnpm build
# Stage 3: serve with Nginx
FROM nginxinc/nginx-unprivileged:${NGINXINC_IMAGE_TAG} AS runner
COPY nginx.conf /etc/nginx/nginx.conf
COPY --from=builder /app/out /usr/share/nginx/html
USER nginx
EXPOSE 8080
ENTRYPOINT ["nginx", "-c", "/etc/nginx/nginx.conf"]
CMD ["-g", "daemon off;"]Why three stages? Each stage only carries what it needs into the next. The final image contains only Nginx and your static files — no Node.js, no source code.
worker_processes 1;
pid /tmp/nginx.pid;
events { worker_connections 1024; }
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
sendfile on;
server {
listen 8080;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri.html $uri/ =404;
}
location ~ ^/_next/ {
try_files $uri =404;
expires 1y;
add_header Cache-Control "public, immutable";
}
error_page 404 /404.html;
}
}services:
nextjs-static-export:
build:
context: .
dockerfile: Dockerfile
ports:
- "8080:8080"
restart: unless-stoppeddocker compose up -d --buildOpen http://localhost:8080. You should see your app served by Nginx.
# Stop the container
docker compose downgit init
git add .
git commit -m "initial commit"
git remote add origin https://github.com/<your-username>/<your-repo>.git
git push -u origin mainCreate .github/workflows/ci-cd.yml:
name: CI/CD
on:
pull_request:
types: [opened, synchronize, reopened]
push:
branches: [main]
jobs:
test:
name: Test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 10
- uses: actions/setup-node@v4
with:
node-version: 24
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm test:ci
docker:
name: Docker Publish
runs-on: ubuntu-latest
needs: test
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- id: image
run: echo "image=ghcr.io/${GITHUB_REPOSITORY,,}" >> "$GITHUB_OUTPUT"
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- id: meta
uses: docker/metadata-action@v5
with:
images: ${{ steps.image.outputs.image }}
tags: |
type=raw,value=latest
type=sha,format=long
- uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}On every PR:
test job → pnpm lint + pnpm build
On push to main:
test job → docker job
├── Login to GHCR
├── Build Docker image
└── Push with two tags:
ghcr.io/<owner>/<repo>:latest
ghcr.io/<owner>/<repo>:sha-<commit>
No extra secrets are needed — the workflow uses the built-in GITHUB_TOKEN to push to GHCR.
The pipeline sends a Block Kit message to Slack after every deployment. Follow the official Slack docs for full details.
- Go to https://api.slack.com/apps → Create New App → From scratch
- Name it (e.g.
my-app-ci) and select your workspace → Create App
- In your app settings, select Incoming Webhooks from the left sidebar
- Toggle Activate Incoming Webhooks to On
- Click Add New Webhook to Workspace, pick a channel, then click Allow
- Copy the URL — it looks like:
https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXXXXXXKeep it secret. Never commit this URL to version control. Slack actively revokes leaked secrets.
- Repo → Settings → Secrets and variables → Actions → New repository secret
- Name:
SLACK_WEBHOOK_URL - Value: paste the webhook URL
Append this step inside the docker job, after the build-push step:
- name: Slack notification
if: always()
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
STATUS: ${{ job.status }}
IMAGE: ${{ steps.image.outputs.image }}
SHA: ${{ github.sha }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: |
if [ -z "$SLACK_WEBHOOK_URL" ]; then
echo "SLACK_WEBHOOK_URL secret not set, skipping."
exit 0
fi
payload=$(cat <<EOF
{
"text": ":rocket: CI/CD ${STATUS} — ${{ github.repository }}",
"blocks": [
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": ":rocket: *CI/CD ${STATUS}*\n*Repo:* ${{ github.repository }}\n*Image:* ${IMAGE}\n*Commit:* ${SHA}\n*Run:* <${RUN_URL}|View run>"
}
}
]
}
EOF
)
curl -sS -X POST -H "Content-Type: application/json" \
--data "$payload" \
"$SLACK_WEBHOOK_URL"When it works, you will see a message like this in your channel:
my-app/
├── .github/
│ └── workflows/
│ └── ci-cd.yml # GitHub Actions pipeline
├── app/
│ ├── globals.css # Global Tailwind styles
│ ├── layout.tsx # Root layout
│ └── page.tsx # Landing page
├── public/ # Static assets
├── Dockerfile # Multi-stage Docker build
├── compose.yml # Docker Compose for local testing
├── nginx.conf # Nginx config (rootless, port 8080)
├── next.config.ts # Static export config
├── package.json # Scripts and dependencies
└── pnpm-lock.yaml # Locked dependency tree
