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:
1npx create-next-app@latestCI/CD overview (the flow we’re building)
Here’s the simple pipeline we’ll draft, then implement:
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.
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 /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 /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.
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:
- Build and push the image to GHCR using
docker/build-push-action@v5 - Copy the docker-compose.yml to the server using
appleboy/scp-action@v1so we can pull the latest image. - SSH into the VPS and restart the container with the latest image using
appleboy/ssh-action@v1.0.0.
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 -fRepository 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:
1SSH_HOST: <ip of your vps>
2SSH_PRIVATE_KEY: <your private key contents>
3SSH_USER: root
4WORK_DIR: next-vps-tutorialThis way we keep the credentials out of the repo source code while letting the workflow log into the VPS.
On the VPS (one-time):
1mkdir -p /root/next-vps-tutorialNote: 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:

After the first successful deployment, verify:
1docker ps
2# You should see: next-vps-tutorial ... Up ... 0.0.0.0:3000->3000/tcpCaddy 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
1# Global options
2{
3 email <your email address>
4}
5# Import all enabled site configurations
6import /etc/caddy/sites-enabled/*.CaddyfileApp 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:
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:
1ln -s ../sites-available/next-vps-tutorial.Caddyfile.disabled /etc/caddy/sites-enabled/next-vps-tutorial.Caddyfile
2sudo systemctl reload caddySSL included by default: Caddy will automatically obtain and renew a Let’s Encrypt certificate for
next-vps-tutorial.emirazazi.deas 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

