How to Install Strapi v5 on Ubuntu 24.04 VPS: Self-Hosted Headless CMS with PostgreSQL
Strapi is the leading open-source headless CMS for Node.js, giving you a polished admin UI, an auto-generated REST and GraphQL API, role-based permissions, and a plugin ecosystem without the per-editor fees or content limits of SaaS platforms like Contentful. This guide walks you through installing Strapi v5 on a production Ubuntu 24.04 VPS, wired up to PostgreSQL 16, managed by PM2 in cluster mode, and exposed safely through an Nginx reverse proxy with Let's Encrypt TLS.
Prefer a ready-made stack? Deploy a Strapi-ready VPS with Node 20 LTS, PostgreSQL, and Nginx pre-installed. Launch a CloudCore Professional VPS and be ready for npm run build in minutes.Table of Contents
What is Strapi?
Strapi is an open-source headless CMS built on Node.js that decouples content storage and editing from the frontend that displays it. Editors work in a clean React admin panel at /admin, while developers consume content through auto-generated REST (/api/:pluralName) or GraphQL endpoints. Strapi v5 is the current stable major version and introduces a flat response format, the Document Service API, draft-and-publish with versioned documents, and a native TypeScript-first developer experience.
Strapi is a fit for a wide range of projects. Marketing teams use it as the content backbone for Next.js, Nuxt, Astro, and SvelteKit frontends. Product teams back mobile apps (iOS, Android, React Native, Flutter) with its REST API. SaaS teams run it as an internal knowledge base, a public blog, or the product catalog behind a storefront. Because content types, components, and dynamic zones are defined in code and stored in version control, Strapi plays well with Git-based workflows and CI/CD.
Out of the box you get: a visual Content-Type Builder, a Media Library with image optimization, draft-and-publish workflows, internationalization (i18n) for any locale, granular role-based access control (RBAC), API tokens with scoped permissions, webhooks, a plugin marketplace, and the Transfer tool for moving data and schema between environments. Every piece is open source, so nothing is locked behind a paywall for self-hosters.
Why Self-Host Strapi Instead of Using Strapi Cloud or Contentful?
Hosted headless CMS platforms are convenient, but the cost curve gets steep as soon as you have more than a handful of editors or more than a trickle of API traffic. Self-hosting on a VPS flips the economics and gives you full ownership of the data.
- Flat monthly cost -- A CloudCore Professional VPS at EUR 19.99/month runs Strapi, PostgreSQL, Nginx, and a staging copy with room to spare. No per-entry, per-seat, or per-API-call charges.
- Unlimited editors and content types -- Strapi Cloud's Pro plan charges per seat and caps entries; Contentful's Team plan charges $489/month for 20 users and 50k records. Self-hosted Strapi has no such limits.
- Your data, your database -- Content lives in your PostgreSQL instance. You can run
pg_dump, replicate to a warm standby, encrypt at rest with LUKS, and take physical backups with Borg or Restic. No export API throttling and no vendor export format to reverse-engineer. - GDPR and data residency -- Pick an EU-hosted VPS and keep every byte of content, media, and analytics inside the EU. Sign your own DPA with the VPS provider. No third-party sub-processors sitting between editors and storage.
- Full plugin and code access -- You can patch core, add middleware, write custom lifecycle hooks, and install any npm package. SaaS platforms restrict you to their plugin marketplace and sandboxed functions.
- Predictable performance -- Dedicated CPU and RAM mean response times stay stable regardless of noisy neighbors. You can also run Redis, Meilisearch, or a vector database on the same box for integrated features.
Cost Comparison: Self-Hosted Strapi vs. Hosted Headless CMS
| Scenario | Strapi Cloud (Pro) | Contentful (Team) | Sanity (Growth) | Self-Hosted Strapi (VPS) |
|---|---|---|---|---|
| Monthly cost | $99/mo + overages | $489/mo | $99/mo + overages | EUR 19.99/mo (flat) |
| Included editors | 10 | 20 | 20 | Unlimited |
| Included entries / documents | 50,000 | 50,000 | 25,000 | Unlimited |
| API calls / month | 1M | 2M | 1M | Unlimited |
| Assets storage | 500 GB | 500 GB | 50 GB | 100 GB NVMe + S3/MinIO |
| Custom plugins | Marketplace only | Limited | Studio only | Full code access |
| Database access | No | No | No | Yes (PostgreSQL) |
| Data residency control | Limited | Paid add-on | Limited | Full (pick VPS region) |
Prerequisites
Before you start, make sure you have:
- A VPS running Ubuntu 24.04 LTS with root or sudo access
- SSH access to the server
- A domain name pointing to the server's IPv4 address (for HTTPS via Let's Encrypt)
- At least 2 GB of RAM (4 GB+ recommended for PM2 cluster mode plus PostgreSQL)
- At least 20 GB of disk space (more if you store media locally)
Recommended Plan: CloudCore Professional>
For a production Strapi deployment with PostgreSQL on the same box and headroom for a staging copy, we recommend the CloudCore Professional plan:>
- 6 vCPU cores
- 12 GB RAM
- 100 GB NVMe SSD
- Unmetered bandwidth
- EUR 19.99/month>
Plenty of room for PM2 to fork a worker per core, PostgreSQL buffers, and a hot staging environment.
If this is a fresh server, you may want to skim our foundation guides first:
Connect to your server to begin:ssh root@your-server-ipStep 1: Prepare the Ubuntu 24.04 Server
Update the package index and upgrade installed packages so dependency resolution works cleanly:
sudo apt update && sudo apt upgrade -yInstall the base tooling Strapi and its native dependencies need:
sudo apt install -y build-essential git curl ca-certificates gnupg ufwCreate a non-root user that will own the Strapi install. Running Node apps as root is a bad habit and Strapi refuses to write to some paths when run as root.
sudo adduser --disabled-password --gecos "" strapi
sudo usermod -aG sudo strapiConfigure the firewall to allow SSH, HTTP, and HTTPS:
sudo ufw allow OpenSSH
sudo ufw allow 'Nginx Full'
sudo ufw --force enable
sudo ufw statusSwitch to the strapi user for the rest of the install:
sudo -iu strapiStep 2: Install Node.js 20 LTS
Strapi v5 requires Node.js 18.x, 20.x, or 22.x and npm 6+. We recommend Node 20 LTS -- it is the long-term support line that receives security patches through April 2026 and matches Strapi's primary CI target.
Add the NodeSource repository for Node 20.x and install:
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
sudo apt install -y nodejsVerify both Node and npm are on the expected major versions:
node --version
npm --versionExpected output:
v20.19.0
10.8.2Install pnpm (faster installs and the package manager Strapi recommends) and pm2 (process manager) globally:
sudo npm install -g pnpm pm2Verify:
pnpm --version && pm2 --versionIf you need a deeper walkthrough of Node installation, alternative versions, or nvm, see our Node.js on Ubuntu guide.
Step 3: Install and Configure PostgreSQL 16
Strapi supports SQLite, MySQL, and PostgreSQL. Use PostgreSQL in production -- it handles concurrent writes well, ships with rich JSON support (Strapi uses jsonb columns for components), and makes backups simple with pg_dump.
Install PostgreSQL 16 from the official PGDG repository:
sudo install -d /usr/share/postgresql-common/pgdg sudo curl -o /usr/share/postgresql-common/pgdg/apt.postgresql.org.asc \ --fail https://www.postgresql.org/media/keys/ACCC4CF8.ascsudo sh -c 'echo "deb [signed-by=/usr/share/postgresql-common/pgdg/apt.postgresql.org.asc] https://apt.postgresql.org/pub/repos/apt $(lsb_release -cs)-pgdg main" > /etc/apt/sources.list.d/pgdg.list'
sudo apt update sudo apt install -y postgresql-16 postgresql-contrib-16
Confirm the cluster is running:
sudo systemctl status postgresqlCreate the Strapi Database and User
Strapi needs a dedicated database and role. Replace the password placeholder with a long random string (generate one with openssl rand -base64 24).
sudo -u postgres psql <<'SQL'
CREATE DATABASE strapi_prod;
CREATE USER strapi_user WITH ENCRYPTED PASSWORD 'REPLACE_WITH_STRONG_PASSWORD';
GRANT ALL PRIVILEGES ON DATABASE strapi_prod TO strapi_user;
ALTER DATABASE strapi_prod OWNER TO strapi_user;
\c strapi_prod
GRANT ALL ON SCHEMA public TO strapi_user;
SQLTest the credentials from the strapi OS user:
psql -h 127.0.0.1 -U strapi_user -d strapi_prod -W -c '\l'You should see a list of databases. If it fails, edit /etc/postgresql/16/main/pg_hba.conf and confirm the line for local IPv4 uses scram-sha-256:
host all all 127.0.0.1/32 scram-sha-256Reload PostgreSQL after any change:
sudo systemctl reload postgresqlFor a deeper PostgreSQL walkthrough (tuning, backups, and remote access), see our PostgreSQL on Ubuntu guide.
Step 4: Create the Strapi Application
With Node and PostgreSQL ready, scaffold the project. Run this as the strapi user in its home directory.
cd ~
npx create-strapi-app@latest strapi-app --quickstart=falseThe installer asks a series of questions. Use these answers for production:
? Please log in or sign up. Skip
? Do you want to use the default database (sqlite)? No
? Choose your default database client postgres
? Database name: strapi_prod
? Host: 127.0.0.1
? Port: 5432
? Username: strapi_user
? Password: <the password from Step 3>
? Enable SSL connection: No
? Start with an example structure? No
? Start with Typescript? Yes
? Install dependencies with npm? Yes
? Initialize a git repository? YesOnce the install finishes, enter the project:
cd ~/strapi-appNote: If you preferpnpm, re-run the install withpnpm dlx create-strapi@latest strapi-app. Strapi's team has been moving toward pnpm as the default, and it is faster for subsequent plugin installs.
Step 5: Configure the .env File
The create-strapi-app generator writes a .env file at the project root with a working set of secrets. Open it and adjust the values for production.
nano ~/strapi-app/.envA production .env should look like this:
# Host binding
HOST=127.0.0.1
PORT=1337Public URL used for admin redirects, API token scopes, and webhooks
URL=https://cms.yourdomain.comApp keys -- comma-separated list of base64 strings used to sign session cookies
APP_KEYS="key1base64,key2base64,key3base64,key4base64"JWT secret for the users-permissions plugin (public API auth)
JWT_SECRET=long-random-base64-stringAdmin panel JWT secret
ADMIN_JWT_SECRET=another-long-random-base64-stringSalt used to hash API tokens
API_TOKEN_SALT=yet-another-long-random-base64-stringSalt used to hash transfer tokens (for the strapi transfer command)
TRANSFER_TOKEN_SALT=one-more-long-random-base64-stringDatabase
DATABASE_CLIENT=postgres
DATABASE_HOST=127.0.0.1
DATABASE_PORT=5432
DATABASE_NAME=strapi_prod
DATABASE_USERNAME=strapi_user
DATABASE_PASSWORD=REPLACE_WITH_STRONG_PASSWORD
DATABASE_SSL=false
DATABASE_SCHEMA=publicNode environment
NODE_ENV=productionGenerate every secret with a one-liner so you are never tempted to reuse defaults:
# For APP_KEYS (run four times, join with commas)
node -e "console.log(require('crypto').randomBytes(16).toString('base64'))"For JWT_SECRET, ADMIN_JWT_SECRET, API_TOKEN_SALT, TRANSFER_TOKEN_SALT
node -e "console.log(require('crypto').randomBytes(32).toString('base64'))"Lock down file permissions -- these secrets grant full admin access to your CMS:
chmod 600 ~/strapi-app/.envWhat Each Secret Does
APP_KEYS-- Comma-separated list of keys used by Koa (Strapi's underlying framework) to sign and rotate session cookies. Strapi refuses to boot if this is missing.JWT_SECRET-- Signs JWTs issued to public API consumers through theusers-permissionsplugin. Rotate this and every public user must log in again.ADMIN_JWT_SECRET-- Signs JWTs for admin panel sessions. Completely independent fromJWT_SECRETso you can rotate admin credentials without logging out end users.API_TOKEN_SALT-- Salt mixed into stored API tokens (created in Settings -> API Tokens). Rotating this invalidates every existing API token.TRANSFER_TOKEN_SALT-- Salt for tokens used bystrapi transferto push or pull data between environments.DATABASE_SSL=false-- Fine when Strapi and PostgreSQL are on the same host (loopback). Set totrueand provideDATABASE_SSL_CAif PostgreSQL is on a managed service like Neon, Supabase, or RDS.
Step 6: Build Strapi for Production
Strapi v5's admin panel is a Vite-built React app. You have to compile it once before serving in production mode -- the dev server (strapi develop) rebuilds on the fly and is not meant for internet-facing traffic.
From inside ~/strapi-app, run:
NODE_ENV=production npm run buildExpected output (abbreviated):
> [email protected] build > strapi build
Building your admin UI with production configuration... ✓ 1247 modules transformed. dist/build/index.html 0.60 kB dist/build/assets/index-Abc123.js 412.98 kB │ gzip: 128.44 kB dist/build/assets/index-Def456.css 48.71 kB │ gzip: 11.22 kB ✓ built in 27.34s Admin UI built successfully
The compiled bundle lives in ./build/ and is served at /admin by the Strapi process. Re-run npm run build any time you install a new plugin or change src/admin/app.tsx.
Do a quick smoke test in production mode to confirm the config loads and the database migrations succeed:
NODE_ENV=production npm run startYou should see:
Project information ┌────────────────────┬────────────────────────────────────┐ │ Time │ Thu Apr 16 2026 12:00:00 GMT+0000 │ │ Launched in │ 3211 ms │ │ Environment │ production │ │ Process PID │ 12345 │ │ Version │ 5.x.x (node v20.19.0) │ │ Edition │ Community │ │ Database │ postgres │ └────────────────────┴────────────────────────────────────┘
Welcome back! To manage your project go to the administration panel at: http://127.0.0.1:1337/admin
Press Ctrl+C to stop the foreground process before moving on -- we will run it under PM2 next.
Step 7: Run Strapi with PM2 in Cluster Mode
PM2 is the de facto process manager for production Node apps. It fork-clusters across CPU cores, auto-restarts on crashes, rotates logs, and can generate a systemd unit so Strapi survives reboots.
Create a PM2 ecosystem file at the project root:
nano ~/strapi-app/ecosystem.config.jsPaste:
module.exports = {
apps: [
{
name: 'strapi',
cwd: '/home/strapi/strapi-app',
script: 'npm',
args: 'run start',
instances: 'max', // one worker per CPU core
exec_mode: 'cluster', // cluster mode for load balancing
max_memory_restart: '1G',
env: {
NODE_ENV: 'production',
},
error_file: '/home/strapi/.pm2/logs/strapi-error.log',
out_file: '/home/strapi/.pm2/logs/strapi-out.log',
merge_logs: true,
time: true,
},
],
};Cluster mode caveat: Strapi's admin panel opens a WebSocket for the Content Manager and the Upload plugin uses ephemeral temp files. Cluster mode works, but if you rely on in-memory caching in custom services, switch toinstances: 1or add Redis as a shared cache layer. On a 6 vCPU VPS,instances: 4is a comfortable sweet spot that still leaves cores for PostgreSQL.
Start the app:
cd ~/strapi-app
pm2 start ecosystem.config.js
pm2 savePersist PM2 across reboots by generating a systemd unit:
pm2 startup systemd -u strapi --hp /home/strapiPM2 prints a sudo command -- copy it, exit back to the sudo-capable user, run it, and then return to the strapi user and run pm2 save again.
Verify:
pm2 statusExpected output:
┌────┬────────┬─────────┬────────┬────────┬────────┬──────┬──────────┐
│ id │ name │ mode │ ↺ │ status │ cpu │ mem │ uptime │
├────┼────────┼─────────┼────────┼────────┼────────┼──────┼──────────┤
│ 0 │ strapi │ cluster │ 0 │ online │ 0.5% │ 248M │ 1m │
│ 1 │ strapi │ cluster │ 0 │ online │ 0.4% │ 246M │ 1m │
│ 2 │ strapi │ cluster │ 0 │ online │ 0.6% │ 251M │ 1m │
│ 3 │ strapi │ cluster │ 0 │ online │ 0.4% │ 245M │ 1m │
└────┴────────┴─────────┴────────┴────────┴────────┴──────┴──────────┘Useful PM2 commands:
pm2 logs strapi # tail logs from all workers
pm2 restart strapi # zero-downtime rolling restart in cluster mode
pm2 reload strapi # full reload (after env changes)
pm2 stop strapi # stop all workers
pm2 monit # live CPU/memory dashboard
pm2 flush # clear log filesStep 8: Alternative systemd Service
If you prefer to skip PM2 and use systemd directly, create a unit at /etc/systemd/system/strapi.service:
sudo tee /etc/systemd/system/strapi.service > /dev/null <<'EOF' [Unit] Description=Strapi v5 Headless CMS After=network.target postgresql.service Requires=postgresql.service[Service] Type=simple User=strapi Group=strapi WorkingDirectory=/home/strapi/strapi-app Environment=NODE_ENV=production EnvironmentFile=/home/strapi/strapi-app/.env ExecStart=/usr/bin/npm run start Restart=always RestartSec=5 StandardOutput=append:/var/log/strapi.log StandardError=append:/var/log/strapi.err.log
Hardening
NoNewPrivileges=true PrivateTmp=true ProtectSystem=strict ReadWritePaths=/home/strapi/strapi-app /var/log ProtectHome=false ProtectKernelTunables=true ProtectControlGroups=true
[Install] WantedBy=multi-user.target EOF
Create the log files and set ownership:
sudo touch /var/log/strapi.log /var/log/strapi.err.log
sudo chown strapi:strapi /var/log/strapi.log /var/log/strapi.err.logEnable and start:
sudo systemctl daemon-reload
sudo systemctl enable --now strapi
sudo systemctl status strapisystemd is simpler and removes the PM2 dependency, but you lose cluster mode and per-worker log separation. Pick PM2 if you want horizontal scaling on a single box; pick systemd if you already run every other service under it and want consistency.
Step 9: Nginx Reverse Proxy and Let's Encrypt SSL
Strapi listens on 127.0.0.1:1337. Nginx terminates TLS on 443 and forwards to Strapi. This keeps the Node process off the public internet, lets you add rate-limiting and caching, and centralizes your certificate renewal.
Install Nginx and Certbot:
sudo apt install -y nginx certbot python3-certbot-nginxCreate the site config:
sudo tee /etc/nginx/sites-available/strapi > /dev/null <<'EOF'Redirect HTTP -> HTTPS
server { listen 80; listen [::]:80; server_name cms.yourdomain.com; return 301 https://$host$request_uri; }Main HTTPS server
server { listen 443 ssl http2; listen [::]:443 ssl http2; server_name cms.yourdomain.com;# SSL certs will be filled in by Certbot ssl_certificate /etc/letsencrypt/live/cms.yourdomain.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/cms.yourdomain.com/privkey.pem;
# Uploads can be large -- default 1m is too small client_max_body_size 200m;
# Security headers add_header X-Content-Type-Options nosniff always; add_header X-Frame-Options SAMEORIGIN always; add_header Referrer-Policy strict-origin-when-cross-origin always; add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;
# Long-lived cache for admin UI static assets location /admin/assets/ { proxy_pass http://127.0.0.1:1337; proxy_set_header Host $host; expires 30d; access_log off; }
# Strapi API, admin, GraphQL location / { proxy_pass http://127.0.0.1:1337; proxy_http_version 1.1; 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_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade";
# Keep websockets and long uploads alive proxy_read_timeout 600s; proxy_send_timeout 600s; } } EOF
Enable it:
sudo ln -s /etc/nginx/sites-available/strapi /etc/nginx/sites-enabled/
sudo rm -f /etc/nginx/sites-enabled/default
sudo nginx -tObtain the certificate. Make sure your DNS A record for cms.yourdomain.com resolves to the server first.
sudo certbot --nginx -d cms.yourdomain.comCertbot edits the config in place to point at /etc/letsencrypt/live/... and sets up an automatic renewal timer. Verify:
sudo systemctl status certbot.timerReload Nginx:
sudo systemctl reload nginxNow browse to https://cms.yourdomain.com/admin and create your first administrator. For a deeper Nginx primer (rate limiting, caching, multi-site layout), see our Nginx on Ubuntu guide.
Tell Strapi About the Public URL
If you did not set URL in .env during Step 5, do it now:
URL=https://cms.yourdomain.comAlso trust the reverse proxy in config/server.ts:
export default ({ env }) => ({
host: env('HOST', '127.0.0.1'),
port: env.int('PORT', 1337),
url: env('URL', 'https://cms.yourdomain.com'),
proxy: true,
app: {
keys: env.array('APP_KEYS'),
},
});Rebuild and reload:
cd ~/strapi-app
npm run build
pm2 reload strapiStep 10: Configure S3 or MinIO Upload Provider
By default Strapi stores uploads in ./public/uploads, which is fine for development but fails in cluster mode (PM2 workers on the same machine share disk, but scaling to a second machine breaks). Point uploads at S3-compatible object storage -- either AWS S3, Backblaze B2, DigitalOcean Spaces, Hetzner Object Storage, or a self-hosted MinIO.
Install the official provider:
cd ~/strapi-app
npm install @strapi/provider-upload-aws-s3Create or edit config/plugins.ts:
export default ({ env }) => ({
upload: {
config: {
provider: 'aws-s3',
providerOptions: {
s3Options: {
endpoint: env('S3_ENDPOINT'), // only for MinIO / non-AWS
region: env('S3_REGION', 'us-east-1'),
credentials: {
accessKeyId: env('S3_ACCESS_KEY_ID'),
secretAccessKey: env('S3_SECRET_ACCESS_KEY'),
},
params: {
Bucket: env('S3_BUCKET'),
},
// MinIO needs path-style URLs
forcePathStyle: env.bool('S3_FORCE_PATH_STYLE', false),
},
},
actionOptions: {
upload: {},
uploadStream: {},
delete: {},
},
},
},
});Add the credentials to .env:
# Object storage (MinIO example -- for AWS S3 leave S3_ENDPOINT blank)
S3_ENDPOINT=https://minio.yourdomain.com
S3_REGION=us-east-1
S3_ACCESS_KEY_ID=AKIAEXAMPLE
S3_SECRET_ACCESS_KEY=EXAMPLEKEY
S3_BUCKET=strapi-uploads
S3_FORCE_PATH_STYLE=trueAllow the bucket host in Strapi's security middleware so the admin preview loads images. Edit config/middlewares.ts:
export default [
'strapi::logger',
'strapi::errors',
{
name: 'strapi::security',
config: {
contentSecurityPolicy: {
useDefaults: true,
directives: {
'connect-src': ["'self'", 'https:'],
'img-src': ["'self'", 'data:', 'blob:', 'https://minio.yourdomain.com'],
'media-src': ["'self'", 'data:', 'blob:', 'https://minio.yourdomain.com'],
upgradeInsecureRequests: null,
},
},
},
},
'strapi::cors',
'strapi::poweredBy',
'strapi::query',
'strapi::body',
'strapi::session',
'strapi::favicon',
'strapi::public',
];Rebuild and reload:
npm run build
pm2 reload strapiUpload an image from the Media Library -- it should land in your S3 bucket and Strapi should serve it from the bucket URL.
Step 11: Create Content Types, API Tokens, and Roles
With the admin panel live at https://cms.yourdomain.com/admin, you can model content visually.
Create a Content Type
Article) -- Strapi generates the API ID automatically (api::article.article)title (Text, Short), slug (UID, attached to title), body (Rich Text -- Blocks), cover (Media, single), publishedAt (auto-managed by draft-and-publish)The new API is immediately live at:
GET /api/articles
GET /api/articles/:documentId
POST /api/articles
PUT /api/articles/:documentId
DELETE /api/articles/:documentIdStrapi v5 uses documentId (a stable UUID) rather than the v4 numeric id -- important detail when integrating frontends that were built against v4.
Set Public Roles and Permissions
By default all endpoints are locked. To expose read-only article access to unauthenticated users:
find and findOneFor authenticated visitors (frontend users who log in), do the same under the Authenticated role. Create additional roles for editors, moderators, or headless app consumers and grant per-action permissions.
Generate an API Token
For server-to-server integrations (your Next.js build pipeline, a Netlify function, a mobile backend), use an API token instead of per-user auth.
nextjs-productionStrapi shows the token once -- copy it immediately. Use it from your frontend with:
curl -H "Authorization: Bearer YOUR_TOKEN" \
https://cms.yourdomain.com/api/articlesCreate Admin Users and Roles
For your team, go to Settings -> Administration Panel -> Users and invite editors. The built-in admin roles (Super Admin, Editor, Author) are usually enough; custom admin roles are available on the Enterprise edition.
Step 12: Use the Transfer Tool for Staging to Production
One of the biggest wins in self-hosting Strapi is strapi transfer -- a built-in CLI that moves schema, content, configuration, and uploads between two Strapi instances over HTTPS. It makes staging-to-production promotion safe and reversible.
Generate a Transfer Token on Production
On the production server's admin panel:
staging-to-prodPush from Staging to Production
On the staging server, inside ~/strapi-app:
npm run strapi transfer -- \
--to https://cms.yourdomain.com/admin \
--to-token YOUR_TRANSFER_TOKENStrapi prompts for confirmation, diffs the two schemas, shows you the counts (entries, assets, configuration), and streams the data over the encrypted channel. The destination is locked (read-only) during the transfer and rolls back automatically on failure.
Pull a Production Snapshot to Local or Staging
Reverse direction -- useful for reproducing a bug from production content:
npm run strapi transfer -- \
--from https://cms.yourdomain.com/admin \
--from-token YOUR_TRANSFER_TOKENExport to a File
Transfer also supports file-based backups:
# Export to a .tar.gz.enc file (encrypted with a passphrase)
npm run strapi export -- --file backup-2026-04-16Import from a file
npm run strapi import -- --file backup-2026-04-16.tar.gz.encSchedule a nightly export to object storage and you have a reliable point-in-time restore path that is independent of your PostgreSQL backups.
Hardening and Performance Tuning
Database Connection Pool
Edit config/database.ts and tune the pool for your CPU/RAM budget:
export default ({ env }) => ({
connection: {
client: 'postgres',
connection: {
host: env('DATABASE_HOST', '127.0.0.1'),
port: env.int('DATABASE_PORT', 5432),
database: env('DATABASE_NAME'),
user: env('DATABASE_USERNAME'),
password: env('DATABASE_PASSWORD'),
ssl: env.bool('DATABASE_SSL', false),
schema: env('DATABASE_SCHEMA', 'public'),
},
pool: {
min: env.int('DATABASE_POOL_MIN', 2),
max: env.int('DATABASE_POOL_MAX', 10),
},
acquireConnectionTimeout: 60000,
},
});In cluster mode with 4 PM2 workers, max: 10 means up to 40 concurrent connections -- comfortable for PostgreSQL's default max_connections=100. Raise max only if your workload is read-heavy and your PostgreSQL instance can handle more sockets.
Caching Layer
Install the REST cache plugin with a Redis provider for dramatic response-time improvements on read-heavy endpoints:
npm install strapi-plugin-rest-cache strapi-provider-rest-cache-redis
sudo apt install -y redis-serverConfigure in config/plugins.ts, point the provider at redis://127.0.0.1:6379, and Strapi memoizes GET /api/... responses by route.
Backups
A production Strapi install has two stateful pieces: PostgreSQL and the upload provider. Back up both.
PostgreSQL daily dump with rotation:
sudo -u postgres crontab -e0 2 * pg_dump -Fc strapi_prod > /var/backups/strapi/strapi-$(date +\%Y\%m\%d).dump && find /var/backups/strapi -mtime +14 -deleteFor S3/MinIO uploads, either enable bucket versioning or use rclone sync to a secondary bucket nightly.
Log Rotation
Add logrotate for PM2 (or systemd logs):
pm2 install pm2-logrotate
pm2 set pm2-logrotate:max_size 100M
pm2 set pm2-logrotate:retain 14Fail2ban for the Admin Panel
Brute-force attempts against /admin/login are common. Install Fail2ban and add a filter that watches Nginx logs for repeated 401/403 responses on /admin/login/local.
Troubleshooting
| Problem | Cause | Solution |
|---|---|---|
Error: Missing APP_KEYS on startup | .env not loaded or empty keys | Confirm ~/strapi-app/.env exists, has 4 comma-separated base64 keys, and that PM2/systemd reads it (dotenv loads automatically; check pm2 env 0) |
error: password authentication failed for user "strapi_user" | Wrong password or pg_hba.conf method mismatch | Re-verify the password by running psql -h 127.0.0.1 -U strapi_user -d strapi_prod -W. Ensure pg_hba.conf uses scram-sha-256 for 127.0.0.1/32 |
Admin panel stuck on Loading... | Build not run after install or plugin change | cd ~/strapi-app && npm run build && pm2 reload strapi |
413 Request Entity Too Large when uploading | Nginx default client_max_body_size 1m | Raise to 200m (or higher) in the Nginx server block and sudo systemctl reload nginx |
| Images from S3 blocked in admin preview | CSP blocking external origin | Add the S3/MinIO host to img-src in config/middlewares.ts (see Step 10) |
| Cluster workers fighting over bootstrap migrations | Multiple workers running migrations concurrently on first boot | Run NODE_ENV=production npm run start once in foreground to complete migrations, stop it, then start PM2 |
connect ECONNREFUSED 127.0.0.1:1337 from Nginx | Strapi not running or bound to wrong host | pm2 status; check .env HOST=127.0.0.1. If it binds to 0.0.0.0, UFW still allows only 80/443 so Nginx path works -- this is fine |
Cannot find module '@strapi/provider-upload-aws-s3' | Provider not installed in project | cd ~/strapi-app && npm install @strapi/provider-upload-aws-s3 && npm run build && pm2 reload strapi |
Transfer tool errors with Invalid signature | Transfer token expired or regenerated | Regenerate the transfer token on the destination and retry. Tokens cannot be viewed after creation, so store them in a password manager |
Admin panel shows ERR_TOO_MANY_REDIRECTS | URL=http://... while Nginx enforces HTTPS | Set URL=https://cms.yourdomain.com in .env, set proxy: true in config/server.ts, rebuild, reload |
Viewing Logs
pm2 logs strapi --lines 200 # PM2-managed logs
sudo journalctl -u strapi -n 200 # systemd alternative
sudo tail -f /var/log/nginx/error.log
sudo tail -f /var/log/postgresql/postgresql-16-main.logFAQ
Can I run Strapi with SQLite instead of PostgreSQL in production?
Technically yes -- Strapi supports SQLite and it is the default for create-strapi-app quickstart. In practice, do not use SQLite in production. SQLite serializes writes, has no concurrency story across PM2 cluster workers, and does not support the jsonb query operators Strapi v5 uses for components and dynamic zones at scale. PostgreSQL 16 costs nothing on your VPS, handles concurrent editors cleanly, and gives you pg_dump-based backups. Use SQLite only for local development or a single-editor hobby project.
How do I upgrade Strapi v4 to v5?
Strapi v5 is a major breaking change -- id is replaced by documentId, the response format flattens, draft-and-publish is versioned, and many v4 plugins require updated versions. Start by running the official codemod: npx @strapi/upgrade major. Then test every frontend integration against a staging v5 instance before touching production. The migration guide at docs.strapi.io/dev-docs/migration/v4-to-v5 lists every breaking change. Budget a full day for a non-trivial project.
Does Strapi scale horizontally across multiple VPS instances?
Yes, with three conditions. First, all instances must share a single PostgreSQL (use a managed DB or a primary plus read replicas). Second, uploads must go to object storage (S3/MinIO) rather than local disk -- see Step 10. Third, configure sticky sessions or use APP_KEYS identical on every node so session cookies validate across instances. Put Nginx or a load balancer (HAProxy, Caddy, Cloudflare) in front and Strapi behaves like any stateless Node app. For most projects a single well-sized VPS with PM2 cluster mode handles hundreds of editors and millions of API calls -- scale horizontally only when you outgrow one box.
How do I add custom business logic to the API?
Strapi generates default controllers and services for every content type, but you can override or extend them. Edit src/api/article/controllers/article.ts to add a custom method or wrap the default find action. Edit src/api/article/services/article.ts to add reusable business logic. For cross-cutting concerns (logging, auth, tenant scoping), write middleware in src/middlewares/ and register it in config/middlewares.ts. Lifecycle hooks (beforeCreate, afterUpdate) live in src/api/article/content-types/article/lifecycles.ts. Everything is TypeScript-first, so your IDE autocompletes the Strapi types.
Strapi vs. Directus vs. Payload vs. Sanity -- which headless CMS should I pick?
Strapi is the most mature option with the biggest plugin ecosystem, best admin UX for non-technical editors, and the widest choice of databases (Postgres, MySQL, SQLite). Pick it when you have editors who need a friendly admin panel and you want first-class REST and GraphQL out of the box. Directus maps one-to-one onto an existing PostgreSQL schema, making it a better fit when the database already exists and you want a CMS layer on top (see our Directus install guide). Payload is TypeScript-native with a code-first config and excellent developer ergonomics -- pick it when developers drive the schema and editors are secondary. Sanity is SaaS-only with a unique portable text model; pick it only if you want the hosted experience and can pay per-seat. All four are solid; the choice comes down to team skills and who owns the schema.
Can I use Strapi's GraphQL API?
Yes. Install the plugin with npm install @strapi/plugin-graphql, rebuild, and restart. You get a /graphql endpoint with an Apollo Sandbox playground and full queries and mutations for every content type. Permissions are applied from the same roles as the REST API. Performance-wise, REST is usually faster because Strapi's SQL generator is more optimized for REST; if you need deep nested queries, GraphQL wins on network round-trips. You can run both simultaneously -- frontends can pick whichever fits.
Next Steps
You now have a production-grade, self-hosted Strapi v5 instance with PostgreSQL, PM2 cluster mode, Nginx TLS, and S3 uploads. Here is what to tackle next:
- Wire up your frontend -- Point Next.js, Nuxt, Astro, or SvelteKit at
https://cms.yourdomain.com/api. Use an API token for build-time ISR/SSG and the public role for runtime requests. The@strapi/clientnpm package gives you typed fetches in TypeScript projects. - Automate staging-to-production promotions -- Turn Step 12's transfer command into a GitHub Action that runs on merge to
main. Combine withstrapi exportbackups for a fully reproducible CMS pipeline. - Monitor the instance -- Deploy Uptime Kuma or use the monitoring stack from our monitoring stack guide to watch
/admin/initand/api/articles?pagination[pageSize]=1for availability and latency. - Add search with Meilisearch -- Install strapi-plugin-meilisearch for full-text search on your content without shipping your editors a raw Elasticsearch cluster.
- Compare with Directus -- If your project is closer to a database admin panel than a content editor experience, read our Directus on Ubuntu guide for a side-by-side walkthrough of the same VPS stack.
- Read the official docs -- The Strapi documentation covers every plugin, API, and internals topic in depth. Bookmark the REST API and Document Service API references -- you will use them daily.
Skip the Setup -- Deploy on a Strapi-Ready VPS>
Our CloudCore Professional plan is sized for exactly this stack: Node 20, PostgreSQL 16, PM2 cluster mode, and Nginx with Let's Encrypt. Plenty of headroom for a staging copy.>
- 6 vCPU / 12 GB RAM / 100 GB NVMe
- Unmetered bandwidth
- EU and North America locations
- Full root access
- Flat EUR 19.99/month -- no per-editor or per-API-call fees>
Launch Your CloudCore Professional VPS and have Strapi live in under an hour.