Deploying Next.js to a VPS with minimal CI/CD Pipeline using Docker, GitHub Actions and Caddy


Emir Azazi

30 Sep, 2025

Deploying Next.js apps tends to get over-engineered fast. For personal sites, dashboards, or internal tools, a straightforward VPS deploy is more than plenty. In this post we’ll set up a small CI/CD pipeline you configure once and then forget about. Every push to main turns into a Docker image, shipped to GitHub’s registry, fetched by your VPS, and swapped in behind Caddy with free HTTPS.

Prerequisites

  • A VPS with SSH access (domain optional but recommended).
  • An SSH key pair already set up for the VPS.
  • Docker & Docker Compose installed on the VPS.
  • Caddy installed and running as a systemd service.

Initialize the Next.js app

We’ll start with a vanilla next.js app. I named the app next-vps-tutorial and also use that as the container name later on:

Initialize project
1npx create-next-app@latest

CI/CD overview (the flow we’re building)

Here’s the simple pipeline we’ll draft, then implement:

Pipeline diagram
1[git push main]
2      |
3      v
4[GitHub Actions]
5Build Next.js image with Docker
6  - Push to GHCR (ghcr.io)
7      |
8      v
9[SSH into VPS]
10  - docker compose pull
11  - docker compose down && up -d
12      |
13      v
14[Caddy reverse proxy]
15  - HTTPS by default (Let's Encrypt)
16  - Reverse proxy :443 -> app:3000
17      |
18      v
19[page online 🌐]

Let's first dive into the building blocks of the pipeline. The Dockerfile specifies how we build the container in the GitHub Action. The docker-compose.yml defines from which registry we pull the container after it was build.

Dockerfile

We'll build the app in stages, so the final image is lean and ready for production. We only ship what we need to run the server on the VPS. This image will be built by GitHub Actions and run on the VPS.

Dockerfile
1# Base image
2FROM node:22-alpine AS base
3
4WORKDIR /app
5
6# Stage 1: Install dependencies
7FROM base AS deps
8RUN apk add --no-cache libc6-compat
9COPY package.json package-lock.json* ./
10RUN npm ci
11
12# Stage 2: Build the app
13FROM base AS builder
14COPY --from=deps /app/node_modules ./node_modules
15COPY . .
16RUN npm run build
17
18# Stage 3: Production image
19FROM base AS runner
20WORKDIR /app
21
22ENV NODE_ENV=production
23# disable telemetry during runtime.
24ENV NEXT_TELEMETRY_DISABLED=1
25
26COPY --from=builder /app ./
27
28EXPOSE 3000
29
30# Next.js port. Default is 3000
31ENV PORT=3000
32CMD ["npm", "start"]

docker-compose on the VPS

Compose defines how the container runs on the server. We’ll listen on port 3000 inside the VPS network; Caddy will proxy to it. Here we also define where we load the image from: ghcr.io/emirazazi/next-vps-tutorial:latest points to the image inside the registry.

docker-compose.yml
1services:
2  app:
3    container_name: next-vps-tutorial
4    image: ghcr.io/emirazazi/next-vps-tutorial:latest
5    ports:
6      - "3000:3000"
7    restart: unless-stopped
8
9volumes:
10  next_data:

GitHub Actions: build & deploy

Now we tie everything together with a deploy.yml workflow. This is the bread and butter which defines the pipeline. The workflow mainly does three things:

  1. Build and push the image to GHCR using docker/build-push-action@v5
  2. Copy the docker-compose.yml to the server using appleboy/scp-action@v1 so we can pull the latest image.
  3. SSH into the VPS and restart the container with the latest image using appleboy/ssh-action@v1.0.0.
.github/workflows/deploy.yml
1name: Build and Deploy
2
3on:
4  push:
5    branches: [ main ]
6  workflow_dispatch:
7
8env:
9  REGISTRY: ghcr.io
10  IMAGE_NAME: ${{ github.repository_owner }}/next-vps-tutorial
11
12jobs:
13  build-and-push:
14    name: Build and Push Docker Image
15    runs-on: ubuntu-latest
16    permissions:
17      contents: read
18      packages: write
19
20    steps:
21      - name: Checkout repository
22        uses: actions/checkout@v4
23
24      - name: Log in to GitHub Container Registry
25        uses: docker/login-action@v3
26        with:
27          registry: ${{ env.REGISTRY }}
28          username: ${{ github.actor }}
29          password: ${{ secrets.GITHUB_TOKEN }}
30
31      - name: Extract metadata
32        id: meta
33        uses: docker/metadata-action@v5
34        with:
35          images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
36          tags: |
37            type=ref,event=branch
38            type=sha,prefix={{branch}}-
39            type=raw,value=latest,enable={{is_default_branch}}
40
41      - name: Build and push Docker image
42        uses: docker/build-push-action@v5
43        with:
44          context: .
45          push: true
46          tags: ${{ steps.meta.outputs.tags }}
47          labels: ${{ steps.meta.outputs.labels }}
48
49  deploy:
50    name: Deploy to VPS
51    needs: build-and-push
52    runs-on: ubuntu-latest
53
54    steps:
55      - name: Checkout repository
56        uses: actions/checkout@v4
57
58      - name: Copy docker-compose.yml to server
59        uses: appleboy/scp-action@v1
60        with:
61          host: ${{ secrets.SSH_HOST }}
62          username: ${{ secrets.SSH_USER }}
63          key: ${{ secrets.SSH_PRIVATE_KEY }}
64          source: "docker-compose.yml"
65          target: "${{ secrets.WORK_DIR }}/"
66
67      - name: Deploy to VPS via SSH
68        uses: appleboy/ssh-action@v1.0.0
69        with:
70          host: ${{ secrets.SSH_HOST }}
71          username: ${{ secrets.SSH_USER }}
72          key: ${{ secrets.SSH_PRIVATE_KEY }}
73          script: |
74            cd ${{ secrets.WORK_DIR }}
75            # Log in to GitHub Container Registry
76            echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u ${{ github.actor }} --password-stdin
77            # Pull the latest image
78            docker compose pull
79            # Stop and remove old container
80            docker compose down
81            # Start new container
82            docker compose up -d
83            # Clean up old images
84            docker image prune -f

Repository secrets

Before we can start the pipeline we need to insert the credentials through the GitHub UI. Add these to your repo Settings → Secrets and variables → Actions → Repository Secrets:

Required secrets
1SSH_HOST: <ip of your vps>
2SSH_PRIVATE_KEY: <your private key contents>
3SSH_USER: root
4WORK_DIR: next-vps-tutorial

This way we keep the credentials out of the repo source code while letting the workflow log into the VPS.

On the VPS (one-time):

Create working directory
1mkdir -p /root/next-vps-tutorial

Note: the SCP step can create the target path automatically, but pre-creating avoids permission surprises.

Now we can trigger a deployment by pushing to the main branch. You should see following Action inside GitHub: site up over https

After the first successful deployment, verify:

Verify container
1docker ps
2# You should see: next-vps-tutorial ... Up ... 0.0.0.0:3000->3000/tcp

Caddy as the HTTPS reverse proxy

At this point the container is alive on the VPS, but not yet reachable over HTTP.

Caddy will terminate TLS and forward traffic to localhost:3000. We’ll use a clean sites-available / sites-enabled layout, similar to Nginx.

Why Caddy: automatic TLS (Let’s Encrypt), and a tidy config model. If you prefer to use nginx go for it. I won't hold you back.

Global Caddyfile

/etc/caddy/Caddyfile
1# Global options
2{
3  email <your email address>
4}
5# Import all enabled site configurations
6import /etc/caddy/sites-enabled/*.Caddyfile

App Caddyfile

Let's now create the config for the app. We expose the next.js server running on port 3000 to the domain: next-vps.tutorial.emirazazi.de. We also set some essential security headers. Create /etc/caddy/sites-available/next-vps-tutorial.Caddyfile.disabled with:

/etc/caddy/sites-available/next-vps-tutorial.Caddyfile.disabled
1next-vps-tutorial.emirazazi.de {
2    reverse_proxy localhost:3000 {
3        # Disable buffering for Next.js SSR streaming
4        flush_interval -1
5    }
6
7    # Essential headers
8    header {
9        X-Content-Type-Options "nosniff"
10        X-Frame-Options "DENY"
11        Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
12    }
13
14    # Simple CSP: only self + https resources
15    header {
16        Content-Security-Policy "
17            default-src 'self';
18            img-src 'self' https:;
19        "
20    }
21
22    # Enable compression
23    encode gzip
24}
25# redirect www to non-www https
26www.next-vps-tutorial.emirazazi.de {
27    redir https://next-vps-tutorial.emirazazi.de{uri} permanent
28}

Enable the site by symlinking the file into sites-enabled:

Enable site
1ln -s ../sites-available/next-vps-tutorial.Caddyfile.disabled /etc/caddy/sites-enabled/next-vps-tutorial.Caddyfile
2sudo systemctl reload caddy

SSL included by default: Caddy will automatically obtain and renew a Let’s Encrypt certificate for next-vps-tutorial.emirazazi.de as soon as DNS points to your VPS and the site is enabled. No extra steps required.


Congratulations 🏁

You've successfully build a minimal CI/CD pipeline for your Next.js application.

App reachable at https://next-vps-tutorial.emirazazi.de site up over https

Emir Azazi profile picture

Emir Azazi

Full Stack Engineer at Procon IT

I share lessons from building software. If you liked this post connect with me on GitHub or LinkedIn.