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

URL: https://www.matthewswong.com/en/blog/cara-deploy-nextjs-ke-vps-docker-github-actions/

- Author: Matthews Wong (https://www.matthewswong.com/en/about/)
- Published: 2026-10-17
- Updated: 2026-10-17
- Category: DevOps

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

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.

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.

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.

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.

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.

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.

Name

Value

Type

The VPS IP address or hostname, for example 203.0.113.10

Secret

The user name on the VPS, which is deploy

Secret

The full contents of the deploy_key file (the private key), including the BEGIN and END lines. Do not paste the .pub file.

Secret

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.

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.

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.

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.

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)

## Source links

- https://nextjs.org/docs/app/api-reference/config/next-config-js/output
- https://docs.docker.com/reference/cli/docker/compose/up/
- https://docs.github.com/en/packages/working-with-a-github-packages-registry/working-with-the-container-registry
- https://docs.github.com/en/actions/how-tos/write-workflows/choose-what-workflows-do/use-secrets
- https://docs.docker.com/engine/install/linux-postinstall/
- https://github.com/appleboy/ssh-action

## FAQ

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

---

- HTML version: https://www.matthewswong.com/en/blog/cara-deploy-nextjs-ke-vps-docker-github-actions/
- Bahasa Indonesia: https://www.matthewswong.com/id/blog/cara-deploy-nextjs-ke-vps-docker-github-actions/
- Site index for agents: https://www.matthewswong.com/llms.txt
- Sitemap: https://www.matthewswong.com/sitemap.xml
- API description (OpenAPI): https://www.matthewswong.com/openapi.json
