Analytics cookies only (Google Analytics, PostHog). No ad networks, no cross-site tracking. See our privacy policy.
Updated Oct 2026
verified on Ubuntu 26.04 · Oct 2026Self-host Strapi, the open-source headless CMS, on a VPS with a Docker production build, PostgreSQL, Caddy HTTPS, the proxy settings that keep admin cookies secure, and tested backups.
Strapi is a headless CMS built on Node.js. You design content types in an admin panel, and Strapi generates a REST API, role-based permissions and a media library for them, with GraphQL available as a plugin. Your website or app reads the content over the API. The core is licensed MIT, with some enterprise features under a separate proprietary license; the Community Edition this guide runs does not need them.
Strapi differs from most apps on this site in one way that shapes the whole
install: it does not publish a ready-made Docker image. Strapi is a project.
You scaffold it with create-strapi, which generates a Node.js application, and
you build and run that application yourself. So this guide scaffolds a project
pinned to a release, builds it with the production Dockerfile from Strapi's own
Docker documentation, and runs it next to PostgreSQL with Caddy in front. The
project folder is your deployable: you keep it, back it up and put your content
types in it.
If you are weighing it against a similar tool, compare it with Directus or Payload.
Our listing for Strapi puts the floor at 2 GB of RAM, and the reason is the build, not the running server. When we ran this exact stack on a 2 vCPU, 8 GB test box and let it settle, the whole stack used about 190 MB at idle: the Node.js process was the biggest part, with PostgreSQL and Caddy small. Installing roughly a thousand npm packages and compiling the admin panel is where memory and CPU spike. We built on the 8 GB box and did not test a build on 2 GB, so if you want the smallest plan, build on a bigger machine or add swap for the build and run on the small one.
Disk matters more than RAM over time: the Docker build leaves several GB of
images and layers behind (our test box measured roughly 17 GB of disk in use after the
install), and uploaded media grows in a volume. Pick a plan with room, and run
sudo docker image prune after upgrades.
Update the system and install the few tools this guide uses:
sudo apt-get update && sudo apt-get -y upgrade
sudo apt-get install -y curl openssl ufw dnsutils
If you are logged in as root, create a non-root sudo user and continue as that user:
adduser deploy
usermod -aG sudo deploy
Then open only SSH and the two ports the reverse proxy needs:
sudo ufw allow OpenSSH
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw --force enable
On a Docker host a published container port goes around ufw, so the Compose
file below publishes Strapi only on 127.0.0.1, and Caddy is the one public
door.
Paid link — we earn a commission if you shop through it.
If Docker is not on the server yet, the convenience script installs Docker Engine and the Compose plugin (the Docker and Compose on Ubuntu guide covers the details):
command -v docker >/dev/null || curl -fsSL https://get.docker.com | sudo sh
sudo docker compose version
create-strapi is run inside a throwaway Node container, so nothing needs to be
installed on the server itself. The version is pinned, the database is
PostgreSQL, and the password is generated here and never typed. The host name
strapiDB is the name of the database service in the Compose file below. This
step installs the project's dependencies and takes a few minutes:
mkdir -p ~/strapi && cd ~/strapi
DBPASS=$(openssl rand -hex 16)
sudo docker run --rm -v "$PWD":/work -w /work -u "$(id -u):$(id -g)" -e HOME=/tmp \
node:22-alpine npx --yes create-strapi@5.58.0 strapi-app \
--non-interactive --skip-cloud --no-run --no-git-init --use-npm --no-example \
--dbclient postgres --dbhost strapiDB --dbport 5432 --dbname strapi \
--dbusername strapi --dbpassword "$DBPASS" > scaffold.log 2>&1
tail -n 3 scaffold.log
ls strapi-app
The scaffold writes a .env with the database settings and freshly generated
secrets (APP_KEYS, ADMIN_JWT_SECRET, JWT_SECRET, ENCRYPTION_KEY and
others). Treat that file like a root password and back it up: lose
ENCRYPTION_KEY or the JWT secrets and stored tokens stop working.
Caddy will terminate HTTPS and forward plain HTTP to Strapi, and by default Strapi does not know that. Set the public address and tell it to trust the proxy's headers, before you build (changes to the server config need a rebuild of the admin panel):
cd ~/strapi/strapi-app
DOMAIN=strapi.example.com
printf 'DOMAIN=%s\nPUBLIC_URL=https://%s\n' "$DOMAIN" "$DOMAIN" >> .env
sed -i "s| port: env.int('PORT', 1337),|&\n url: env('PUBLIC_URL'),\n proxy: { koa: true, maxIpsCount: 1 },|" config/server.ts
sed -n 1,10p config/server.ts
grep -c "proxy: { koa: true" config/server.ts
The last line should print 1. url is the address Strapi puts in links, and
proxy.koa makes it trust the forwarded protocol and client address.
maxIpsCount: 1 limits it to the one proxy you actually run, so a visitor cannot
forge their IP with an X-Forwarded-For header. Skip this and the install
still appears to work, which is the trap: see the cookie check below.
Now the production Dockerfile. It is a two-stage build adapted from Strapi's
Docker documentation: the first stage installs everything and builds the admin
panel, the second keeps only production dependencies and runs as the unprivileged
node user. The healthcheck uses 127.0.0.1, not localhost, because the
Alpine image resolves localhost to the IPv6 address first and Strapi listens on
IPv4 only:
cd ~/strapi/strapi-app
printf "node_modules\n.tmp\n.cache\n.git\nbuild\n.env\n" > .dockerignore
cat > Dockerfile.prod <<'EOF'
FROM node:22-alpine AS build
RUN apk update && apk add --no-cache build-base gcc autoconf automake zlib-dev libpng-dev bash vips-dev git > /dev/null 2>&1
WORKDIR /opt/
COPY package.json package-lock.json ./
RUN npm install -g node-gyp
RUN npm config set fetch-retry-maxtimeout 600000 -g && npm ci
ENV PATH=/opt/node_modules/.bin:$PATH
WORKDIR /opt/app
COPY . .
ENV NODE_ENV=production
RUN npm run build
FROM node:22-alpine
RUN apk add --no-cache vips-dev
ENV NODE_ENV=production
WORKDIR /opt/
COPY --from=build /opt/package.json /opt/package-lock.json ./
RUN npm ci --omit=dev && npm cache clean --force
ENV PATH=/opt/node_modules/.bin:$PATH
WORKDIR /opt/app
COPY --from=build /opt/app ./
RUN chown -R node:node /opt/app
USER node
EXPOSE 1337
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
CMD wget --quiet --tries=1 --spider http://127.0.0.1:1337/_health || exit 1
CMD ["npm", "run", "start"]
EOF
Next the Compose file for Strapi and PostgreSQL, which reads its settings from
the .env the scaffold wrote, and a small override that adds Caddy. Compose
loads docker-compose.override.yml automatically:
cd ~/strapi/strapi-app
cat > docker-compose.yml <<'EOF'
services:
strapi:
build:
context: .
dockerfile: Dockerfile.prod
restart: always
env_file: .env
environment:
NODE_ENV: production
ports:
- "127.0.0.1:1337:1337"
volumes:
- strapi-uploads:/opt/app/public/uploads
depends_on:
strapiDB:
condition: service_healthy
strapiDB:
image: postgres:16-alpine
restart: always
environment:
POSTGRES_USER: ${DATABASE_USERNAME}
POSTGRES_PASSWORD: ${DATABASE_PASSWORD}
POSTGRES_DB: ${DATABASE_NAME}
volumes:
- strapi-data:/var/lib/postgresql/data/
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DATABASE_USERNAME} -d ${DATABASE_NAME}"]
interval: 10s
timeout: 5s
retries: 5
volumes:
strapi-data:
strapi-uploads:
EOF
cat > docker-compose.override.yml <<'EOF'
services:
caddy:
image: caddy:2
restart: always
ports: ["80:80", "443:443"]
environment:
DOMAIN: ${DOMAIN}
volumes: ["./Caddyfile:/etc/caddy/Caddyfile:ro", "caddy_data:/data"]
depends_on: [strapi]
volumes:
caddy_data:
EOF
cat > Caddyfile <<'EOF'
{$DOMAIN} {
encode zstd gzip
reverse_proxy strapi:1337
}
EOF
sudo docker compose config --quiet && echo "compose config valid"
Build and start. The first build downloads the dependencies and compiles the admin panel, so it takes several minutes; later builds reuse cached layers. The loop is bounded so a broken build fails instead of hanging:
cd ~/strapi/strapi-app
sudo docker compose up -d --build
for i in $(seq 1 60); do
sudo docker compose ps strapi | grep -q '(healthy)' && break
sleep 5
done
sudo docker compose ps --format 'table {{.Service}}\t{{.Status}}'
Create an A record for your hostname (say strapi.example.com) pointing at
the server's public IP, and confirm it resolves before Caddy tries to get a
certificate:
dig +short strapi.example.com
Caddy is already running. It requests the certificate as soon as the name resolves and renews it on its own (see Automatic HTTPS with Caddy for how that works). Check that HTTPS answers, retrying while the certificate is issued:
cd ~/strapi/strapi-app
DOMAIN=$(grep '^DOMAIN=' .env | cut -d= -f2)
out=000
for i in $(seq 1 24); do
out=$(curl -s -o /dev/null -w '%{http_code} %{ssl_verify_result}' "https://$DOMAIN/_health") && [ "${out%% *}" = "204" ] && break
sleep 5
done
echo "health endpoint, status and certificate check: $out"
[ "${out%% *}" = "204" ] || false
A 204 is the health endpoint's normal answer, and a second number of 0 means
the certificate verified.
On a fresh install the first visit to /admin offers a registration form, and
whoever reaches it first becomes the super administrator. Confirm that the
instance is waiting for one:
cd ~/strapi/strapi-app
DOMAIN=$(grep '^DOMAIN=' .env | cut -d= -f2)
curl -s "https://$DOMAIN/admin/init"; echo
"hasAdmin":false means nobody has claimed it. Rather than race a stranger in
the browser, create the administrator from the command line with a generated
password. Strapi requires an uppercase letter, so the generated value gets a
fixed prefix (a lowercase hex string alone is refused with Password must contain at least one uppercase character). Use your own email address:
cd ~/strapi/strapi-app
( umask 077; echo "Aa1-$(openssl rand -hex 12)" > ~/strapi/admin-password )
sudo docker compose exec -T strapi npx strapi admin:create-user \
--firstname=Admin --lastname=User --email=admin@example.com \
--password="$(cat ~/strapi/admin-password)"
Sign in at https://strapi.example.com/admin with that email and the password in
~/strapi/admin-password, then change it.
This is the check that catches a missing proxy setting. Sign in through the API and look at the cookie Strapi issues for the admin session:
cd ~/strapi/strapi-app
DOMAIN=$(grep '^DOMAIN=' .env | cut -d= -f2)
curl -s -i -X POST "https://$DOMAIN/admin/login" -H 'Content-Type: application/json' \
-d "{\"email\":\"admin@example.com\",\"password\":\"$(cat ~/strapi/admin-password)\"}" \
| grep -i '^set-cookie: strapi_admin_refresh=' | grep -ci 'secure'
It should print 1: the refresh cookie carries the Secure attribute. We hit
the opposite on our first run. With the stock configuration, no url and no
proxy trust, sign-in worked, every page loaded, and the cookie came back with
httponly and samesite but without Secure, because Strapi believed the
request was plain HTTP. Nothing errors, so you would only find out from an
audit. Adding url and proxy.koa and rebuilding fixed it.
Open the Content-Type Builder in the admin panel on this server and you will find
it cannot save. In production mode Strapi disables schema changes; the API answers
Content-Type Builder modifications are disabled in production mode, which we
confirmed with a 403 on our own instance. This is by design: content types are
files in the project (src/api/...), so they live in version control and reach
the server through a build.
The practical workflow is to design content types on your laptop with
npm run develop, commit the generated src/api files, and deploy them. You can
also write the files by hand. This adds a minimal article type, and then you
rebuild:
cd ~/strapi/strapi-app
A=src/api/article
mkdir -p $A/content-types/article $A/routes $A/controllers $A/services
cat > $A/content-types/article/schema.json <<'EOF'
{
"kind": "collectionType",
"collectionName": "articles",
"info": { "singularName": "article", "pluralName": "articles", "displayName": "Article" },
"options": { "draftAndPublish": false },
"attributes": {
"title": { "type": "string", "required": true },
"body": { "type": "text" }
}
}
EOF
cat > $A/routes/article.ts <<'EOF'
import { factories } from '@strapi/strapi';
export default factories.createCoreRouter('api::article.article');
EOF
cat > $A/controllers/article.ts <<'EOF'
import { factories } from '@strapi/strapi';
export default factories.createCoreController('api::article.article');
EOF
cat > $A/services/article.ts <<'EOF'
import { factories } from '@strapi/strapi';
export default factories.createCoreService('api::article.article');
EOF
sudo docker compose up -d --build
for i in $(seq 1 60); do
sudo docker compose ps strapi | grep -q '(healthy)' && break
sleep 5
done
sudo docker compose ps strapi --format '{{.Service}} {{.Status}}'
Now exercise the API the way your website would. The content API refuses anonymous requests until you grant a role permission or create an API token. This creates a full-access token (use a read-only one for a public site), writes an article, reads it back and uploads a file:
cd ~/strapi/strapi-app
DOMAIN=$(grep '^DOMAIN=' .env | cut -d= -f2)
PW=$(cat ~/strapi/admin-password)
JWT=$(curl -s -X POST "https://$DOMAIN/admin/login" -H 'Content-Type: application/json' \
-d "{\"email\":\"admin@example.com\",\"password\":\"$PW\"}" | sed -n 's/.*"token":"\([^"]*\)".*/\1/p')
KEY=$(curl -s -X POST "https://$DOMAIN/admin/api-tokens" -H "Authorization: Bearer $JWT" \
-H 'Content-Type: application/json' \
-d '{"name":"guide-test","description":"","type":"full-access","lifespan":null}' \
| sed -n 's/.*"accessKey":"\([^"]*\)".*/\1/p')
echo "anonymous read: HTTP $(curl -s -o /dev/null -w '%{http_code}' "https://$DOMAIN/api/articles")"
curl -s -X POST "https://$DOMAIN/api/articles" -H "Authorization: Bearer $KEY" \
-H 'Content-Type: application/json' -d '{"data":{"title":"Hello","body":"first article"}}' > /dev/null
curl -s "https://$DOMAIN/api/articles" -H "Authorization: Bearer $KEY" | grep -o '"total":[0-9]*'
echo "hello upload" > ~/strapi/hello.txt
curl -s -X POST "https://$DOMAIN/api/upload" -H "Authorization: Bearer $KEY" -F "files=@$HOME/strapi/hello.txt" | grep -o '"url":"[^"]*"'
Expect anonymous read: HTTP 403, then "total":1, then the uploaded file's
/uploads/... URL. Anonymous access being refused is the safe default: you
decide, per content type, what the public may read.
State lives in four places: the PostgreSQL database (your content), the
strapi-uploads volume (media), the project folder (your content-type files and
config) and the .env with the secrets. Dump the database in PostgreSQL's custom
format, and archive the uploads and the project. These archives hold your
content and secrets, so they are created readable by their owner only:
cd ~/strapi/strapi-app
( umask 077
sudo docker compose exec -T strapiDB sh -c 'pg_dump -U "$POSTGRES_USER" -Fc "$POSTGRES_DB"' > ~/strapi-db-$(date +%F).dump
sudo docker compose exec -T strapi tar czf - -C /opt/app/public/uploads . > ~/strapi-uploads-$(date +%F).tar.gz
tar czf ~/strapi-project-$(date +%F).tar.gz --exclude=node_modules --exclude=.cache --exclude=.tmp --exclude=build -C ~/strapi strapi-app )
ls -lh ~/strapi-db-*.dump ~/strapi-uploads-*.tar.gz ~/strapi-project-*.tar.gz
Copy these off the server and run them from cron. A backup you have never restored is a guess, so prove the dump loads. This restores it into a throwaway PostgreSQL container and compares the number of articles with the live database:
cd ~/strapi/strapi-app
sudo docker rm -f strapi-restore-test >/dev/null 2>&1
sudo docker run -d --name strapi-restore-test -e POSTGRES_PASSWORD=restore-test -e POSTGRES_DB=strapi postgres:16-alpine >/dev/null
for i in $(seq 1 40); do
sudo docker exec strapi-restore-test pg_isready -U postgres -d strapi >/dev/null 2>&1 && break
sleep 2
done
sleep 3
sudo docker exec -i strapi-restore-test pg_restore -U postgres -d strapi --no-owner < ~/strapi-db-$(date +%F).dump
LIVE=$(sudo docker compose exec -T strapiDB sh -c 'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -tAc "select count(*) from articles"')
RESTORED=$(sudo docker exec strapi-restore-test psql -U postgres -d strapi -tAc "select count(*) from articles")
echo "articles live: $LIVE, restored: $RESTORED"
sudo docker rm -f strapi-restore-test >/dev/null
[ "$LIVE" = "$RESTORED" ] && [ "$LIVE" -ge 1 ]
A Strapi upgrade changes the project's dependencies, then rebuilds the image.
Strapi ships a tool for the first half. Back up first (the previous section), then
run it against the project folder in the same kind of throwaway container. We ran
it with --dry, which changes nothing, and it reported our project as already up
to date; we did not perform a real version bump. Note that it exits with an error
when there is nothing to upgrade:
cd ~/strapi/strapi-app
sudo docker run --rm -v "$PWD":/app -w /app -u "$(id -u):$(id -g)" -e HOME=/tmp \
node:22-alpine npx --yes @strapi/upgrade@latest minor
Use minor for the newest minor and patch release of your current major version,
patch for fixes only, and read the
Strapi release notes before a major
version. Then rebuild and restart with sudo docker compose up -d --build, check
/_health and the admin panel, and prune old images.
The build fails or the server freezes during npm ci. The build is the
memory-hungry step. Check free -m and the kernel log for the out-of-memory
killer, and build on a larger machine or add swap.
Port 80 is already in use when Caddy starts. Another web server or container
holds it. Find it with sudo ss -tlnp | grep ':80 ' and stop it, or remove it,
before running sudo docker compose up -d.
The healthcheck keeps failing though the logs say Strapi started. The check
must call 127.0.0.1. With localhost, Alpine resolves to the IPv6 address first
and Strapi is listening on IPv4 only, so the check never connects.
Admin sign-in loops or logs you out behind the proxy. Confirm PUBLIC_URL
is the exact https address you browse to and that proxy.koa is set, then
rebuild: changes to the server config only take effect after the admin panel is
rebuilt.
Caddy never gets a certificate. Confirm dig +short strapi.example.com
returns the server's IP and that your provider's network firewall (not just
ufw) allows inbound 80 and 443.
You are done when you can: load https://strapi.example.com/admin with a valid
certificate, sign in as the administrator, see the Secure flag on the admin
cookie, create an entry through the API with a token, and restore a database dump
into a throwaway container as above.
From here, design your real content types on a laptop and ship them through the build, create read-only API tokens for your front end, and set up an upload provider if you outgrow local disk. For ranked server options, see Best VPS for self-hosting. We confirmed the install, the HTTPS certificate, the administrator login, the API and upload round trip and the backup restore in our own run. We did not test GraphQL, the email plugin or the enterprise features.