Strapi has become the leading open-source headless CMS for developers who want full control over their content architecture. Unlike traditional CMS platforms that bundle content management with front-end rendering, Strapi provides an API-first approach — you define content types through an intuitive admin panel, and Strapi generates both REST and GraphQL APIs automatically. Self-hosting Strapi on an Ubuntu VPS gives you complete ownership of your content data, eliminates per-editor pricing that plagues SaaS CMS platforms, and lets you scale your content infrastructure independently from your front-end applications.
This guide walks through a complete production deployment of Strapi on an Ubuntu VPS, from initial project creation through PostgreSQL integration, process management, reverse proxy configuration, and front-end connectivity. By the end, you'll have a robust, performant content API serving your websites, mobile apps, and any other consumer that speaks HTTP.
MassiveGRID Ubuntu VPS includes: Ubuntu 24.04 LTS pre-installed · Proxmox HA cluster with automatic failover · Ceph 3x replicated NVMe storage · Independent CPU/RAM/storage scaling · 12 Tbps DDoS protection · 4 global datacenter locations · 100% uptime SLA · 24/7 human support rated 9.5/10
Deploy a self-managed VPS — from $1.99/mo
Need dedicated resources? — from $19.80/mo
Want fully managed hosting? — we handle everything
Why Self-Host a Headless CMS on a VPS
The headless CMS model separates content management from content presentation. Your editorial team creates and organizes content through Strapi's admin dashboard, and that content is delivered to any front-end through structured APIs. This decoupled architecture offers tangible advantages over monolithic CMS platforms like WordPress or Drupal.
API-first content delivery. Every piece of content you create in Strapi is immediately available through auto-generated REST endpoints and optional GraphQL queries. Your Next.js website, React Native mobile app, and digital signage system can all pull from the same content source without duplicating editorial work. Content is returned as clean JSON, not HTML fragments tied to a specific theme.
Front-end flexibility. Because Strapi doesn't dictate how content is rendered, your development team can use whatever framework suits each project — Next.js for a marketing site, Nuxt for an e-commerce storefront, Astro for a documentation portal. Switch frameworks without migrating content. Redesign completely without touching the CMS layer.
No per-editor pricing. SaaS headless CMS platforms like Contentful, Sanity, and Hygraph charge per user seat, often $50–$100+ per editor per month. With self-hosted Strapi, you create unlimited admin accounts at zero marginal cost. A team of twenty content editors costs the same to host as a team of two.
Data sovereignty. Your content lives on your VPS, under your control. No vendor lock-in, no surprise API deprecations, no terms-of-service changes that affect how you access your own data. Export everything at any time because you own the database.
Customization depth. Strapi's plugin system and custom controller layer let you extend the CMS with business logic — custom validation, automated content workflows, webhook-triggered deployments, third-party integrations. Self-hosting means no restrictions on what plugins you install or what code you deploy.
Strapi vs Directus vs Ghost: Choosing Your Headless CMS
Strapi isn't the only self-hosted headless CMS option. Understanding the differences helps you choose the right tool:
Strapi is purpose-built as a headless CMS with a strong focus on developer experience. Its content type builder lets non-developers define schemas visually, and the auto-generated API layer means zero boilerplate for content delivery. Strapi excels when you need a flexible content backend for multiple front-ends and want both REST and GraphQL out of the box.
Directus takes a database-first approach — it wraps any existing SQL database with an API and admin panel. This makes it excellent for projects where you already have a database schema or need to manage data that goes beyond traditional CMS content. However, its content modeling workflow is less intuitive for editorial teams compared to Strapi's content type builder.
Ghost focuses on publishing — blogs, newsletters, and membership content. If your primary need is long-form publishing with built-in email newsletters and member subscriptions, Ghost may be the better fit. But Ghost's content API is limited to posts and pages; it lacks the arbitrary content type flexibility that Strapi provides.
For most API-first content management scenarios — especially those involving structured content types, multi-platform delivery, and custom data models — Strapi offers the best balance of power and usability.
Prerequisites: Node.js and PostgreSQL
Strapi runs on Node.js and supports SQLite, MySQL, MariaDB, and PostgreSQL as its database backend. For production deployments, PostgreSQL is the recommended choice due to its reliability, performance with complex queries, and robust JSON support that Strapi leverages for dynamic content types.
Start by ensuring your VPS has Node.js installed. Strapi 5.x requires Node.js 18 or 20 (LTS versions). If you haven't set up Node.js yet, follow our Node.js deployment guide to install it via the NodeSource repository or nvm:
node --version
# Should output v18.x.x or v20.x.x
npm --version
# Should output 9.x or higher
Next, install and configure PostgreSQL. If PostgreSQL isn't already running on your VPS, our PostgreSQL installation guide covers the complete setup. Create a dedicated database and user for Strapi:
sudo -u postgres psql
CREATE USER strapi_user WITH PASSWORD 'your_secure_password_here';
CREATE DATABASE strapi_db OWNER strapi_user;
GRANT ALL PRIVILEGES ON DATABASE strapi_db TO strapi_user;
\q
Choose a strong password and store it securely — you'll reference it in Strapi's environment configuration. Verify the connection works:
psql -U strapi_user -d strapi_db -h localhost
# Enter password when prompted
# If you see the psql prompt, the connection is working
\q
Creating Your Strapi Project
Create a new Strapi project using the official CLI. We'll initialize it and then configure it to use PostgreSQL instead of the default SQLite:
cd /var/www
npx create-strapi-app@latest my-cms --no-run
The --no-run flag creates the project without starting the development server, giving you time to configure the database connection first. Navigate into the project directory:
cd /var/www/my-cms
Switching to PostgreSQL
Install the PostgreSQL client package that Strapi needs:
npm install pg
Edit the database configuration file at config/database.js (or config/database.ts if you chose TypeScript during setup):
// config/database.js
module.exports = ({ env }) => ({
connection: {
client: 'postgres',
connection: {
host: env('DATABASE_HOST', 'localhost'),
port: env.int('DATABASE_PORT', 5432),
database: env('DATABASE_NAME', 'strapi_db'),
user: env('DATABASE_USERNAME', 'strapi_user'),
password: env('DATABASE_PASSWORD', ''),
ssl: env.bool('DATABASE_SSL', false),
},
pool: {
min: env.int('DATABASE_POOL_MIN', 2),
max: env.int('DATABASE_POOL_MAX', 10),
},
},
});
Create a .env file in the project root with your database credentials:
DATABASE_HOST=localhost
DATABASE_PORT=5432
DATABASE_NAME=strapi_db
DATABASE_USERNAME=strapi_user
DATABASE_PASSWORD=your_secure_password_here
APP_KEYS=key1_base64,key2_base64,key3_base64,key4_base64
API_TOKEN_SALT=random_salt_value
ADMIN_JWT_SECRET=random_jwt_secret
TRANSFER_TOKEN_SALT=random_transfer_salt
JWT_SECRET=random_jwt_secret_for_users
Generate the required secrets using Node.js:
# Generate random keys
node -e "console.log(Array.from({length: 4}, () => require('crypto').randomBytes(32).toString('base64')).join(','))"
# Generate individual salts
node -e "console.log(require('crypto').randomBytes(32).toString('base64'))"
Now build and start Strapi to verify everything works:
NODE_ENV=production npm run build
NODE_ENV=production npm run start
Strapi should start on port 1337. Visit http://your-server-ip:1337/admin to create your first administrator account. Once confirmed, stop the process — we'll set up proper process management next.
PM2 Production Process Management
Running Strapi directly in a terminal session means it dies when you disconnect. PM2 keeps Strapi running persistently, restarts it on crashes, and manages log rotation:
# Install PM2 globally
npm install -g pm2
# Create an ecosystem file
cd /var/www/my-cms
Create ecosystem.config.js in the project root:
// ecosystem.config.js
module.exports = {
apps: [
{
name: 'strapi-cms',
cwd: '/var/www/my-cms',
script: 'npm',
args: 'run start',
env: {
NODE_ENV: 'production',
DATABASE_HOST: 'localhost',
DATABASE_PORT: 5432,
DATABASE_NAME: 'strapi_db',
DATABASE_USERNAME: 'strapi_user',
DATABASE_PASSWORD: 'your_secure_password_here',
},
instances: 1,
autorestart: true,
max_memory_restart: '1G',
log_date_format: 'YYYY-MM-DD HH:mm:ss Z',
},
],
};
Start Strapi through PM2 and configure it to survive reboots:
pm2 start ecosystem.config.js
pm2 save
pm2 startup
# Verify it's running
pm2 status
pm2 logs strapi-cms --lines 50
PM2 will automatically restart Strapi if it crashes or if memory usage exceeds the 1GB threshold. For a VPS with 4GB RAM, this leaves ample room for PostgreSQL, Nginx, and the operating system. Monitor resource usage with pm2 monit to fine-tune the memory limit for your workload.
Nginx Reverse Proxy for Admin Panel and API
Strapi runs on port 1337 by default, but you should never expose Node.js applications directly to the internet. Nginx acts as a reverse proxy, handling SSL termination, static file serving, request buffering, and basic security headers. For a detailed walkthrough of Nginx reverse proxy concepts, see our Nginx reverse proxy guide.
Install Nginx and create a server block:
sudo apt install nginx -y
sudo nano /etc/nginx/sites-available/strapi
Add the following configuration:
server {
listen 80;
server_name cms.yourdomain.com;
client_max_body_size 128M;
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";
proxy_buffering on;
proxy_buffer_size 16k;
proxy_buffers 8 16k;
proxy_busy_buffers_size 32k;
}
# Cache static admin assets
location /admin {
proxy_pass http://127.0.0.1:1337/admin;
proxy_cache_valid 200 1d;
add_header Cache-Control "public, max-age=86400";
}
# Serve uploaded media efficiently
location /uploads {
alias /var/www/my-cms/public/uploads;
expires 30d;
add_header Cache-Control "public, immutable";
}
}
Enable the configuration and test it:
sudo ln -s /etc/nginx/sites-available/strapi /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx
The client_max_body_size 128M directive is important for Strapi — content editors frequently upload images and documents, and the default 1MB Nginx limit will reject most file uploads silently.
SSL Certificate Configuration
Every production Strapi deployment needs HTTPS. The admin panel transmits credentials, API tokens authenticate requests, and modern browsers flag HTTP-only sites as insecure. Use Let's Encrypt with Certbot to obtain free SSL certificates. Our SSL certificate guide covers the process in detail:
sudo apt install certbot python3-certbot-nginx -y
sudo certbot --nginx -d cms.yourdomain.com
Certbot modifies your Nginx configuration to add SSL listeners and redirect HTTP to HTTPS automatically. After SSL is active, update Strapi's server configuration to reflect the public URL:
// config/server.js
module.exports = ({ env }) => ({
host: env('HOST', '0.0.0.0'),
port: env.int('PORT', 1337),
url: env('PUBLIC_URL', 'https://cms.yourdomain.com'),
app: {
keys: env.array('APP_KEYS'),
},
});
Add PUBLIC_URL=https://cms.yourdomain.com to your .env file. This ensures Strapi generates correct URLs for media uploads, admin panel assets, and API responses.
Environment Variables and Production Configuration
Strapi's configuration system supports environment-specific overrides. Create a production-specific server configuration at config/env/production/server.js:
// config/env/production/server.js
module.exports = ({ env }) => ({
host: env('HOST', '0.0.0.0'),
port: env.int('PORT', 1337),
url: env('PUBLIC_URL'),
proxy: true,
app: {
keys: env.array('APP_KEYS'),
},
});
The proxy: true setting tells Strapi it's running behind a reverse proxy and should trust X-Forwarded-* headers from Nginx. Without this, Strapi may generate incorrect URLs or reject legitimate requests.
Create a production admin configuration at config/env/production/admin.js to tighten security:
// config/env/production/admin.js
module.exports = ({ env }) => ({
auth: {
secret: env('ADMIN_JWT_SECRET'),
},
apiToken: {
salt: env('API_TOKEN_SALT'),
},
transfer: {
token: {
salt: env('TRANSFER_TOKEN_SALT'),
},
},
flags: {
nps: false,
promoteEE: false,
},
});
Never hardcode secrets in configuration files. Always use environment variables loaded from your .env file or injected through PM2's ecosystem config. Your .env file should have restrictive permissions:
chmod 600 /var/www/my-cms/.env
chown www-data:www-data /var/www/my-cms/.env
Content Types, API Endpoints, and Permissions
With Strapi running in production, you can build your content architecture through the admin panel at https://cms.yourdomain.com/admin.
Creating content types. Navigate to the Content-Type Builder in the admin sidebar. Strapi supports two categories: Collection Types (multiple entries, like blog posts or products) and Single Types (one entry, like a homepage or site settings). For each content type, you define fields — text, rich text, media, relations, components, dynamic zones, and more.
For example, creating a "Blog Post" collection type might include fields for title (text), slug (UID), content (rich text), featured image (media), category (relation to a Category collection), author (relation to a User), publication date (datetime), and SEO metadata (component).
Auto-generated API endpoints. Each collection type automatically gets REST endpoints:
GET /api/blog-posts # List all posts (with pagination)
GET /api/blog-posts/:id # Get single post
POST /api/blog-posts # Create post (authenticated)
PUT /api/blog-posts/:id # Update post (authenticated)
DELETE /api/blog-posts/:id # Delete post (authenticated)
Configuring permissions. By default, all API endpoints are locked down. Navigate to Settings → Users & Permissions Plugin → Roles to configure access. For a typical setup, grant the Public role find and findOne permissions on content types that should be publicly accessible. Keep create, update, and delete restricted to authenticated roles.
For programmatic access from front-end build processes, create API tokens under Settings → API Tokens. Use read-only tokens with limited scope for front-end data fetching, and keep full-access tokens restricted to deployment pipelines.
Media Upload Configuration
By default, Strapi stores uploaded media files on the local filesystem in public/uploads/. This works for single-server deployments, but consider the tradeoffs:
Local filesystem (default). Simple to set up, no additional services needed. Files are served directly by Nginx from the /uploads location block we configured earlier. Backups must include this directory. Adequate for most small to medium deployments.
S3-compatible object storage. For larger deployments or when you want to separate media storage from application storage, Strapi supports S3-compatible providers. If you're running MinIO on your VPS, you can use it as a local S3-compatible storage backend:
npm install @strapi/provider-upload-aws-s3
Configure the upload provider in config/plugins.js:
// config/plugins.js
module.exports = ({ env }) => ({
upload: {
config: {
provider: 'aws-s3',
providerOptions: {
baseUrl: env('MINIO_PUBLIC_URL'),
s3Options: {
credentials: {
accessKeyId: env('MINIO_ACCESS_KEY'),
secretAccessKey: env('MINIO_SECRET_KEY'),
},
endpoint: env('MINIO_ENDPOINT'),
region: 'us-east-1',
forcePathStyle: true,
params: {
Bucket: env('MINIO_BUCKET', 'strapi-uploads'),
},
},
},
},
},
});
Object storage decouples your media files from the Strapi application server, making it easier to scale, back up, and serve media through a CDN.
API Response Caching with Redis
For high-traffic content APIs, caching API responses in Redis dramatically reduces database load and improves response times. If you haven't set up Redis yet, follow our Redis installation guide.
Install the Strapi Redis caching plugin or implement a custom middleware. A straightforward approach uses a custom middleware that caches GET responses:
// src/middlewares/redis-cache.js
const Redis = require('ioredis');
const redis = new Redis({
host: process.env.REDIS_HOST || '127.0.0.1',
port: process.env.REDIS_PORT || 6379,
});
module.exports = (config) => {
const ttl = config.ttl || 60; // Cache TTL in seconds
return async (ctx, next) => {
if (ctx.method !== 'GET' || ctx.path.startsWith('/admin')) {
return next();
}
const cacheKey = `strapi:${ctx.url}`;
const cached = await redis.get(cacheKey);
if (cached) {
ctx.body = JSON.parse(cached);
ctx.set('X-Cache', 'HIT');
return;
}
await next();
if (ctx.status === 200 && ctx.body) {
await redis.setex(cacheKey, ttl, JSON.stringify(ctx.body));
ctx.set('X-Cache', 'MISS');
}
};
};
Register the middleware in config/middlewares.js and install the ioredis package:
npm install ioredis
Configure cache invalidation to clear relevant keys when content is updated. Add lifecycle hooks to your content types that flush cache entries when records are created, updated, or deleted:
// src/api/blog-post/content-types/blog-post/lifecycles.js
const Redis = require('ioredis');
const redis = new Redis();
module.exports = {
async afterCreate() {
await redis.del('strapi:/api/blog-posts*');
},
async afterUpdate() {
await redis.del('strapi:/api/blog-posts*');
},
async afterDelete() {
await redis.del('strapi:/api/blog-posts*');
},
};
With Redis caching, repeated API requests for the same content return in under 5ms instead of 50–200ms, which is particularly impactful when multiple front-end applications are fetching content simultaneously.
Connecting Front-End Frameworks
The power of a headless CMS materializes when you connect front-end applications. Strapi's REST and GraphQL APIs work with any framework that can make HTTP requests.
Next.js (React). Use Strapi's REST API with Next.js data fetching methods. For static generation, fetch content at build time:
// pages/blog/[slug].js
export async function getStaticProps({ params }) {
const res = await fetch(
`https://cms.yourdomain.com/api/blog-posts?filters[slug][$eq]=${params.slug}&populate=*`,
{
headers: {
Authorization: `Bearer ${process.env.STRAPI_API_TOKEN}`,
},
}
);
const { data } = await res.json();
return { props: { post: data[0] }, revalidate: 60 };
}
export async function getStaticPaths() {
const res = await fetch('https://cms.yourdomain.com/api/blog-posts?fields[0]=slug');
const { data } = await res.json();
return {
paths: data.map((post) => ({ params: { slug: post.attributes.slug } })),
fallback: 'blocking',
};
}
Nuxt (Vue). The @nuxtjs/strapi module simplifies integration. Configure the Strapi URL in nuxt.config.ts and use the useStrapi composable in your components for reactive data fetching with automatic typing.
Astro. Astro's static-first architecture pairs exceptionally well with Strapi. Fetch content in Astro's frontmatter section and render it as static HTML at build time. For hosting the generated static site, see our guide on hosting static sites on Ubuntu VPS. The combination of Strapi for content management and Astro for static rendering delivers outstanding performance — your pages load instantly because there's no runtime JavaScript or server-side rendering overhead.
Webhooks for automated rebuilds. Configure Strapi webhooks under Settings → Webhooks to trigger front-end rebuilds when content changes. Point the webhook to your CI/CD pipeline or a simple rebuild script on your VPS:
# /var/www/rebuild-frontend.sh
#!/bin/bash
cd /var/www/frontend
npm run build
pm2 restart frontend-app
This ensures your static front-end always reflects the latest content without manual intervention.
Content API Performance and Dedicated Resources
As your content library grows and front-end traffic increases, API response times become critical. Every additional 100ms of API latency adds directly to your page load time, impacting user experience and SEO rankings.
Strapi's API performance depends heavily on database query speed, which in turn depends on consistent CPU and I/O availability. On shared VPS infrastructure, neighboring tenants can cause CPU and disk I/O contention that introduces unpredictable latency spikes in your content API.
For production content APIs serving front-end applications, a Dedicated VPS (VDS) ensures consistent API response times. With dedicated CPU cores and NVMe storage that isn't shared with other tenants, your PostgreSQL queries and Strapi response generation execute with predictable, low latency. This matters most during traffic spikes — a product launch, viral blog post, or marketing campaign — when your content API needs to handle hundreds of concurrent requests without degradation.
A 2vCPU/4GB VPS handles moderate Strapi workloads well — around 50–100 concurrent API requests with response times under 100ms. For heavier workloads or when serving multiple front-ends simultaneously, consider scaling to 4vCPU/8GB with dedicated resources to maintain sub-50ms response times under load.
Backup Strategy for Strapi
A Strapi deployment has three components that need regular backups: the PostgreSQL database (your content), the public/uploads directory (media files), and the project configuration files (content type schemas, plugin settings, environment configs).
Automate database backups using pg_dump:
# Daily PostgreSQL backup
pg_dump -U strapi_user -h localhost strapi_db | gzip > /var/backups/strapi/db-$(date +%Y%m%d).sql.gz
# Sync uploads directory
rsync -a /var/www/my-cms/public/uploads/ /var/backups/strapi/uploads/
# Backup configuration
tar czf /var/backups/strapi/config-$(date +%Y%m%d).tar.gz \
/var/www/my-cms/config/ \
/var/www/my-cms/src/ \
/var/www/my-cms/.env \
/var/www/my-cms/ecosystem.config.js
Our automated backup guide covers setting up cron schedules, retention policies, and off-site backup storage to protect your content investment. For Strapi specifically, test your restore process regularly — dump the backup to a staging environment and verify that content types, entries, media references, and user accounts all restore correctly.
Production Maintenance and Updates
Keeping Strapi updated is important for security patches and feature improvements. Strapi follows semantic versioning, so minor and patch updates are generally safe to apply:
# Check for updates
npm outdated @strapi/strapi
# Update Strapi (minor/patch)
cd /var/www/my-cms
npm update @strapi/strapi
NODE_ENV=production npm run build
pm2 restart strapi-cms
For major version upgrades, always test on a staging environment first. Strapi major releases may include database migrations and breaking changes to content type schemas or API behavior. Review the official migration guide and back up your database before proceeding.
Monitor your Strapi instance with PM2's built-in metrics and consider adding health checks:
# Add to Nginx for load balancer health checks
location /health {
proxy_pass http://127.0.0.1:1337/_health;
access_log off;
}
Set up log rotation for PM2 logs to prevent disk space issues on long-running deployments:
pm2 install pm2-logrotate
pm2 set pm2-logrotate:max_size 50M
pm2 set pm2-logrotate:retain 10
Security Hardening
Beyond SSL, several security measures protect your Strapi deployment:
Restrict admin panel access. If your editorial team connects from known IP ranges, add an Nginx allow/deny block to the /admin location:
location /admin {
allow 203.0.113.0/24; # Office IP range
deny all;
proxy_pass http://127.0.0.1:1337/admin;
}
Rate limiting. Protect the API from abuse and the admin login from brute-force attempts:
# In the http block of nginx.conf
limit_req_zone $binary_remote_addr zone=strapi_api:10m rate=30r/s;
limit_req_zone $binary_remote_addr zone=strapi_admin:10m rate=5r/s;
# In the server block
location /api {
limit_req zone=strapi_api burst=50 nodelay;
proxy_pass http://127.0.0.1:1337;
}
location /admin {
limit_req zone=strapi_admin burst=10 nodelay;
proxy_pass http://127.0.0.1:1337;
}
Security headers. Add standard security headers in your Nginx configuration:
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
Firewall rules. Ensure only ports 22 (SSH), 80 (HTTP), and 443 (HTTPS) are open. PostgreSQL (5432) and Strapi (1337) should only be accessible from localhost:
sudo ufw allow 22/tcp
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw enable
Prefer Managed CMS Hosting?
Self-hosting Strapi gives you maximum control and eliminates per-seat licensing costs, but it does require ongoing maintenance — Node.js runtime updates, PostgreSQL version upgrades, security patching, backup verification, media storage management, and SSL certificate renewals. For development teams focused on building front-end experiences rather than managing infrastructure, the operational overhead can divert attention from product work.
MassiveGRID's fully managed hosting handles the entire infrastructure layer for your Strapi deployment. We manage Node.js updates, PostgreSQL backups and optimization, media storage scaling, SSL renewals, security patching, and 24/7 monitoring. Your Strapi instance runs on dedicated resources with automatic failover, so your content API stays responsive even during maintenance windows. You focus on content modeling and front-end development while we keep the stack running, secure, and performant.
Whether you choose a self-managed VPS starting at $1.99/mo for development and testing, a Dedicated VPS for production content APIs with guaranteed resources, or fully managed hosting for hands-off operation, MassiveGRID provides the infrastructure foundation your headless CMS needs to deliver content reliably across every front-end channel.