← Guides
Deployment Guides

How to Deploy Strapi v5 to a VPS

Strapi v5 requires building the admin panel before starting in production, persistent storage for uploads, and five security secrets that must be set correctly from day one. This guide walks through the full production setup.

ThomasThomas·2026-05-27

Strapi v5 has a few deployment quirks that are not obvious from the documentation. The admin panel is a separate build artifact. The five secret keys in your environment must never change once the app has users. Uploaded files need persistent disk. And pm2 is the recommended process manager, not just a nice-to-have.

This guide covers a production Strapi v5 deployment on a Linux VPS, using PostgreSQL and Nginx.

What Strapi is and how it runs

Strapi is a headless CMS — it gives you an admin panel to manage content and a REST or GraphQL API to serve it to your frontend. In production, it runs as a Node.js process on port 1337 by default. The admin panel is a React SPA that gets compiled into build/ and served as static files by the Strapi server itself.

The critical thing to understand: the admin panel must be compiled before you start Strapi in production. Running strapi start does not build the admin panel. You must run strapi build first. If you skip this step, the admin panel either does not load or you get an outdated version.

Choosing a server

Strapi is more resource-hungry than a typical Node.js API because the admin panel compilation during build is CPU-intensive. The Strapi docs recommend a minimum of 2 GB RAM in production.

Hetzner CX22 (2 vCPU, 4 GB RAM, ~4 EUR/month) is a solid choice. Plenty of headroom for the admin panel build and comfortable for a production workload. Hetzner's European data centers (Nuremberg, Falkenstein, Helsinki) provide good latency for European users.

DigitalOcean Droplets at $12/month (2 vCPU, 2 GB RAM) work but can be tight during the build step. Upgrade to the $18/month plan if you are building on the server rather than in CI.

Contabo VPS S gives you 4 vCPU, 6 GB RAM, and 100 GB NVMe for under 7 EUR/month — good value if you are running several services on one machine.

For the database, you have two options: run PostgreSQL on the same VPS (simplest) or use a managed database service like DigitalOcean Managed PostgreSQL or Neon. Managed databases cost more but handle backups, failover, and upgrades for you. For a hobby or early-stage project, same-VPS Postgres is fine.

Server setup

Install Node.js 22:

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

Strapi v5 requires Node.js 18, 20, or 22. Odd-numbered releases (19, 21, 23) are not supported.

Install build dependencies for native modules (Strapi uses Sharp for image processing):

sudo apt-get install -y build-essential libvips-dev

Install PM2:

npm install -g pm2

Install PostgreSQL:

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

Create database and user:

sudo -u postgres psql
CREATE DATABASE strapi_production;
CREATE USER strapi_user WITH ENCRYPTED PASSWORD 'your_strong_password';
GRANT ALL PRIVILEGES ON DATABASE strapi_production TO strapi_user;
ALTER DATABASE strapi_production OWNER TO strapi_user;
\q

Environment variables

Strapi requires five secret keys that are generated when you first create your project. These are written into your local .env file at project creation time. Copy those exact values to your production environment.

Do not regenerate them. If you generate new values for an existing application with users, all existing API tokens, admin sessions, and transfer tokens are immediately invalidated. Every admin user will be logged out and existing API tokens will stop working.

# Server
HOST=0.0.0.0
PORT=1337
NODE_ENV=production

# Security secrets — copy these from your local .env, do not regenerate
APP_KEYS=key1,key2,key3,key4
API_TOKEN_SALT=your_api_token_salt
ADMIN_JWT_SECRET=your_admin_jwt_secret
JWT_SECRET=your_jwt_secret
TRANSFER_TOKEN_SALT=your_transfer_token_salt

# Database
DATABASE_CLIENT=postgres
DATABASE_HOST=127.0.0.1
DATABASE_PORT=5432
DATABASE_NAME=strapi_production
DATABASE_USERNAME=strapi_user
DATABASE_PASSWORD=your_strong_password
DATABASE_SSL=false

If you are setting up a brand new production instance with no existing users, you can generate fresh secrets:

node -e "console.log(require('crypto').randomBytes(32).toString('base64'))"

Run this four times to generate four unique keys for APP_KEYS (comma-separated), and once each for the other secrets.

Store the .env file on your server at /var/www/strapi/.env with restricted permissions:

chmod 600 /var/www/strapi/.env

Building the admin panel

This is the step most deployment guides skip or bury. The admin panel must be built in production mode before you can start Strapi.

Clone your repository:

git clone https://github.com/yourname/yourapp.git /var/www/strapi
cd /var/www/strapi

Install dependencies. Important: do not set NODE_ENV=production before this step. npm skips devDependencies in production mode, and several of them are needed to compile the admin panel:

npm ci

Now build the admin panel:

NODE_ENV=production npm run build

This compiles the React admin panel into the .cache/ and build/ directories. It takes a few minutes on first run. You need to repeat this step every time you update Strapi or change admin panel customizations.

Starting Strapi with PM2

cd /var/www/strapi
pm2 start npm --name strapi-app -- run start
pm2 save
pm2 startup

Run the command that pm2 startup prints to register PM2 as a systemd service.

Verify Strapi started correctly:

pm2 logs strapi-app

You should see:

[2024-xx-xx xx:xx:xx.xxx] info: Starting Strapi in production mode...
[2024-xx-xx xx:xx:xx.xxx] info: Your application is ready at http://0.0.0.0:1337/admin

Visit http://yourserver-ip:1337/admin (or your domain if DNS is set up) and you should see the Strapi admin panel login screen.

Persistent file storage

Strapi stores uploaded files in public/uploads/ by default. This directory must persist across deployments.

If you are deploying by pulling from git and rebuilding, public/uploads/ will be wiped every time. You have two options:

Option 1: Symlink to a persistent directory

Create a directory outside the project root:

mkdir -p /var/strapi-uploads
chown www-data:www-data /var/strapi-uploads

After cloning or pulling, symlink it:

ln -sfn /var/strapi-uploads /var/www/strapi/public/uploads

This is the simplest approach for a single-server setup.

Option 2: Use a cloud storage provider

Strapi has first-party upload plugins for AWS S3 (@strapi/provider-upload-aws-s3), Cloudinary (@strapi/provider-upload-cloudinary), and others. Cloud storage is the right choice if you are running multiple Strapi instances or if you want uploads to survive a server migration.

npm install @strapi/provider-upload-aws-s3

Configure in config/plugins.ts:

export default {
  upload: {
    config: {
      provider: 'aws-s3',
      providerOptions: {
        accessKeyId: process.env.AWS_ACCESS_KEY_ID,
        secretAccessKey: process.env.AWS_ACCESS_SECRET,
        region: process.env.AWS_REGION,
        params: {
          Bucket: process.env.AWS_BUCKET,
        },
      },
    },
  },
}

Reverse proxy with Nginx

sudo apt install nginx

Create /etc/nginx/sites-available/strapi:

server {
    listen 80;
    server_name yourdomain.com;
    return 301 https://$server_name$request_uri;
}

server {
    listen 443 ssl http2;
    server_name yourdomain.com;

    ssl_certificate /etc/letsencrypt/live/yourdomain.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/yourdomain.com/privkey.pem;

    # Increase max body size for file uploads
    client_max_body_size 100M;

    location / {
        proxy_pass http://127.0.0.1:1337;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection 'upgrade';
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_cache_bypass $http_upgrade;
    }
}

Enable the site and get a TLS certificate:

sudo ln -s /etc/nginx/sites-available/strapi /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx

sudo apt install certbot python3-certbot-nginx
sudo certbot --nginx -d yourdomain.com

Note the client_max_body_size 100M. Without this, Nginx rejects file uploads larger than 1 MB with a 413 error. Set it to whatever makes sense for your use case.

Health check

Strapi exposes a health check endpoint at /_health. It returns HTTP 204 when the application is ready. Use this in your load balancer or uptime monitoring:

curl -I https://yourdomain.com/_health
# HTTP/2 204

Deploying updates

#!/bin/bash
set -e

APP_DIR=/var/www/strapi

cd $APP_DIR

echo "Pulling latest code..."
git pull origin main

echo "Installing dependencies..."
npm ci

echo "Building admin panel..."
NODE_ENV=production npm run build

echo "Restoring uploads symlink..."
ln -sfn /var/strapi-uploads $APP_DIR/public/uploads

echo "Restarting..."
pm2 reload strapi-app

echo "Done."

Common issues

Admin panel shows a blank page or 404 — the admin panel was not built. Run NODE_ENV=production npm run build and restart Strapi.

"Invalid credentials" after changing secrets — you changed APP_KEYS, ADMIN_JWT_SECRET, or JWT_SECRET after users existed. Those keys sign tokens, so changing them invalidates everything. Restore the original values.

File uploads return 413 — Nginx's client_max_body_size is too low. Set it to at least 100M.

"No database client set"DATABASE_CLIENT environment variable is missing. Make sure it is set to postgres (not postgresql — Strapi uses the shorter form).

Build fails with Sharp errors on Alpine — Sharp needs native binaries. On Alpine-based Docker images, install build-base, gcc, and vips-dev before running npm ci.


Strapi is a mature platform but getting the production setup right — persistent uploads, the right build order, secrets that must never change — takes some careful attention. If you would rather skip the server administration and just connect your repository, Jetpacked handles the build pipeline, database provisioning, persistent storage for uploads, and the reverse proxy automatically.

Deploy your app in minutes

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

Launch your app free