Why self-host Strapi instead of using managed SaaS
Strapi's managed hosting is convenient but costly. You pay per seat, per API call, and per storage increment. A team of three editors with moderate content usage can run 500+ dollars monthly. Self-hosting on a VPS costs a fixed 20-40 dollars per month, with no surprise usage charges.
More importantly, self-hosting gives you complete control. Your content lives in a database you control. You can integrate Strapi directly with private systems, run custom plugins without restrictions, and back up data whenever you want. There's no vendor lock-in. You own the infrastructure.
Strapi 5 is particularly well-suited for self-hosting. It's built to run on modest hardware and doesn't require extensive DevOps expertise. With PostgreSQL as your database and Nginx handling traffic, you get a production-grade content management system that scales from your first user to thousands.
Resource requirements for Strapi and PostgreSQL
Running both Strapi and PostgreSQL on the same server demands more resources than a typical Node.js app. Plan for at least 4 GB of RAM to run both comfortably. With 2 GB, you'll hit memory limits quickly under any real traffic. A 2-core CPU handles small deployments, but 4 cores is better if you expect regular traffic or large media libraries.
Disk space depends on your media. If you're managing images or video, plan for 50+ GB on a production setup. Strapi itself takes only 500 MB after installation and build.
For larger deployments or to avoid resource contention, put PostgreSQL on a separate VPS. This guide assumes a combined deployment, which works fine for development and small production sites. If your team is larger or traffic is heavy, separate the database.
Install PostgreSQL on Ubuntu 24.04 LTS
SSH into your VPS and update the package list:
ssh root@your-vps-ip
apt update
apt install -y postgresql postgresql-contrib
Start the PostgreSQL service and enable it to run at boot:
systemctl start postgresql
systemctl enable postgresql
Verify the installation:
sudo -u postgres psql --version
You should see PostgreSQL 16 or later. If the service fails to start, check the logs with journalctl -u postgresql -n 50.
Create a database and dedicated user for Strapi
PostgreSQL uses role-based access. Create a database and a user that Strapi will authenticate as:
sudo -u postgres createdb strapi_db
sudo -u postgres createuser -P strapi_user
The -P flag prompts you to set a password. Choose something strong and save it securely. You'll reference this password in Strapi's configuration.
Grant all privileges on the database to your new user:
sudo -u postgres psql -c "GRANT ALL PRIVILEGES ON DATABASE strapi_db TO strapi_user;"
Also grant schema privileges to ensure Strapi can create tables:
sudo -u postgres psql -c "GRANT ALL PRIVILEGES ON SCHEMA public TO strapi_user;" strapi_db
Understanding connection strings and authentication
PostgreSQL connection strings take this form: postgres://username:password@host:port/database. For local connections on your VPS, use localhost. The default PostgreSQL port is 5432.
Your Strapi connection string will look like:
postgres://strapi_user:your-strong-password@localhost:5432/strapi_db
PostgreSQL uses password authentication by default for local connections, configured in pg_hba.conf. You shouldn't need to edit this file unless you're using socket authentication or SSL certificates.
Verifying the database is ready
Test that your new user can connect:
psql -U strapi_user -d strapi_db -h localhost -c "SELECT version();"
If that command returns the PostgreSQL version, your database setup is complete. If it fails with "password authentication failed", double-check the password you created and ensure you're connecting to the right host.
Deploy Strapi 5 to your VPS
You have two options: clone an existing Strapi project from Git, or create a fresh Strapi installation directly on the server. For most deployments, cloning from Git is cleaner because it includes your content types and plugins.
Create a directory for your app and clone your repository:
mkdir -p /var/www/my-strapi-app
cd /var/www/my-strapi-app
git clone https://github.com/yourusername/my-strapi-app.git .
npm install
If you're starting fresh and don't have an existing Strapi project, create one on the VPS:
npm create strapi-app@latest my-strapi-app
The installer will prompt you to choose a database. Select PostgreSQL and enter your connection details. The installer runs database migrations automatically and creates an initial admin user.
For cloned projects, the next section covers database configuration.
Configure the Strapi database connection
Strapi 5 uses a configuration file at config/database.ts (or config/database.js if not using TypeScript) to define database connections. Open that file and configure PostgreSQL.
export default ({ env }) => ({
connection: {
client: 'postgres',
connection: {
host: env('DATABASE_HOST', 'localhost'),
port: env('DATABASE_PORT', 5432),
database: env('DATABASE_NAME', 'strapi_db'),
user: env('DATABASE_USERNAME', 'strapi_user'),
password: env('DATABASE_PASSWORD'),
ssl: false
},
pool: {
min: env.int('DATABASE_POOL_MIN', 2),
max: env.int('DATABASE_POOL_MAX', 10)
},
useNullAsDefault: true,
},
});
The pool settings control how many database connections Strapi maintains. With min: 2 and max: 10, Strapi keeps 2 connections open for fast requests and allows up to 10 total connections during traffic spikes. A common formula is (CPU cores * 2) + 1 for the maximum.
Connection pooling and performance tuning
The connection pool acts as a reservoir of ready-to-use database connections, avoiding the latency of creating a new connection for every request. The minimum value keeps idle connections open, while the maximum prevents exhausting your database server's resources.
If you see "Timeout acquiring a connection" errors, increase DATABASE_POOL_MAX. If you're running in Docker with automatic cleanup of idle connections, set DATABASE_POOL_MIN to 0 in your Docker environment.
PostgreSQL vs SQLite comparison
SQLite is simpler to set up and works for development, but it's not recommended for production. PostgreSQL handles concurrent users better, supports advanced features like transactions and full-text search, and scales more reliably. You're making the right choice by using PostgreSQL on a VPS.
Create a production environment file with your database credentials:
nano .env.production
Add these variables:
DATABASE_HOST=localhost
DATABASE_PORT=5432
DATABASE_NAME=strapi_db
DATABASE_USERNAME=strapi_user
DATABASE_PASSWORD=your-strong-password
DATABASE_POOL_MIN=2
DATABASE_POOL_MAX=10
NODE_ENV=production
HOST=0.0.0.0
PORT=1337
Restrict file permissions so only the app can read this file:
chmod 600 .env.production
Never commit .env.production to Git. Add it to your .gitignore.
Build Strapi for production
Strapi includes a build process that compiles the admin dashboard and optimizes plugins. This step is essential before running in production.
npm run build
The build process creates a dist/ folder and prepares the admin interface. It might take 2-3 minutes on first run. Verify your package.json includes a start script:
cat package.json | grep "\"start\""
It should show "start": "strapi start" or similar. If it's missing, add it:
"start": "strapi start"
Test the build locally before deploying:
npm run build
npm start
Press Ctrl+C to stop. If the app starts without errors, you're ready for PM2.
Create a PM2 ecosystem configuration file
PM2 will manage your Strapi process and restart it if it crashes. Create ecosystem.config.js in your project root:
module.exports = {
apps: [
{
name: 'strapi',
script: 'npm',
args: 'start',
cwd: '/var/www/my-strapi-app',
instances: 1,
exec_mode: 'fork',
env: {
NODE_ENV: 'production'
},
error_file: '/var/log/pm2/strapi-err.log',
out_file: '/var/log/pm2/strapi-out.log',
log_date_format: 'YYYY-MM-DD HH:mm:ss Z',
max_memory_restart: '1G'
}
]
};
Strapi runs in fork mode with a single instance. Cluster mode isn't supported because Strapi synchronizes its database schema at startup, which breaks when multiple instances try to do this simultaneously. The max_memory_restart setting restarts the process if memory exceeds 1 GB, protecting against memory leaks.
Test the configuration:
cd /var/www/my-strapi-app
npm run build
pm2 start ecosystem.config.js
pm2 logs strapi
pm2 delete strapi
Commit this file to version control so future deploys use the same configuration.
Configure Nginx as a reverse proxy for Strapi
Create an Nginx configuration file:
sudo nano /etc/nginx/sites-available/strapi
Add this configuration:
server {
listen 80;
server_name your-domain.com;
client_max_body_size 50M;
location / {
proxy_pass http://localhost: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;
}
}
Strapi runs on port 1337 by default. The client_max_body_size 50M directive allows file uploads up to 50 MB. Adjust based on your needs (content/media/image files).
Enable this configuration:
sudo ln -s /etc/nginx/sites-available/strapi /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl restart nginx
Nginx reverse proxy headers and caching static assets
The proxy headers forward the original client IP and protocol to Strapi, so logs show real IPs instead of 127.0.0.1. The Upgrade header is needed for WebSocket support if you use real-time features.
To cache static assets served by Strapi (like the admin dashboard), add this location block:
location ~* ^/admin/(.*)$ {
proxy_pass http://localhost:1337;
proxy_cache_valid 200 1h;
expires 1h;
}
Setting up SSL with Certbot
Secure your domain with a free Let's Encrypt certificate:
sudo certbot --nginx -d your-domain.com
Certbot automatically modifies your Nginx config to use HTTPS and sets up auto-renewal. Verify renewal works:
sudo certbot renew --dry-run
Your Nginx config will now include:
server {
listen 443 ssl http2;
server_name your-domain.com;
ssl_certificate /etc/letsencrypt/live/your-domain.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/your-domain.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
client_max_body_size 50M;
location / {
proxy_pass http://localhost: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;
}
}
server {
listen 80;
server_name your-domain.com;
return 301 https://$server_name$request_uri;
}
Start Strapi with PM2
Now launch your Strapi application:
cd /var/www/my-strapi-app
npm run build
pm2 start ecosystem.config.js
pm2 save
pm2 startup
The pm2 save command saves the current process list so PM2 restarts it after a reboot. The pm2 startup command configures the system to start PM2 at boot.
Monitor the logs:
pm2 logs strapi
Visit your domain in a browser. If this is a fresh installation, you'll see the Strapi admin setup screen to create your first user.
Media uploads and file storage strategies
By default, Strapi stores uploaded files locally in public/uploads/. This works for small deployments, but has limits. If your disk fills up or you scale to multiple servers, local storage becomes problematic.
For most VPS deployments, local storage is sufficient. Monitor disk usage:
df -h /var/www/my-strapi-app
If you outgrow local storage, Strapi plugins support S3-compatible providers. Install the S3 upload plugin and configure your credentials. Services like AWS S3, DigitalOcean Spaces, Cloudflare R2, or Scaleway Object Storage all work. Configuration involves setting environment variables for your bucket name, region, access key and secret.
S3-compatible provider setup
Install the official Strapi S3 provider:
npm install @strapi/provider-upload-aws-s3
Configure it in config/plugins.js:
export default {
upload: {
provider: 'aws-s3',
config: {
credentials: {
accessKeyId: env('AWS_ACCESS_KEY_ID'),
secretAccessKey: env('AWS_SECRET_ACCESS_KEY'),
},
region: env('AWS_REGION'),
params: {
Bucket: env('AWS_BUCKET'),
},
},
},
};
Add these to your .env.production:
AWS_ACCESS_KEY_ID=your-key
AWS_SECRET_ACCESS_KEY=your-secret
AWS_REGION=us-east-1
AWS_BUCKET=your-bucket-name
With S3 storage, uploaded files are stored off-server, which scales better for large media libraries.
Disk monitoring and alerting
Set up automated alerts if disk usage climbs. A simple cron job can warn you:
0 */6 * * * df -h /var/www/my-strapi-app | grep -E '[8-9][0-9]%|100%' && echo "Disk almost full" | mail -s "Disk Alert" [email protected]
Backup strategies for Strapi content and database
Your Strapi setup has two critical components: the PostgreSQL database (your content) and uploaded media files. Both need regular backups.
Database backup with pg_dump
Create a backup script:
#!/bin/bash
BACKUP_DIR="/backups/strapi"
mkdir -p $BACKUP_DIR
DATE=$(date +%Y%m%d_%H%M%S)
sudo -u postgres pg_dump strapi_db | gzip > $BACKUP_DIR/strapi_db_$DATE.sql.gz
echo "Database backed up to $BACKUP_DIR/strapi_db_$DATE.sql.gz"
Save as /usr/local/bin/backup-strapi-db.sh and make executable:
chmod +x /usr/local/bin/backup-strapi-db.sh
Media file backup with tar
Back up your uploads directory:
tar -czf /backups/strapi/uploads_$(date +%Y%m%d_%H%M%S).tar.gz /var/www/my-strapi-app/public/uploads/
Automating backups with cron
Schedule daily backups. Edit your crontab:
crontab -e
Add these entries:
0 2 * * * /usr/local/bin/backup-strapi-db.sh
0 3 * * * tar -czf /backups/strapi/uploads_$(date +\%Y\%m\%d).tar.gz /var/www/my-strapi-app/public/uploads/
Database backup runs at 2 AM, media backup at 3 AM daily.
Off-site storage for disaster recovery
Don't keep backups on the same server. Copy them to another VPS or cloud storage:
rsync -avz /backups/strapi/ backup-user@backup-server:/var/backups/strapi/
Or use a cloud provider's CLI tools. For example, with DigitalOcean Spaces (S3-compatible):
aws s3 sync /backups/strapi s3://my-backup-bucket/strapi-backups --no-progress
Schedule this as a daily cron job to keep off-site copies current.
Updating Strapi to new versions
Test updates on a staging environment first. When ready for production:
cd /var/www/my-strapi-app
git pull origin main
npm install
npm run build
pm2 restart strapi
PM2 will gracefully restart the app. Visit your Strapi admin panel after the restart to run any required migrations.
If the update breaks something, roll back:
git revert HEAD
npm install
npm run build
pm2 restart strapi
Performance tuning for Strapi in production
Monitor your Strapi instance with pm2 monit to watch CPU and memory usage in real time. Check the Strapi admin panel's performance section to identify slow content queries.
Database indexing makes a huge difference. If your content types have fields you query frequently (like published status or category), add indexes in PostgreSQL:
sudo -u postgres psql strapi_db -c "CREATE INDEX idx_published ON strapi_db (published_at);"
For detailed metrics over time, set up Prometheus and Grafana monitoring on your VPS.
Troubleshooting common Strapi deployment issues
If Strapi won't start, check PM2 logs first:
pm2 logs strapi --lines 100
Common issues are database connection failures, missing environment variables, or out-of-memory errors. Ensure your .env.production file has all required variables and correct permissions (600).
If your database feels slow, check PostgreSQL logs:
tail -f /var/log/postgresql/postgresql-16-main.log
Slow query logs can be enabled to identify bottlenecks. Query Strapi's database documentation for advanced tuning.
If media uploads fail, verify the public/uploads/ directory exists and is writable by the Node.js process:
ls -la /var/www/my-strapi-app/public/ | grep uploads
Check available disk space with df -h.

