Analytics cookies only (Google Analytics, PostHog). No ad networks, no cross-site tracking. See our privacy policy.
A headless CMS built on Node.js. You design content types in an admin Content-Type Builder, and Strapi generates a REST API, role-based permissions and a media library for them, with GraphQL available as a plugin. It runs as a project you scaffold and deploy yourself.
Runs on a VPS from $12/mo (Kamatera 4GB, 4 GB RAM) · prices checked Sep 2026
Pick Strapi when you want a headless CMS that editors can use from day one and developers extend in JavaScript or TypeScript: content types designed in the admin's Content-type Builder, a REST API generated for each one (GraphQL is an optional plugin), role-based permissions, a media library and an admin panel in the box. It is a Node.js project you scaffold with `create-strapi`, own and deploy yourself, with PostgreSQL, MySQL or SQLite as the database. The Community Edition is MIT; code under `ee/` directories is not. If you would rather point an admin and API at a SQL database you already have, Directus is the closer fit; Payload is the code-first alternative that lives inside a Next.js app.
Follow the Strapi deploy guide →Paid link — we earn a commission if you shop through it.
Run these commands on your server:
# Strapi publishes no official image: scaffold a project (pinned to 5.58.0, Postgres), build it with the production Dockerfile from Strapi's Docker docs, run it behind Caddy for automatic HTTPS
# needs a domain pointed at this server and ports 80/443 open; Strapi and Postgres publish nothing publicly
mkdir -p ~/strapi && cd ~/strapi
DOMAIN=strapi.example.com
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"
cd strapi-app
# behind a proxy Strapi must know its public URL and trust one proxy hop, or the admin cookies are issued without Secure
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
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
# 127.0.0.1, not localhost: Alpine resolves localhost to ::1 first, and Strapi listens on IPv4 only
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
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 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 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 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 up -d --build
for i in $(seq 1 60); do sudo docker compose ps strapi | grep -q '(healthy)' && break; sleep 5; done
# the first visitor to /admin would become super admin: create yours now (use your own email)
( 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://DOMAIN/admin (password in ~/strapi/admin-password); content types are read-only in production: design them locally and ship them through the buildStrapi's licence and its own Docker docs set out what to settle before it holds production content:
A CMS where you build the content model vs. a data platform that sits on top of your SQL database.
A standalone Node.js CMS with a click-to-model builder vs. a code-first CMS that lives inside your Next.js app.