← Guides
Deployment Guides

How to Deploy Payload CMS to a VPS (Complete Guide)

Payload CMS v3 runs on Next.js and needs a database, persistent file storage, and a handful of secrets to work in production. This guide walks through every step from environment variables to reverse proxy config.

ThomasThomas·2026-05-27

Payload CMS v3 is not a typical Node app. It runs as a Next.js application, which means deploying it correctly requires understanding how Next.js standalone output works, where uploaded files need to live, and which secrets are mandatory before your first boot. This guide covers all of it.

What you are deploying

A Payload v3 project is a Next.js app with Payload embedded as a plugin. When you run next build, it compiles both the Next.js frontend and the Payload admin panel into a single production artifact. The admin panel lives at /admin and your API lives at /api. Both are served by a single Node.js process.

This architecture is powerful but it has implications for deployment. You cannot just drop a static folder onto a CDN. You need a real server running Node.js, a database that Payload can connect to, and persistent disk for any files your users upload.

Choosing a server

For a Payload app you want a VPS with at least 1 GB of RAM, though 2 GB is much more comfortable once the admin panel is compiled and the Node process is running. Good options at different price points:

Hetzner Cloud is the best value in Europe right now. A CX22 instance (2 vCPU, 4 GB RAM) runs around 4 EUR per month and gives you more than enough headroom for a production Payload app. Their network is fast, the control panel is clean, and they have data centers in Nuremberg, Falkenstein, Helsinki, and Ashburn.

DigitalOcean Droplets are a solid choice if you want a more established platform with a polished UI and good documentation. Their $12/month Droplet (2 vCPU, 2 GB RAM) is a reasonable starting point for Payload.

Contabo is worth looking at if you need more disk space cheaply. Their VPS S plan includes 100 GB of NVMe for under 5 EUR.

Once your app is running and you want to offload complexity, platforms like Railway handle the server provisioning for you. You push a Dockerfile, set your environment variables, and Railway runs it. Convenient, but you pay more per unit of compute than a raw VPS.

Prerequisites

On your server, you need:

  • Node.js 20 or 22 (Payload v3 requires an Active LTS release)
  • npm or yarn
  • A PostgreSQL database (version 14 minimum, 17 recommended)
  • A reverse proxy (Nginx or Caddy)
  • PM2 for process management

Install Node.js using the NodeSource repository or nvm. Do not use the version in your distro's package manager — it is usually outdated.

curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -
sudo apt-get install -y nodejs

Install PM2 globally:

npm install -g pm2

Install PostgreSQL:

sudo apt install postgresql postgresql-contrib
sudo systemctl enable postgresql
sudo systemctl start postgresql

Create a database and user:

sudo -u postgres psql
CREATE DATABASE payload_production;
CREATE USER payload_user WITH ENCRYPTED PASSWORD 'your_strong_password_here';
GRANT ALL PRIVILEGES ON DATABASE payload_production TO payload_user;
\q

Configuring standalone output

Payload v3 works best with Next.js standalone output enabled. This bundles all dependencies into .next/standalone/ so you can run the app without installing node_modules on the server.

In your next.config.js or next.config.ts:

/** @type {import('next').NextConfig} */
const nextConfig = {
  output: 'standalone',
}

export default nextConfig

This is the single most important configuration change for VPS deployment. Without it you would need to copy the entire node_modules folder to the server or install dependencies there.

Environment variables

Payload requires several secrets before it will start. Create a .env file on your server (not in your repository — never commit secrets):

# Required — Payload will refuse to start without this
PAYLOAD_SECRET=your_long_random_string_at_least_32_characters

# Database connection
DATABASE_URL=postgresql://payload_user:your_strong_password_here@localhost:5432/payload_production

# Required by Next.js
NODE_ENV=production

# The public URL of your app — used for email links, webhooks, CORS
NEXT_PUBLIC_SERVER_URL=https://yourdomain.com

# Port (optional, defaults to 3000)
PORT=3000

Generate PAYLOAD_SECRET with:

openssl rand -base64 32

The NEXT_PUBLIC_SERVER_URL value is not enforced by Payload at startup, but without it, password reset emails will contain wrong links, webhooks will fire to the wrong address, and CORS will behave unpredictably. Set it correctly from day one.

Building the app

Clone your repository on the server and install dependencies:

git clone https://github.com/yourname/yourapp.git /var/www/payload
cd /var/www/payload
npm ci --omit=dev

Wait — do not use --omit=dev yet. Payload's build step needs devDependencies to compile TypeScript and the admin panel. Install everything first:

npm ci

Build the application:

NODE_ENV=production npm run build

This runs next build which compiles both Next.js and the Payload admin panel. On a CX22 Hetzner instance this takes around 2 to 3 minutes the first time. Subsequent builds are faster thanks to Next.js's incremental compilation.

After the build completes, copy the static files that standalone mode needs:

cp -r .next/static .next/standalone/.next/static
cp -r public .next/standalone/public

This is a step that trips up a lot of people. Standalone output does not include static assets by default. Without this copy the admin panel's CSS and JS files will 404.

Starting the application with PM2

cd /var/www/payload/.next/standalone
pm2 start server.js --name payload-app --env production
pm2 save
pm2 startup

The last two commands ensure PM2 restarts your app if the server reboots. Run the command that pm2 startup prints (it will be a sudo env PATH=... command).

Verify the app is running:

pm2 logs payload-app

You should see output like:

▲ Next.js 15.x.x
- Local: http://localhost:3000
- Network: http://0.0.0.0:3000

Reverse proxy with Caddy

Caddy handles HTTPS automatically using Let's Encrypt. Install it:

sudo apt install -y debian-keyring debian-archive-keyring apt-transport-https curl
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | sudo tee /etc/apt/sources.list.d/caddy-stable.list
sudo apt update
sudo apt install caddy

Edit /etc/caddy/Caddyfile:

yourdomain.com {
    reverse_proxy localhost:3000
}

Reload Caddy:

sudo systemctl reload caddy

Caddy will automatically obtain and renew a TLS certificate for your domain. Your Payload admin panel is now accessible at https://yourdomain.com/admin.

Persistent file storage

This is the most commonly skipped step and the one that causes the most pain.

By default, Payload stores uploaded files in /public/uploads/ inside your project directory. Every time you redeploy — clone the repo fresh and rebuild — that directory starts empty. Every file your users ever uploaded: gone.

There are two clean solutions:

Option 1: Mount a persistent volume and point Payload's upload path to it. On a VPS you can create a directory outside the project root:

mkdir -p /var/www/payload-uploads

Then configure Payload to use it in your payload.config.ts:

import { buildConfig } from 'payload'

export default buildConfig({
  collections: [
    {
      slug: 'media',
      upload: {
        staticDir: '/var/www/payload-uploads',
        staticURL: '/uploads',
      },
    },
  ],
})

Then in your next.config.js, add a rewrite so Next.js serves those files:

async rewrites() {
  return [
    {
      source: '/uploads/:path*',
      destination: '/var/www/payload-uploads/:path*',
    },
  ]
}

Option 2: Use S3-compatible cloud storage. Payload has an official @payloadcms/storage-s3 plugin that redirects all uploads to S3, R2, or any S3-compatible service. This is the right choice for apps that will scale beyond a single server.

npm install @payloadcms/storage-s3
import { s3Storage } from '@payloadcms/storage-s3'
import { buildConfig } from 'payload'

export default buildConfig({
  plugins: [
    s3Storage({
      collections: { media: true },
      bucket: process.env.S3_BUCKET,
      config: {
        credentials: {
          accessKeyId: process.env.S3_ACCESS_KEY_ID,
          secretAccessKey: process.env.S3_SECRET_ACCESS_KEY,
        },
        region: process.env.S3_REGION,
      },
    }),
  ],
})

Database migrations

Payload handles schema management automatically, but you need to run migrations on first deploy and after any schema changes:

cd /var/www/payload
node_modules/.bin/payload migrate

Or if you have it in your npm scripts:

npm run payload migrate

Run this before starting the app, not after. Starting Payload with a mismatched schema can produce confusing errors.

Deploying updates

A simple update script you can run from your local machine or in CI:

#!/bin/bash
set -e

cd /var/www/payload

git pull origin main
npm ci
NODE_ENV=production npm run build
cp -r .next/static .next/standalone/.next/static
cp -r public .next/standalone/public

npm run payload migrate

pm2 restart payload-app

Checking that everything works

Visit https://yourdomain.com/admin. You should see the Payload login screen. Create your first admin user, upload a test file, and verify it appears at the URL Payload returns. Then redeploy and check the file is still there.

Common issues

"PayloadConfigError: PAYLOAD_SECRET is not defined" — your .env file is not being loaded. Make sure PM2 is started from the directory that contains .env, or pass the env file explicitly: pm2 start server.js --env-file /var/www/payload/.env.

Admin panel loads but returns 500 on API calls — almost always a database connection problem. Check pm2 logs payload-app for the specific error. Verify PostgreSQL is running and the connection string is correct.

Uploaded files 404 after redeploy — you are not persisting the upload directory. See the persistent file storage section above.

"ENOENT: no such file or directory" on startup — you forgot to copy .next/static and public into .next/standalone/ after the build.


All of the above is the reality of self-hosting. It works, and once it is running it is stable. But it is a non-trivial amount of setup to get right, and it is all on you to maintain, update, and debug. If you would rather skip straight to a working Payload deployment, Jetpacked can analyze your repository, detect that it is Payload, wire up the database, configure persistent storage for uploads, and give you a live URL without a single config file.

Deploy your app in minutes

Jetpacked handles Docker, HTTPS, databases, and deployments — so you can focus on building.

Launch your app free