Key Takeaway
The clean way to deploy Next.js to a VPS: enable standalone output, wrap the app in a Docker image, push the image to GHCR from GitHub Actions, then have the workflow SSH into the VPS and run docker compose up -d --wait. Every push to main becomes a deploy, and a rollback is just a different image tag.
You have a Next.js app running on your laptop and an empty VPS. Then you get stuck: do you run npm run build on the server? Copy the folder over FTP? Install Node there? Most tutorials stop at ssh and git pull, yet the real problems come after that: the build eats the server's RAM, the Node version differs from your laptop, and every update has to be repeated by hand.
This guide shows a safer order of work for beginners: build once in GitHub Actions, ship the result as a Docker image, and let the VPS simply pull that image. It is the same order this site uses itself, with the Dockerfile and workflow from its repo as the base. Every address and name below is an example, so replace them with your own.
Want the concepts, not only the steps?
- Deploy Docker to a VPS with GitHub Actions and GHCR explains why jobs are chained with needs, when a build may be cancelled, and how secrets stay safe.
- Self-Host Next.js: Standalone Output, Docker and Env Vars explains what the runner image holds, build-time vs runtime env vars, and the health check.
What you need
Have these five things ready before you start. Without any one of them, the steps below stall halfway.
- A Next.js (App Router) project pushed to a GitHub repository, with a main branch named main.
- Docker installed on your laptop to try the image, and on the VPS (follow the official Docker install guide for your distro).
- A Linux VPS with SSH access as a user with sudo. In this example the address is 203.0.113.10, an address reserved for documentation.
- npm run lint and npm run build scripts in package.json, because the workflow in step 6 calls them.
- A reverse proxy such as nginx on the VPS to expose the app to the internet. This guide stops at a healthy container on a local port.
Step 1: Enable standalone output and try it on your laptop
Standalone makes next build write a .next/standalone folder holding a minimal server.js and only the node_modules files actually needed, so the folder runs without npm install. That is what keeps the image small and quick to ship. Add one line to the config.
// next.config.ts
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: "standalone", // build writes .next/standalone with its own server.js
};
export default nextConfig;Before wrapping it in Docker, run it on your laptop first. The Next.js docs say this minimal server does not copy the public and .next/static folders by default, so you copy them yourself. Note the difference between the wrong way and the right way.
npm run build
# Wrong: start it right away. The page loads but every script and style is a 404.
node .next/standalone/server.js
# Right: copy public and .next/static next to server.js first (Next.js docs)
cp -r public .next/standalone/ && cp -r .next/static .next/standalone/.next/
PORT=3000 HOSTNAME=0.0.0.0 node .next/standalone/server.jsWhat you should see: the server starts and prints the address it listens on. In the wrong version the HTML page appears without styles, and the browser's Network panel fills with 404s for files under /_next/static. In the right version the page renders fully. Once it does, move on to Docker.
Step 2: Write a multi-stage Dockerfile
This Dockerfile has three stages: deps installs dependencies, builder runs npm run build, and runner carries only the standalone result. It is a simplified version of this site's Dockerfile. Match the Node version to the one your project uses.
FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
FROM node:20-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:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV PORT=3000
# Listen on every interface inside the container, or the published port
# cannot reach the server.
ENV HOSTNAME=0.0.0.0
RUN addgroup --system --gid 1001 nodejs && adduser --system --uid 1001 nextjs
# Standalone leaves public and .next/static out on purpose: copy them yourself.
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
CMD ["node", "server.js"]Also add a .dockerignore so the node_modules from your laptop and a .env file full of secrets do not end up in the image. Then build and run the image on your laptop.
# .dockerignore
node_modules
.next
.git
.env
.env.*docker build -t myapp:test .
docker run --rm -p 3000:3000 myapp:test
# in another terminal:
curl -I http://localhost:3000/What you should see: docker build finishes without errors, and curl -I returns an HTTP status line from the Next.js server. If curl cannot connect, first check that ENV HOSTNAME and the -p 3000:3000 flag are in place.
Want an even leaner image? Read Next.js standalone Docker image size once your first deploy works.
Step 3: Prepare the VPS, a deploy user and an SSH key
GitHub Actions has to log in to the VPS without a password, so create a key pair just for the pipeline. Do not reuse your personal key, so that if it ever leaks you revoke one key only. The ssh-keygen and ssh-copy-id commands below use -t ed25519, -C for a comment and -f for the file name.
# On your laptop: a dedicated key pair for the pipeline, no passphrase
ssh-keygen -t ed25519 -C "github-actions-deploy" -f ./deploy_key
# On the VPS (as a sudo user): a non-root user that deploys
sudo adduser deploy
sudo usermod -aG docker deploy # docker must already be installed
sudo -u deploy mkdir -p /home/deploy/app
# Back on your laptop: authorise the PUBLIC key for that user
ssh-copy-id -i ./deploy_key.pub deploy@203.0.113.10
# Prove it works before GitHub ever tries
ssh -i ./deploy_key deploy@203.0.113.10 "docker ps"What you should see: the last command logs in without asking for a password, then docker ps prints a table of containers, empty if there are none yet. If you get Permission denied (publickey), the key is not installed for the deploy user; run ssh-copy-id again.
Adding a user to the docker group is equivalent to giving it root access. The Docker docs state that the docker group grants root-level privileges. That is a reasonable trade for a deploy user, but do not grant it to other users, and do not use this key for anything but deploying.
Step 4: Write the docker-compose.yml for the server
This compose file is what runs the container on the VPS. Notice that the image name is not hard-coded: it is read from the IMAGE variable in the .env file that the pipeline writes on each deploy. That is what makes one commit point at exactly one image.
# docker-compose.yml (copied to /home/deploy/app by the pipeline)
services:
web:
image: ${IMAGE}
container_name: myapp
restart: unless-stopped
env_file: .env
ports:
# Wrong: "3000:3000" opens the app to the whole internet, bypassing nginx.
# Right: loopback only. A reverse proxy is the way in.
- "127.0.0.1:3000:3000"
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://127.0.0.1:3000/"]
interval: 30s
timeout: 5s
retries: 3
start_period: 20sThis site uses the same pattern: the port is opened on 127.0.0.1 only, and nginx in front of it takes the traffic from the internet. The health check requests one cheap route from inside the container, and start_period gives the server time to boot before failures count. Keep this file at the repo root so the workflow can copy it to the server.
Step 5: Fill in secrets and variables on GitHub
The workflow reads the server address and key from GitHub, not from a file in the repo. According to the GitHub docs, the path looks like this.
- Open the repository on GitHub, then click Settings.
- In the Security section of the sidebar, click Secrets and variables, then Actions.
- On the Secrets tab click New repository secret, fill in Name and Secret, then Add secret. Repeat for the three secrets in the table.
- Switch to the Variables tab and create one variable, DEPLOY_PATH. It is not secret, so a variable is enough.
| Name | Value | Type |
|---|---|---|
| VPS_SSH_HOST | The VPS IP address or hostname, for example 203.0.113.10 | Secret |
| VPS_SSH_USER | The user name on the VPS, which is deploy | Secret |
| VPS_SSH_KEY | The full contents of the deploy_key file (the private key), including the BEGIN and END lines. Do not paste the .pub file. | Secret |
| DEPLOY_PATH | The folder on the server that holds the compose file, for example /home/deploy/app | Variable |
Once the key is stored in GitHub, delete the deploy_key file from your laptop or keep it in a password manager. A private key must never go into the repo, a chat or a screenshot.
Step 6: Write the GitHub Actions workflow
Create .github/workflows/deploy.yml. This workflow has three chained jobs: verify checks the commit, build-and-push builds the image and pushes it to GHCR, then deploy logs in to the VPS. It is a compact version of this site's deploy.yml.
# .github/workflows/deploy.yml
name: Deploy to VPS
on:
push:
branches: [main]
workflow_dispatch:
jobs:
verify:
runs-on: ubuntu-latest
concurrency:
group: build-${{ github.ref }}
cancel-in-progress: true
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- run: npm ci
- run: npx tsc --noEmit
- run: npm run lint
build-and-push:
needs: verify
runs-on: ubuntu-latest
concurrency:
group: build-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
packages: write
outputs:
image: ${{ steps.meta.outputs.image }}
steps:
- uses: actions/checkout@v4
# Registry names must be lowercase; your GitHub name may not be.
- name: Compute lowercase image ref
id: meta
run: |
ref="$(echo "ghcr.io/${{ github.repository }}" | tr '[:upper:]' '[:lower:]')"
echo "ref=${ref}" >> "$GITHUB_OUTPUT"
echo "image=${ref}:${{ github.sha }}" >> "$GITHUB_OUTPUT"
- uses: docker/setup-buildx-action@v3
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: |
${{ steps.meta.outputs.ref }}:latest
${{ steps.meta.outputs.ref }}:${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max
deploy:
needs: build-and-push
runs-on: ubuntu-latest
# A deploy must never be cancelled halfway through its SSH session.
concurrency:
group: deploy-vps
cancel-in-progress: false
permissions:
contents: read
packages: read
steps:
- uses: actions/checkout@v4
- name: Copy compose file to VPS
uses: appleboy/scp-action@v0.1.7
with:
host: ${{ secrets.VPS_SSH_HOST }}
username: ${{ secrets.VPS_SSH_USER }}
key: ${{ secrets.VPS_SSH_KEY }}
source: docker-compose.yml
target: ${{ vars.DEPLOY_PATH }}
- name: Deploy over SSH
uses: appleboy/ssh-action@v1.0.3
with:
host: ${{ secrets.VPS_SSH_HOST }}
username: ${{ secrets.VPS_SSH_USER }}
key: ${{ secrets.VPS_SSH_KEY }}
script: |
set -euo pipefail
cd "${{ vars.DEPLOY_PATH }}"
# Quoted heredoc: the shell expands nothing inside the values.
# Add your app secrets here as more KEY=value lines.
cat > .env <<'ENVEOF'
IMAGE=${{ needs.build-and-push.outputs.image }}
ENVEOF
chmod 600 .env
echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u "${{ github.actor }}" --password-stdin
docker compose pull
# --wait: fail the job if the container never becomes healthy
docker compose up -d --wait --remove-orphans
docker logout ghcr.ioThree things here often trip up beginners. First, needs keeps a commit that fails verify from ever reaching the server. Second, the image is tagged with the commit SHA, so the server runs exactly the build that just passed, not a latest tag that can move. Third, GITHUB_TOKEN is created by GitHub for each job, so there is no registry password for you to store. According to the GitHub docs, a newly published package is private by default, which is why the server needs docker login before pulling.
This site itself uses docker compose up -d without --wait, so a job can go green before the health check has passed. According to the Docker docs, --wait waits for services to be running or healthy and implies detached mode. I put it in the version above because I consider it safer for beginners.
Is the build slow? The cache strategies in Docker build cache for faster deploys can be added once this basic pipeline works.
Step 7: Push, watch and prepare a rollback
Commit everything and push to main. Open the Actions tab on GitHub and wait for all three jobs to go green. Then check from the server side.
# On the VPS, as the deploy user
cd /home/deploy/app
docker compose ps # STATUS should read "healthy" once start_period passes
docker compose logs --tail 50 web
curl -I http://127.0.0.1:3000/What you should see: the container STATUS changes from starting to healthy once start_period has passed, and curl returns an HTTP response from your app. If a new deploy misbehaves you do not need to rebuild anything: because every image has a SHA tag, a rollback only means pointing IMAGE at the old tag.
# Roll back: point IMAGE at an older commit tag and recreate the container
cd /home/deploy/app
docker image ls ghcr.io/your-github-name/your-repo # find the older SHA tag
sed -i 's|^IMAGE=.*|IMAGE=ghcr.io/your-github-name/your-repo:OLD_SHA|' .env
docker compose up -d --waitThe container is recreated on deploy, so there is a short gap. If that gap is not acceptable, study zero-downtime rolling deploys as your next step.
Common errors and how to fix them
These are the ones that hit most often the first time. Match the symptom you see in the log with the first column.
| Symptom | Cause | Fix |
|---|---|---|
| Permission denied (publickey) during deploy or a manual ssh | The public key is not in the deploy user's authorized_keys, or the VPS_SSH_KEY secret holds the wrong file. | Run ssh-copy-id again, test with ssh -i, then paste the full private key (BEGIN to END) into the secret again. |
| invalid reference format, repository name must be lowercase | The GitHub owner or repository name has capital letters, while a registry image name must be lowercase. | Lowercase it with tr as in the Compute lowercase image ref step, and never type the image name by hand. |
| pull access denied or unauthorized on docker compose pull | The package on GHCR is private and the server has not logged in to ghcr.io. | Make sure the docker login ghcr.io line with --password-stdin runs before the pull, and that the deploy job has packages: read permission. |
| permission denied on docker.sock on the server | The deploy user is not in the docker group, or the session has not been reloaded. | Run sudo usermod -aG docker deploy, then log out and back in (or newgrp docker). |
| The page shows without styles and many 404s under /_next/static | The public and .next/static folders were not copied into the runner image. | Add the two COPY lines to the runner stage of the Dockerfile, then rebuild the image. |
| The container runs on the server but cannot be opened from the internet | The port is deliberately open on 127.0.0.1 only, and no reverse proxy forwards to it yet. | Set up nginx to proxy to 127.0.0.1:3000. Do not change it to 3000:3000 just to get it open quickly, because that bypasses the proxy. |
| The deploy job is green, but the site is on the old version or erroring | docker compose up -d returns as soon as the container starts, before the health check passes, so a broken release still looks successful. | Use up -d --wait so the job fails when the container is not healthy, then read docker compose logs web. |
| no space left on device during build or pull | The disk is full because each deploy pulls a new image with a different SHA tag and the old ones pile up. | Run docker image prune -af --filter until=72h on the server. This site puts that command at the end of its deploy script. |
Checklist before your first push
- next.config.ts uses standalone output, and node .next/standalone/server.js runs on your laptop.
- The Dockerfile copies public and .next/static, and docker run answers on port 3000.
- ssh -i deploy_key deploy@your-vps-address logs in without a password.
- Three secrets and one variable are filled in, and the deploy_key file is deleted from your laptop.
- The port in the compose file is bound to 127.0.0.1, and a reverse proxy is ready.
- You know how to roll back: change IMAGE in .env to an old SHA tag, then docker compose up -d --wait.
Go deeper
This guide answers what the order is. If you want to understand why each part is built that way, these two free lessons cover it with interactive diagrams and a short quiz.
- Lesson: deploy Docker to a VPS with GitHub Actions and GHCR
- Lesson: self-host Next.js with standalone output and Docker
The rule to carry away: build in CI, ship an image, and let the server only pull and run. One commit becomes one SHA-tagged image, and that makes a deploy repeatable and reversible.
Sources and references
- Next.js docs: output (standalone and file tracing)
- Docker docs: docker compose up (--wait, --remove-orphans)
- GitHub docs: Working with the Container registry
- GitHub docs: Using secrets in GitHub Actions
- Docker docs: Linux post-installation steps (the docker group)
- appleboy/ssh-action README (available inputs)

