DEV Community

Peon Sh
Peon Sh

Posted on Originally published at peon.sh

How to Deploy a Next.js App to a VPS (Hetzner, DigitalOcean)

Step-by-step guide to deploying Next.js on your own VPS with Docker, automatic HTTPS and git-push deploys, no Vercel required.

What you are building
By the end of this guide you will have a Next.js app running on your own VPS with the full production workflow: push to your Git branch, the server builds a Docker image, swaps containers with zero downtime, and serves your custom domain over automatically renewed HTTPS. No Vercel, no per-seat pricing, and everything about the setup is portable to any provider.

Prerequisites: a VPS (a $4 Hetzner CX22 or a $6 DigitalOcean Droplet is plenty for most apps), a domain you control, and a Next.js project in GitHub or GitLab. Total setup time is around 30 minutes the first time.

Step 1: enable standalone output
Next.js has a purpose-built mode for self-hosting. With standalone output, next build traces exactly which files the production server needs and emits a minimal bundle, cutting your final image from over a gigabyte to roughly 150 MB:

// next.config.js
module.exports = {
output: 'standalone',
};

Step 2: add a Dockerfile
A multi-stage build keeps build tooling out of the runtime image. This recipe works for the App Router and Pages Router alike:

The static and public folders are NOT included in standalone output; forgetting to copy them is the most common cause of a deployed app with 404ing assets
NEXT_PUBLIC_* variables are baked in at build time, so they must be present during the build, not just at runtime

FROM node:22-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:22-alpine
WORKDIR /app
ENV NODE_ENV=production
COPY --from=build /app/.next/standalone ./
COPY --from=build /app/.next/static ./.next/static
COPY --from=build /app/public ./public
EXPOSE 3000
CMD ["node", "server.js"]

Step 3: connect your server and deploy
Connect the VPS to Peon by providing its IP and an SSH key. The platform installs Docker, creates the shared network and starts a Traefik reverse proxy that will own ports 80 and 443. Then create a Git-app service: pick your repository and branch, and Peon registers a webhook so every push triggers a build.

Set your domain on the service (say app.example.com), create an A record pointing at the server IP, and deploy. The first build clones the repo, builds the image on the server, starts the container on the proxy network and requests a Let’s Encrypt certificate. Subsequent pushes reuse the Docker layer cache, so typical rebuilds finish in under a minute.

Step 4: environment variables done right
Next.js splits variables into two classes and self-hosters trip on the difference constantly. Anything prefixed NEXT_PUBLIC_ is inlined into the client bundle at build time: set these as build-time variables. Server-only secrets (database URLs, API keys) are read at runtime from process.env: set these as runtime variables. In Peon both live in the service’s environment tab, encrypted at rest, with a flag for build-time exposure.

A useful sanity check: if you change a NEXT_PUBLIC_ value and do not see it after redeploying, you changed it at runtime only, trigger a rebuild.

What about ISR, images and middleware?
Everything works, because you are running the real Next.js server rather than a serverless adaptation. ISR revalidates on the schedule you set (the cache lives on the container filesystem), next/image optimizes on demand out of the box, and middleware runs in the Node process. The features that need attention are multi-instance concerns: if you later scale to several replicas, move the ISR cache to a shared handler. On a single VPS, defaults just work.

Zero-downtime deploys and rollbacks
On each deploy the platform starts the new container alongside the old one, waits for a health check to pass, switches the proxy route, then drains and stops the old container. Users never see a gap. If a deploy ships a bug, rollback re-points at the previous image in seconds since it is still on the host.

Add a simple health endpoint (an app/api/health/route.ts returning 200) so the health check verifies real readiness rather than just an open port.

Cost and performance recap
Hosting: $4 to $8 VPS runs several Next.js apps; Peon adds $2 per project
Cold starts: none, the Node process stays warm, first request after deploy is fast
Database latency: put Postgres on the same server and queries drop below a millisecond
Scaling path: resize the VPS (minutes), then split database to a second server, then add replicas, years of headroom before anything exotic

Top comments (0)