Skip to main content
BlogDevOps

How to Deploy Next.js to a VPS with Docker and GitHub Actions

How to deploy Next.js to a VPS: a standalone Docker image, GHCR and GitHub Actions over SSH, with copy-paste commands and a table of common errors.

Matthews Wong

Software Engineer

October 202611 min read
How to Deploy Next.js to a VPS with Docker and GitHub Actions

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?

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.js

What 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: 20s

This 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.

  1. Open the repository on GitHub, then click Settings.
  2. In the Security section of the sidebar, click Secrets and variables, then Actions.
  3. On the Secrets tab click New repository secret, fill in Name and Secret, then Add secret. Repeat for the three secrets in the table.
  4. Switch to the Variables tab and create one variable, DEPLOY_PATH. It is not secret, so a variable is enough.
NameValueType
VPS_SSH_HOSTThe VPS IP address or hostname, for example 203.0.113.10Secret
VPS_SSH_USERThe user name on the VPS, which is deploySecret
VPS_SSH_KEYThe full contents of the deploy_key file (the private key), including the BEGIN and END lines. Do not paste the .pub file.Secret
DEPLOY_PATHThe folder on the server that holds the compose file, for example /home/deploy/appVariable

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.io

Three 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 --wait

The 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.

SymptomCauseFix
Permission denied (publickey) during deploy or a manual sshThe 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 lowercaseThe 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 pullThe 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 serverThe 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/staticThe 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 internetThe 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 erroringdocker 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 pullThe 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.

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.

FAQ

Frequently Asked Questions

Short answers to what readers ask most about this topic.

Why does my Next.js Docker container run but the page has no CSS or images?

The standalone build leaves the public and .next/static folders out by default, so server.js has nothing to serve them from. Add two COPY lines for those folders to the runner stage of your Dockerfile and rebuild the image. Then request one /_next/static file directly to confirm it no longer returns 404.

Do I need to install Node.js on the VPS to deploy Next.js with Docker?

No. The Node.js runtime lives inside the Docker image, so the server only needs Docker and Docker Compose. That also removes the Node version mismatch between your laptop and the server.

Why does GitHub Actions say Permission denied (publickey) when deploying over SSH?

Usually the matching public key is not in the deploy user's authorized_keys, or the secret holds the public key or a truncated private key. Test the same key from your laptop with ssh -i first. Then paste the full private key, from the BEGIN line to the END line, into the repository secret.

Is it safe to add my deploy user to the docker group?

It is a trade-off, because the Docker docs say the docker group grants root-level privileges. For a dedicated deploy user it is common, as long as you use a separate key that only the pipeline holds. Do not add everyday login users to that group.

How do I roll back a Next.js deploy made with GitHub Actions and Docker?

Each deploy runs an image tagged with its commit SHA, so the old image is still in GHCR. Change the IMAGE line in the server's .env to the older tag and run docker compose up -d --wait. Nothing needs to be rebuilt.