Skip to main contentSkip to navigation
[email protected]
Client AreaSupport
Hosting Mammoth
HostingMammothYour Data, Our Responsibility
Home
Solutions
Hosting Services
Store
Pricing
About
Blog
API
Contact

Stay Ahead of the Curve

Get the latest insights on cybersecurity, AI innovations, and enterprise data solutions delivered to your inbox.

Hosting Mammoth
HostingMammothEnterprise Solutions

Enterprise-grade data solutions. Hosting, recovery, cybersecurity, and AI-powered services for businesses worldwide.

[email protected]
Sun - Fri, 9:00am - 5:00pm

Services

  • Cloud Hosting
  • Data Recovery
  • Cybersecurity
  • Legal Support
  • MSP Services
  • Web Development
  • AI Services
  • Free Server Migration

Hosting

  • VPS Hosting (NVMe SSD)
  • VDS Hosting (NVMe)
  • Storage VPS (High SSD)
  • GPU Servers
  • Managed Services
  • Cloud Firewall
  • Load Balancer
  • One-Click Apps
  • n8n Hosting
  • Object Storage
  • FAQ

Company

  • Store
  • Pricing
  • About Us
  • Locations
  • Blog
  • Testimonials
  • Contact
  • Affiliate Program
  • White-Label
  • Terms of Service
  • Privacy Policy
  • Browser Cookies
  • SLA

Support

  • Client Area
  • Submit Ticket
  • Knowledge Base
  • Server Status
  • API Documentation

© 2026 Hosting Mammoth. All rights reserved.

Knowledge Base
Getting StartedAccount ManagementVPS HostingGPU ServersStorage VPSCloud FirewallLoad BalancerServer ManagementBilling & PaymentsSupport & TicketsAffiliate ProgramReseller ProgramMarketplace & Appsn8n HostingManaged ServicesServer MigrationAPI & DevelopersSecurityTroubleshootingGlossaryInstall Guides
  1. Home
  2. /
  3. Support
  4. /
  5. Install Guides
  6. /
  7. How To Install Strapi Ubuntu
GUIDEInstall Guides

How to Install Strapi v5 on Ubuntu 24.04 VPS: Self-Hosted Headless CMS with PostgreSQL

34 min read

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?
  • Why Self-Host Strapi Instead of Using Strapi Cloud or Contentful?
  • Prerequisites
  • Step 1: Prepare the Ubuntu 24.04 Server
  • Step 2: Install Node.js 20 LTS
  • Step 3: Install and Configure PostgreSQL 16
  • Step 4: Create the Strapi Application
  • Step 5: Configure the .env File
  • Step 6: Build Strapi for Production
  • Step 7: Run Strapi with PM2 in Cluster Mode
  • Step 8: Alternative systemd Service
  • Step 9: Nginx Reverse Proxy and Let's Encrypt SSL
  • Step 10: Configure S3 or MinIO Upload Provider
  • Step 11: Create Content Types, API Tokens, and Roles
  • Step 12: Use the Transfer Tool for Staging to Production
  • Hardening and Performance Tuning
  • Troubleshooting
  • FAQ
  • Next Steps
  • 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

    ScenarioStrapi Cloud (Pro)Contentful (Team)Sanity (Growth)Self-Hosted Strapi (VPS)
    Monthly cost$99/mo + overages$489/mo$99/mo + overagesEUR 19.99/mo (flat)
    Included editors102020Unlimited
    Included entries / documents50,00050,00025,000Unlimited
    API calls / month1M2M1MUnlimited
    Assets storage500 GB500 GB50 GB100 GB NVMe + S3/MinIO
    Custom pluginsMarketplace onlyLimitedStudio onlyFull code access
    Database accessNoNoNoYes (PostgreSQL)
    Data residency controlLimitedPaid add-onLimitedFull (pick VPS region)
    For any project with more than a single editor or more than occasional API traffic, self-hosting pays for itself in the first month and the savings compound.

    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:

    • How to Install Node.js on Ubuntu
    • How to Install PostgreSQL on Ubuntu
    • How to Install Nginx on Ubuntu
    Connect to your server to begin:

    bash
    ssh root@your-server-ip

    Step 1: Prepare the Ubuntu 24.04 Server

    Update the package index and upgrade installed packages so dependency resolution works cleanly:

    bash
    sudo apt update && sudo apt upgrade -y

    Install the base tooling Strapi and its native dependencies need:

    bash
    sudo apt install -y build-essential git curl ca-certificates gnupg ufw

    Create 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.

    bash
    sudo adduser --disabled-password --gecos "" strapi
    sudo usermod -aG sudo strapi

    Configure the firewall to allow SSH, HTTP, and HTTPS:

    bash
    sudo ufw allow OpenSSH
    sudo ufw allow 'Nginx Full'
    sudo ufw --force enable
    sudo ufw status

    Switch to the strapi user for the rest of the install:

    bash
    sudo -iu strapi

    Step 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:

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

    Verify both Node and npm are on the expected major versions:

    bash
    node --version
    npm --version

    Expected output:

    text
    v20.19.0
    10.8.2

    Install pnpm (faster installs and the package manager Strapi recommends) and pm2 (process manager) globally:

    bash
    sudo npm install -g pnpm pm2

    Verify:

    bash
    pnpm --version && pm2 --version

    If 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:

    bash
    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.asc

    sudo 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:

    bash
    sudo systemctl status postgresql

    Create 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).

    bash
    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;
    SQL

    Test the credentials from the strapi OS user:

    bash
    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:

    text
    host    all    all    127.0.0.1/32    scram-sha-256

    Reload PostgreSQL after any change:

    bash
    sudo systemctl reload postgresql

    For 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.

    bash
    cd ~
    npx create-strapi-app@latest strapi-app --quickstart=false

    The installer asks a series of questions. Use these answers for production:

    text
    ? 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?         Yes

    Once the install finishes, enter the project:

    bash
    cd ~/strapi-app
    Note: If you prefer pnpm, re-run the install with pnpm 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.

    bash
    nano ~/strapi-app/.env

    A production .env should look like this:

    env
    # Host binding
    HOST=127.0.0.1
    PORT=1337

    Public URL used for admin redirects, API token scopes, and webhooks

    URL=https://cms.yourdomain.com

    App 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-string

    Admin panel JWT secret

    ADMIN_JWT_SECRET=another-long-random-base64-string

    Salt used to hash API tokens

    API_TOKEN_SALT=yet-another-long-random-base64-string

    Salt used to hash transfer tokens (for the strapi transfer command)

    TRANSFER_TOKEN_SALT=one-more-long-random-base64-string

    Database

    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=public

    Node environment

    NODE_ENV=production

    Generate every secret with a one-liner so you are never tempted to reuse defaults:

    bash
    # 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:

    bash
    chmod 600 ~/strapi-app/.env

    What 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 the users-permissions plugin. Rotate this and every public user must log in again.
    • ADMIN_JWT_SECRET -- Signs JWTs for admin panel sessions. Completely independent from JWT_SECRET so 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 by strapi transfer to push or pull data between environments.
    • DATABASE_SSL=false -- Fine when Strapi and PostgreSQL are on the same host (loopback). Set to true and provide DATABASE_SSL_CA if 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:

    bash
    NODE_ENV=production npm run build

    Expected output (abbreviated):

    text
    > [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:

    bash
    NODE_ENV=production npm run start

    You should see:

    text
    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:

    bash
    nano ~/strapi-app/ecosystem.config.js

    Paste:

    javascript
    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 to instances: 1 or add Redis as a shared cache layer. On a 6 vCPU VPS, instances: 4 is a comfortable sweet spot that still leaves cores for PostgreSQL.

    Start the app:

    bash
    cd ~/strapi-app
    pm2 start ecosystem.config.js
    pm2 save

    Persist PM2 across reboots by generating a systemd unit:

    bash
    pm2 startup systemd -u strapi --hp /home/strapi

    PM2 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:

    bash
    pm2 status

    Expected output:

    text
    ┌────┬────────┬─────────┬────────┬────────┬────────┬──────┬──────────┐
    │ 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:

    bash
    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 files

    Step 8: Alternative systemd Service

    If you prefer to skip PM2 and use systemd directly, create a unit at /etc/systemd/system/strapi.service:

    bash
    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:

    bash
    sudo touch /var/log/strapi.log /var/log/strapi.err.log
    sudo chown strapi:strapi /var/log/strapi.log /var/log/strapi.err.log

    Enable and start:

    bash
    sudo systemctl daemon-reload
    sudo systemctl enable --now strapi
    sudo systemctl status strapi

    systemd 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:

    bash
    sudo apt install -y nginx certbot python3-certbot-nginx

    Create the site config:

    bash
    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:

    bash
    sudo ln -s /etc/nginx/sites-available/strapi /etc/nginx/sites-enabled/
    sudo rm -f /etc/nginx/sites-enabled/default
    sudo nginx -t

    Obtain the certificate. Make sure your DNS A record for cms.yourdomain.com resolves to the server first.

    bash
    sudo certbot --nginx -d cms.yourdomain.com

    Certbot edits the config in place to point at /etc/letsencrypt/live/... and sets up an automatic renewal timer. Verify:

    bash
    sudo systemctl status certbot.timer

    Reload Nginx:

    bash
    sudo systemctl reload nginx

    Now 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:

    env
    URL=https://cms.yourdomain.com

    Also trust the reverse proxy in config/server.ts:

    typescript
    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:

    bash
    cd ~/strapi-app
    npm run build
    pm2 reload strapi

    Step 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:

    bash
    cd ~/strapi-app
    npm install @strapi/provider-upload-aws-s3

    Create or edit config/plugins.ts:

    typescript
    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:

    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=true

    Allow the bucket host in Strapi's security middleware so the admin preview loads images. Edit config/middlewares.ts:

    typescript
    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:

    bash
    npm run build
    pm2 reload strapi

    Upload 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

  • Go to Content-Type Builder in the sidebar
  • Click Create new collection type
  • Name it (for example Article) -- Strapi generates the API ID automatically (api::article.article)
  • Add fields: title (Text, Short), slug (UID, attached to title), body (Rich Text -- Blocks), cover (Media, single), publishedAt (auto-managed by draft-and-publish)
  • Click Save -- Strapi generates the schema, migrations, routes, controllers, and services, then restarts
  • The new API is immediately live at:

    text
    GET    /api/articles
    GET    /api/articles/:documentId
    POST   /api/articles
    PUT    /api/articles/:documentId
    DELETE /api/articles/:documentId

    Strapi 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:

  • Go to Settings -> Users & Permissions Plugin -> Roles -> Public
  • Expand Article under Permissions
  • Check find and findOne
  • Click Save
  • For 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.

  • Settings -> API Tokens -> Create new API Token
  • Name: nextjs-production
  • Token type: Read-only (or Custom for fine-grained per-endpoint permissions)
  • Token duration: Unlimited or 7/30/90 days if you rotate
  • Click Save
  • Strapi shows the token once -- copy it immediately. Use it from your frontend with:

    bash
    curl -H "Authorization: Bearer YOUR_TOKEN" \
      https://cms.yourdomain.com/api/articles

    Create 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:

  • Settings -> Transfer Tokens -> Create new Transfer Token
  • Name: staging-to-prod
  • Token type: Push (if pushing from staging to prod) or Pull (if pulling to staging from prod)
  • Duration: 7 days (rotate often)
  • Save and copy the token
  • Push from Staging to Production

    On the staging server, inside ~/strapi-app:

    bash
    npm run strapi transfer -- \
      --to https://cms.yourdomain.com/admin \
      --to-token YOUR_TRANSFER_TOKEN

    Strapi 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:

    bash
    npm run strapi transfer -- \
      --from https://cms.yourdomain.com/admin \
      --from-token YOUR_TRANSFER_TOKEN

    Export to a File

    Transfer also supports file-based backups:

    bash
    # Export to a .tar.gz.enc file (encrypted with a passphrase)
    npm run strapi export -- --file backup-2026-04-16

    Import from a file

    npm run strapi import -- --file backup-2026-04-16.tar.gz.enc

    Schedule 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:

    typescript
    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:

    bash
    npm install strapi-plugin-rest-cache strapi-provider-rest-cache-redis
    sudo apt install -y redis-server

    Configure 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:

    bash
    sudo -u postgres crontab -e
    text
    0 2   * pg_dump -Fc strapi_prod > /var/backups/strapi/strapi-$(date +\%Y\%m\%d).dump && find /var/backups/strapi -mtime +14 -delete

    For 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):

    bash
    pm2 install pm2-logrotate
    pm2 set pm2-logrotate:max_size 100M
    pm2 set pm2-logrotate:retain 14

    Fail2ban 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

    ProblemCauseSolution
    Error: Missing APP_KEYS on startup.env not loaded or empty keysConfirm ~/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 mismatchRe-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 changecd ~/strapi-app && npm run build && pm2 reload strapi
    413 Request Entity Too Large when uploadingNginx default client_max_body_size 1mRaise to 200m (or higher) in the Nginx server block and sudo systemctl reload nginx
    Images from S3 blocked in admin previewCSP blocking external originAdd the S3/MinIO host to img-src in config/middlewares.ts (see Step 10)
    Cluster workers fighting over bootstrap migrationsMultiple workers running migrations concurrently on first bootRun 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 NginxStrapi not running or bound to wrong hostpm2 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 projectcd ~/strapi-app && npm install @strapi/provider-upload-aws-s3 && npm run build && pm2 reload strapi
    Transfer tool errors with Invalid signatureTransfer token expired or regeneratedRegenerate 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_REDIRECTSURL=http://... while Nginx enforces HTTPSSet URL=https://cms.yourdomain.com in .env, set proxy: true in config/server.ts, rebuild, reload

    Viewing Logs

    bash
    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.log

    FAQ

    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/client npm 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 with strapi export backups 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/init and /api/articles?pagination[pageSize]=1 for 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.

    Was this article helpful?

    ← Back to Install GuidesBrowse all categories →

    Still have questions?

    Contact Support →Submit a Ticket