Strapi Cloud or self-hosting: what you lose, what you gain
Strapi Cloud is convenient to get started, but it comes with constraints that quickly become blocking in real production. The free tier limits content types and admin users; paid tiers bill on the number of API requests and uploads. Above all, your data lives on Strapi Inc.'s infrastructure — which is incompatible with the data localisation requirements many clients have (strict GDPR, sector-specific contracts, sensitive data). By self-hosting on a VPS, you regain full ownership of the PostgreSQL database, the choice of storage (local disk or S3-compatible bucket), the freedom to install any community plugin, and content volumes that no longer depend on a pricing grid. The trade-off is operational — you handle upgrades, backups and monitoring. This guide shows you exactly how.
Benefits of self-hosting Strapi on a VPS
- Data ownership — your PostgreSQL database and your uploads stay on your VPS, under your exclusive control
- No plan quotas — the number of entries, content types and admin accounts is no longer set by a subscription, but by the resources of your VPS
- Community plugins — any npm plugin installs freely, with no platform review or allowlist
- API on your own domain — REST and GraphQL exposed on
cms.your-domain.com, with no intermediary and no imposed rate limit - Unified stack — Strapi and a Nuxt or Next.js frontend live on the same VPS, behind a single reverse proxy
- Data localisation — essential for GDPR compliance and for your clients' contractual requirements
- Predictable cost — the VPS has a fixed monthly rate, independent of content volume or API traffic
- Migration control — you decide when and how to apply Strapi upgrades
Prerequisites: hardware, software and domain
Strapi is hungrier than most frameworks because the React admin build alone consumes more than 2 GB of RAM. A VPS with 1 GB of RAM will systematically die of OOM during the build phase — do not undersize it. Minimum targets: 2 vCPU, 4 GB of RAM. In steady production, once the build is done, the application runs comfortably on 512 MB to 1 GB. On the software side: Docker 24+ and Docker Compose v2 (docker compose, not docker-compose), Git to pull your code onto the server, and Certbot for TLS. On the database side: PostgreSQL 16 is recommended in production — SQLite works in development but is too limited under concurrent load and incompatible with some plugins. On the network side: point cms.your-domain.com at your VPS IP before you start, and plan for at least 20 GB of disk (node_modules, uploads, backup dumps).
Prepare the project before touching the server
Generate the Strapi project
On your workstation, generate the project with the official command npx create-strapi-app@latest my-cms --dbclient=postgres. Choose TypeScript if your team is comfortable with it. Initialise the Git repository, push it to your forge, then clone it on the VPS into /srv/my-cms. Everything that follows is prepared in that repository and not on the server: the VPS should only ever receive code that is already versioned.
Wire the database configuration to PostgreSQL
In config/database.js — or config/database.ts in TypeScript — the exported function receives env and returns a connection object. Declare client: 'postgres', then six keys read from the environment inside the connection sub-object: host: env('DATABASE_HOST', '127.0.0.1'), port: env.int('DATABASE_PORT', 5432), database: env('DATABASE_NAME', 'strapi'), user: env('DATABASE_USERNAME', 'strapi'), password: env('DATABASE_PASSWORD', '') and ssl: env.bool('DATABASE_SSL', false). The second argument of env() is only a development fallback: no real value belongs in this file, it goes into Git.
Generate the five production secrets
Strapi refuses to start in production without APP_KEYS, API_TOKEN_SALT, ADMIN_JWT_SECRET, JWT_SECRET and TRANSFER_TOKEN_SALT. Generate each one with openssl rand -base64 32; APP_KEYS expects several, separated by commas, so produce at least two. Write them into /srv/my-cms/.env on the VPS — never into Git — together with DATABASE_HOST, DATABASE_NAME, DATABASE_USERNAME, DATABASE_PASSWORD, NODE_ENV=production and URL=https://cms.your-domain.com. A missing key means a refusal to start, not a warning.
Write the multi-stage Dockerfile
Two stages are enough to keep the production image light. The builder stage starts from node:20-alpine, sets WORKDIR /app, copies package*.json, runs npm ci, copies the rest of the code, then compiles the admin with NODE_ENV=production npm run build. The runner stage starts again from the same base image and copies only three things from builder: the build folder, node_modules and package.json. Finish with EXPOSE 1337 and a CMD that calls npm run start — that is the start script Strapi installs, do not try to launch a server file by hand. If the build dies of OOM, set NODE_OPTIONS=--max-old-space-size=4096 before npm run build.
Deploy the stack on the VPS
Describe the services in docker-compose.yml
The file declares two services and one named volume. The postgres service uses the postgres:16-alpine image, receives POSTGRES_DB, POSTGRES_USER and POSTGRES_PASSWORD in its environment block, mounts the pgdata volume on /var/lib/postgresql/data and runs with restart: unless-stopped. The strapi service builds from the local Dockerfile with build: ., reads its variables through env_file: .env, declares depends_on: postgres, mounts ./public/uploads on /app/public/uploads and publishes its port with ports: 127.0.0.1:1337:1337. That binding on the loopback interface is the important point: Nginx becomes the only public entry point, and port 1337 is never exposed to the outside. A redis service remains optional, for session caching or processing queues.
Build the image and start the stack
Run docker compose build, then docker compose up -d. Follow the startup with docker compose logs -f strapi. Strapi applies its schema migrations on first start as soon as NODE_ENV=production is set. Expect two to five minutes: building the React admin is by far the slowest step. Wait for the Strapi started successfully line before going further.
Put Nginx in front as a reverse proxy
Create the vhost /etc/nginx/sites-available/cms.your-domain.com. It listens with listen 80 on server_name cms.your-domain.com, carries a client_max_body_size 50M — indispensable for media uploads — and a single location / block that does proxy_pass http://127.0.0.1:1337. Add the four headers Strapi expects behind a proxy: Host, X-Real-IP, X-Forwarded-For and X-Forwarded-Proto, each set by a proxy_set_header directive. Enable the vhost with a symbolic link into sites-enabled, then validate and reload with nginx -t && systemctl reload nginx.
Enable HTTPS and set the URL variable
Obtain the certificate with certbot --nginx -d cms.your-domain.com: Certbot rewrites the vhost to redirect HTTP to HTTPS. Then check that URL=https://cms.your-domain.com really is in the .env, with no trailing slash. That variable is what Strapi uses to build media links and admin redirects; without it, uploaded files come out with wrong addresses and the admin panel misbehaves behind the proxy. Restart the container after any change to the .env: variables are read at startup.
Check that the instance really answers
Open https://cms.your-domain.com/admin and create the first administrator account — Strapi asks for it on first access and will not ask again. Then check three things: docker compose ps shows both containers in the running state, the public API answers on /api, and uploading a test file from the media library succeeds. A 502 Bad Gateway at this stage almost always means a stopped strapi container, or one still building: read docker compose logs strapi before touching Nginx.
Moving media off the disk: the S3 provider
By default, Strapi stores uploaded files in public/uploads, on the VPS disk. The ./public/uploads mount in docker-compose lets them survive a container recreation, but they grow with the media library, end up in every backup and disappear with the server. The official provider solves all three problems: npm install @strapi/provider-upload-aws-s3. Then declare it in config/plugins.js, under the upload key and then config: provider: 'aws-s3' and a providerOptions object that reads four values from the environment — accessKeyId: env('AWS_ACCESS_KEY_ID'), secretAccessKey: env('AWS_ACCESS_SECRET'), region: env('AWS_REGION') and params: { Bucket: env('AWS_BUCKET') }. Any S3-compatible storage will do: Scaleway Object Storage, Wasabi, Cloudflare R2. Two pitfalls are worth knowing before you switch. Already uploaded media do not migrate on their own — switch the provider before going live, or copy the contents of public/uploads into the bucket by hand. And media URLs change domain: if your frontend caches or rewrites them, check that after the switch.
Automate the database backup
Create the destination folder
On the VPS, mkdir -p /srv/backups/strapi. Keep this folder outside the Git repository and outside any path mounted into a container: a dump should never end up in an image or in a release.
Find the real name of the PostgreSQL container
docker compose ps gives the exact name, of the form my-cms-postgres-1. It derives from the project folder name: do not copy it from a guide, read it on your own machine. A backup script aimed at a container that does not exist fails silently once it is in the cron.
Write the dump command
A single line is enough: docker exec my-cms-postgres-1 pg_dump -U strapi strapi | gzip > /srv/backups/strapi/strapi-$(date +%Y%m%d).sql.gz. The pg_dump runs inside the container, compression and writing happen on the host. Run it once by hand and check the size of the file produced: a dump of a few bytes signals an authentication error swallowed by the pipe.
Purge dumps that are too old
Add find /srv/backups/strapi -name '*.sql.gz' -mtime +7 -delete right after. Without that line, the disk fills up within a few weeks: it is the most mundane failure of a daily backup — the database goes down because the backup saturated the volume.
Schedule it, then move the dumps off the server
Put both commands in a /etc/cron.daily/strapi-backup script, with #!/bin/bash on the first line and a chmod +x to make it executable. Add an rsync to external storage, or a copy to the same S3 bucket as the media. A backup that stays on the machine it backs up protects nothing; test a full restore at least once before relying on it.
Upgrade Strapi without breaking the migrations
An upgrade is driven from the repository, not on the server: change the version in package.json, push, then, on the VPS, chain git pull and docker compose build && docker compose up -d. Strapi detects and applies schema migrations at startup as long as NODE_ENV=production is set; confirm it with docker compose logs strapi | grep -i migrat. Three precautions are worth the detour. Take a pg_dump right before the version bump, and not just the one from the previous night — a failed migration is repaired by a restore, never by a second attempt. Read the release notes of the plugins you have installed: a major Strapi bump breaks a community plugin more often than the core itself. Finally, if a migration fails, do not restart the stack in a loop: every restart replays the same migration on a database that is already half modified. Stop the containers, read the full log, restore the dump if needed, then fix.
Troubleshooting: the most frequent errors
OOM during the build (Killed or JavaScript heap out of memory): the React admin build exceeds the available memory. Two remedies — add NODE_OPTIONS=--max-old-space-size=4096 to the builder stage of the Dockerfile, or temporarily increase the VPS swap with fallocate -l 2G /swapfile, chmod 600 /swapfile, mkswap /swapfile then swapon /swapfile. If the VPS still gets stuck, build the image on a more powerful machine and push it to a registry.
Cannot find module @strapi/plugin-*: never share the node_modules folder between a local development environment and the production image through a Docker volume. The local node_modules is compiled for your operating system, not for the container's Alpine Linux. Remove any node_modules volume from the docker-compose and let the npm ci in the Dockerfile handle it.
URL mismatch in the admin, or media on relative paths: the URL variable in the .env must match the public HTTPS address of your Strapi exactly, with no trailing slash. Any divergence breaks the links of uploaded media and causes CORS errors in the admin panel.
413 Request Entity Too Large: the Nginx client_max_body_size directive is too low. Raise it to 50M at least, or to 100M if you upload videos, then reload Nginx.
password authentication failed for user at startup: the password in the .env changed after the pgdata volume was created. PostgreSQL only reads its password at volume initialisation; align the .env with the existing password, or start from a fresh volume after backing up the database.
Three non-negotiable rules for a Strapi instance in production: S3 for uploads (@strapi/provider-upload-aws-s3) — your media survive any container recreation and do not inflate the VPS disk; automated daily pg_dump copied off the server — a local-only backup disappears with the VPS; NODE_ENV=production without exception — development mode recompiles the admin on the fly, exposes the Content-Type Builder and disables cache optimisations, all of which are to be avoided in production.
Strapi and a Nuxt or Next.js frontend on the same VPS
It is perfectly possible to run Strapi and a frontend side by side on the same VPS, provided you have enough RAM — count on 8 GB for both, since the two builds can be triggered at the same time. The simplest architecture uses Nginx as a dispatcher: requests to cms.your-domain.com are proxied to port 1337 (Strapi), and those to your-domain.com to port 3000 (Nuxt or Next.js). Strapi exposes its REST API on /api — the frontend consumes it directly over the internal Docker network, without going back through Nginx, which reduces latency. If your frontend generates static pages (nuxt generate, or next build in static export), Nginx can serve the files from disk and proxy only the dynamic routes. This monolithic architecture is well suited to a medium-sized project — one server, one TLS certificate, one supervision point. If you would rather separate the frontend, our Nuxt and Node.js deployment guides apply the same method on a second VPS sized for the build.