A headless CMS gives your editors a friendly admin panel and gives your developers an API, so the same content can feed a website, a mobile app and anything else. This guide takes a Strapi project from your laptop to a production server with PostgreSQL, systemd, nginx and backups.
Strapi is one of the most popular open-source headless CMSs: a Node.js admin panel that generates a REST or GraphQL API from the content types you define. Develop locally, then run it on a VPS under systemd with PostgreSQL, behind nginx with SSL, with uploads in S3-compatible storage and your own off-server backups.
Headless vs traditional CMS
| Traditional (WordPress) | Headless (Strapi) |
|---|---|
| Single repo, HTML+CSS+JS | Separate backend (API) + frontend (any) |
| Template layer coupled to content | API-first, zero presentation |
| Hard to reuse across web+mobile+IoT | One API serves all clients |
| PHP ecosystem | Node.js ecosystem |
| Plugins often fragile | TypeScript SDK + custom controllers |
Pick Strapi when:
- You need the same content in web + mobile + email
- You want developers to build custom frontends with React/Vue/Next/Flutter
- Editorial team wants a friendly UI to manage content
- You'll outgrow WordPress for content schema complexity
Requirements
- A VPS with at least 2 GB of RAM (4 GB is more comfortable with PostgreSQL on the same server, and the admin build is memory-hungry)
- A Node.js LTS version supported by your Strapi release (check Strapi's docs; Node.js 22 is a safe choice)
- PostgreSQL (recommended) or MySQL/MariaDB
- nginx for reverse proxy + SSL
Step 1 — Create Strapi project
On your laptop first (faster dev loop):
npx create-strapi@latest my-cms
# Accept the default SQLite database for local development
cd my-cms && npm run developVisit http://localhost:1337/admin — create admin account.
Create content types (e.g. "Article" with title, body, author, thumbnail). Strapi auto-generates the API endpoints.
Test API:
curl http://localhost:1337/api/articlesNew content types are private: until you allow find for the Public role (Settings → Users & Permissions → Roles) or send an API token, this returns 403.
Step 2 — Prepare production config
Edit config/database.ts for production:
export default ({ env }) => ({
connection: {
client: 'postgres',
connection: {
host: env('DATABASE_HOST', 'localhost'),
port: env.int('DATABASE_PORT', 5432),
database: env('DATABASE_NAME', 'strapi'),
user: env('DATABASE_USERNAME', 'strapi'),
password: env('DATABASE_PASSWORD'),
ssl: env.bool('DATABASE_SSL', false),
},
pool: { min: 2, max: 10 },
},
});config/server.ts:
export default ({ env }) => ({
host: env('HOST', '127.0.0.1'), // only nginx should reach Strapi
port: env.int('PORT', 1337),
url: env('PUBLIC_URL', 'https://cms.yourcompany.com'),
proxy: { koa: true }, // behind nginx (Strapi 4 uses: proxy: true)
app: {
keys: env.array('APP_KEYS'),
},
});Step 3 — Setup VPS
# AlmaLinux / Rocky, as root
curl -fsSL https://rpm.nodesource.com/setup_22.x | sudo bash -
sudo dnf install -y epel-release
sudo dnf install -y nodejs postgresql-server postgresql-contrib nginx certbot python3-certbot-nginx git
# or Ubuntu / Debian:
# curl -fsSL https://deb.nodesource.com/setup_22.x | sudo bash -
# sudo apt install -y nodejs postgresql nginx certbot python3-certbot-nginx git
# PostgreSQL setup (AlmaLinux; Ubuntu initialises it for you)
sudo postgresql-setup --initdb
sudo systemctl enable --now postgresql
# Use a long random password, not the placeholder below
sudo -u postgres psql <<EOF
CREATE USER strapi WITH PASSWORD 'use-a-long-random-password';
CREATE DATABASE strapi OWNER strapi;
EOF
# On AlmaLinux, pg_hba.conf defaults to "ident" for 127.0.0.1; change the
# host lines to scram-sha-256 and reload PostgreSQL so password logins work
# Create app user
sudo useradd -m -s /bin/bash strapi
sudo su - strapiStep 4 — Deploy code
# As strapi user
cd ~
git clone https://github.com/yourcompany/my-cms.git
cd my-cms
npm ciCreate .env:
HOST=127.0.0.1
PORT=1337
NODE_ENV=production
PUBLIC_URL=https://cms.yourcompany.com
DATABASE_HOST=localhost
DATABASE_PORT=5432
DATABASE_NAME=strapi
DATABASE_USERNAME=strapi
DATABASE_PASSWORD=use-a-long-random-password
DATABASE_SSL=false
APP_KEYS=key1,key2,key3,key4
API_TOKEN_SALT=random-long-string
ADMIN_JWT_SECRET=another-random-string
TRANSFER_TOKEN_SALT=yet-another
JWT_SECRET=one-more-random-stringProtect the file with chmod 600 .env, and generate each random value with:
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"Build the admin panel:
NODE_ENV=production npm run buildDon't open the admin panel to the internet yet: whoever reaches /admin first on a new install can register the first administrator. You'll create the admin account at the end of Step 6, once nginx and SSL are in place.
Step 5 — systemd service
/etc/systemd/system/strapi.service:
[Unit]
Description=Strapi CMS
After=network.target postgresql.service
[Service]
Type=simple
User=strapi
Group=strapi
WorkingDirectory=/home/strapi/my-cms
ExecStart=/usr/bin/node /home/strapi/my-cms/node_modules/.bin/strapi start
Restart=on-failure
RestartSec=5
EnvironmentFile=/home/strapi/my-cms/.env
# Hardening
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ReadWritePaths=/home/strapi
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.targetStart:
sudo systemctl daemon-reload
sudo systemctl enable --now strapi
sudo journalctl -u strapi -fStep 6 — nginx reverse proxy
/etc/nginx/conf.d/strapi.conf:
upstream strapi { server 127.0.0.1:1337; }
server {
listen 80;
server_name cms.yourcompany.com;
client_max_body_size 100M; # for uploads
location / {
proxy_pass http://strapi;
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_read_timeout 120s;
}
}Test + SSL:
sudo nginx -t && sudo systemctl reload nginx
sudo certbot --nginx -d cms.yourcompany.comNow open https://cms.yourcompany.com/admin straight away and create your administrator account.
Step 7 — Frontend integration
Generate API token in Admin → Settings → API Tokens.
Next.js:
async function getArticles() {
const res = await fetch(`${process.env.STRAPI_URL}/api/articles?populate=*`, {
headers: { Authorization: `Bearer ${process.env.STRAPI_TOKEN}` },
next: { revalidate: 60 },
});
const json = await res.json();
return json.data;
}Vue/Nuxt:
Anything in client-side code is public, so use a read-only API token here, or call Strapi from server routes and keep tokens on the server.
const { data } = await useFetch('/api/articles?populate=*', {
baseURL: 'https://cms.yourcompany.com',
headers: { Authorization: `Bearer ${config.strapiToken}` },
});React Native:
Same pattern; use TanStack Query or SWR for caching, and a read-only token.
Step 8 — Media uploads → S3/R2
By default Strapi stores uploads on local disk, so a rebuilt or lost server loses your media. Use object storage:
npm install @strapi/provider-upload-aws-s3config/plugins.ts:
export default ({ env }) => ({
upload: {
config: {
provider: 'aws-s3',
providerOptions: {
s3Options: {
credentials: {
accessKeyId: env('AWS_ACCESS_KEY_ID'),
secretAccessKey: env('AWS_SECRET_ACCESS_KEY'),
},
region: 'auto',
endpoint: env('AWS_ENDPOINT'), // e.g. Cloudflare R2 endpoint
params: { Bucket: env('AWS_BUCKET') },
},
},
},
},
});Also add your bucket's public domain to the img-src and media-src lists of the security middleware in config/middlewares.ts, or the admin panel won't show thumbnails. See our Cloudflare Workers and R2 article for storage without egress fees.
Step 9 — Backups
Cron entries must fit on one line, so put the steps in a script. /home/strapi/backup.sh (make it executable with chmod 700, and put the database password in /home/strapi/.pgpass with chmod 600):
#!/bin/bash
set -euo pipefail
mkdir -p /home/strapi/backups
d=$(date +%Y%m%d)
pg_dump -h localhost -U strapi strapi | gzip > /home/strapi/backups/db-$d.sql.gz
tar czf /home/strapi/backups/uploads-$d.tar.gz -C /home/strapi/my-cms/public/uploads .
find /home/strapi/backups -name "*.gz" -mtime +30 -deleteThen /etc/cron.d/strapi-backup:
0 3 * * * strapi /home/strapi/backup.shBackups on the same server don't protect you if the server is lost. Copy them off the VPS every day; see Automated backups with cron and rclone.
Plugins worth installing
- Internationalisation (i18n) — multi-language content (built into Strapi 5)
- Users & Permissions — JWT auth for your API's end users (built in)
- GraphQL plugin — GraphQL alongside REST
- SEO plugin (community) — meta fields per entry
- Meilisearch or Algolia plugin — search
Install only what you need, and rebuild the admin panel after each plugin.
Common pitfalls
NODE_ENV=production npm run build again.pg_dump before every upgrade.NODE_ENV=production.systemctl status strapi or your monitoring, and investigate plugins before you schedule restarts.Running this on Domain India
- VPS: this guide is written for a Domain India VPS, which is self-managed with full root access, from ₹553 a month excluding 18% GST. You install and secure the stack yourself, and VPS plans include no backups or snapshots, which is why Step 9 matters.
- App Platform: Node.js apps are detected automatically and every plan includes PostgreSQL and free SSL, so Strapi is a possible fit. Test it first, and keep uploads in S3-compatible storage rather than on the app's local disk. See the App Platform guide.
- Shared hosting: Strapi runs as its own long-lived Node.js server with a memory-hungry admin build, which shared hosting isn't designed for. Use a VPS or the App Platform.
FAQ
Strapi vs Directus vs Payload CMS?
Strapi has the largest ecosystem and plugin marketplace. Directus is database-first and works on top of an existing SQL schema. Payload is TypeScript-native and can run inside a Next.js app. All three are valid; Strapi is the most widely used.
Strapi Cloud vs self-hosted?
Strapi Cloud is Strapi's managed hosting, priced per project; check its pricing page for current plans and regions. Self-hosting on a VPS usually costs less and gives you full control, but you handle updates, security and backups yourself.
Scalability?
For read-heavy sites, put a cache or CDN in front of the API, or have your frontend build static pages, so most requests never reach Strapi. For bigger loads, run several Strapi instances behind a load balancer with shared object storage for uploads.
Can I use Strapi for e-commerce?
Possible but not ideal — no built-in cart/checkout. Pair with Medusa.js or use a dedicated e-commerce platform.
Is Strapi free?
The Community Edition is free and open source, and covers most projects. Paid plans add features such as SSO, audit logs, content history and releases.
Ready to host your CMS? Compare VPS plans, or open a support ticket with questions about a plan.
A self-managed Domain India VPS gives you full root access to run Strapi, PostgreSQL and nginx together.
See VPS plans