Strapi 5 в Docker Compose

Рабочий docker-compose для Strapi 5 с PostgreSQL 16 и Nginx с HTTPS ниже, целиком, с Dockerfile и переменными окружения. Конфиг собран из официальной документации Strapi (образ node:22-alpine, healthcheck на /_health, порт только на 127.0.0.1) и доведён до того вида, в котором мы разворачиваем сайты на серверах клиентов. Актуальная версия на момент публикации Strapi 5.56. В конце список ошибок, на которые мы натыкались сами, и что с ними делать.

Что получится

Три контейнера в одной сети Docker:

КонтейнерЧто делаетКуда смотрит
strapiStrapi 5, админка и API, порт 1337Только внутрь сети и на 127.0.0.1 хоста
dbPostgreSQL 16, данные в томеТолько внутрь сети
nginxHTTPS, проксирует admin.example.ru на strapi:1337Порты 80 и 443 наружу

Админка и API живут на отдельном поддомене, например admin.example.ru. Сайт на Next.js на основном домене, его контейнер добавляется в тот же compose, об этом в конце.

1. Создаём проект

На локальной машине с Node.js 22 или 24 (Strapi 5 поддерживает только LTS-версии 22, 24 и 26):

bash
npx create-strapi@latest cms \
  --dbclient postgres \
  --dbhost 127.0.0.1 --dbport 5432 \
  --dbname strapi --dbusername strapi --dbpassword strapi \
  --skip-cloud --no-run --no-example --use-npm --ts

Флаги реальные, из документации CLI: --skip-cloud пропускает предложение завести проект в Strapi Cloud, --no-run не запускает сервер после установки, --no-example не добавляет демо-данные. Флаг --quickstart в Strapi 5 объявлен устаревшим, вместо него --non-interactive, но он создаёт проект на SQLite, поэтому нам не подходит.

Проверьте, что в config/database.ts клиент postgres и параметры читаются из переменных окружения. Так выглядит стандартный конфиг из документации, менять его не нужно:

ts
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', 'strapi'),
      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) },
  },
});

В config/server.ts добавьте публичный адрес и доверие к заголовкам прокси, иначе ссылки на медиа и письма сброса пароля будут собираться с http://localhost:1337:

ts
export default ({ env }) => ({
  host: env('HOST', '0.0.0.0'),
  port: env.int('PORT', 1337),
  url: env('PUBLIC_URL', 'http://localhost:1337'),
  proxy: { koa: env.bool('IS_PROXIED', false) },
  app: { keys: env.array('APP_KEYS') },
});

2. Dockerfile

Файл Dockerfile.prod в корне проекта. Это многоступенчатая сборка из документации Strapi, где первая стадия собирает админку, а вторая берёт только то, что нужно для запуска:

dockerfile
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://localhost:1337/_health || exit 1
CMD ["npm", "run", "start"]

Два момента, на которых спотыкаются. NODE_ENV=production стоит после npm ci, потому что сборка админки требует devDependencies. vips-dev нужен библиотеке sharp, без него не будут делаться превью картинок.

Рядом .dockerignore, иначе в образ уедут node_modules с вашей машины и локальный .env:

node_modules/
.tmp/
.cache/
.git/
build/
dist/
.env

3. Переменные окружения

Файл .env рядом с compose. Секреты генерируем, а не копируем из примеров:

bash
for i in 1 2 3 4; do openssl rand -base64 32; done
env
NODE_ENV=production
HOST=0.0.0.0
PORT=1337
PUBLIC_URL=https://admin.example.ru
IS_PROXIED=true

DATABASE_CLIENT=postgres
DATABASE_HOST=db
DATABASE_PORT=5432
DATABASE_NAME=strapi
DATABASE_USERNAME=strapi
DATABASE_PASSWORD=длинный_пароль

APP_KEYS=ключ1,ключ2,ключ3,ключ4
API_TOKEN_SALT=...
ADMIN_JWT_SECRET=...
TRANSFER_TOKEN_SALT=...
JWT_SECRET=...
ENCRYPTION_KEY=...

DATABASE_HOST=db это имя сервиса из compose, не localhost и не IP. Внутри сети Docker контейнеры находят друг друга по именам сервисов. ENCRYPTION_KEY нужен Strapi 5 для шифрования секретов в базе, в частности чтобы API-токены можно было посмотреть в админке повторно.

4. docker-compose.yml

yaml
services:
  strapi:
    build:
      context: ./cms
      dockerfile: Dockerfile.prod
    image: example/strapi:latest
    restart: always
    env_file: .env
    volumes:
      - uploads:/opt/app/public/uploads
    ports:
      - "127.0.0.1:1337:1337"
    depends_on:
      db:
        condition: service_healthy
    networks:
      - internal

  db:
    image: postgres:16-alpine
    restart: always
    environment:
      POSTGRES_USER: ${DATABASE_USERNAME}
      POSTGRES_PASSWORD: ${DATABASE_PASSWORD}
      POSTGRES_DB: ${DATABASE_NAME}
    volumes:
      - pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U ${DATABASE_USERNAME} -d ${DATABASE_NAME}"]
      interval: 10s
      timeout: 5s
      retries: 5
    networks:
      - internal

  nginx:
    image: nginx:1.27-alpine
    restart: always
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./nginx/conf.d:/etc/nginx/conf.d:ro
      - certs:/etc/letsencrypt
      - certbot-www:/var/www/certbot
    depends_on:
      - strapi
    networks:
      - internal

  certbot:
    image: certbot/certbot
    volumes:
      - certs:/etc/letsencrypt
      - certbot-www:/var/www/certbot
    entrypoint: sh -c "trap exit TERM; while :; do certbot renew --webroot -w /var/www/certbot; sleep 12h & wait $${!}; done"
    networks:
      - internal

volumes:
  pgdata:
  uploads:
  certs:
  certbot-www:

networks:
  internal:

Что здесь важно.

5. Nginx и HTTPS

Файл nginx/conf.d/admin.conf. Сначала только HTTP, чтобы получить сертификат:

nginx
server {
    listen 80;
    server_name admin.example.ru;

    location /.well-known/acme-challenge/ {
        root /var/www/certbot;
    }

    location / {
        return 301 https://$host$request_uri;
    }
}

Поднимаем Nginx и просим сертификат:

bash
docker compose up -d nginx
docker compose run --rm certbot certonly --webroot -w /var/www/certbot \
  -d admin.example.ru --email admin@example.ru --agree-tos --no-eff-email

Добавляем HTTPS-блок в тот же файл:

nginx
server {
    listen 443 ssl;
    http2 on;
    server_name admin.example.ru;

    ssl_certificate     /etc/letsencrypt/live/admin.example.ru/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/admin.example.ru/privkey.pem;

    client_max_body_size 200m;

    location / {
        proxy_pass http://strapi: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";
        proxy_pass_request_headers on;
    }
}

client_max_body_size поднимаем сразу: у Nginx по умолчанию 1 МБ, и первая же загрузка фото в медиатеку вернёт 413. X-Forwarded-Proto вместе с proxy.koa: true в конфиге Strapi нужен, чтобы Strapi понимал, что он за HTTPS.

Оговорка про Let's Encrypt. С июня 2026 года в его пользовательском соглашении есть пункт про санкции США, и доменам компаний из санкционных списков сертификаты не выдают, в ответе certbot будет "forbidden by policy". Обычным компаниям выдают как раньше. Если ваш случай первый, certbot заменяется на любой другой удостоверяющий центр, конфиг Nginx при этом не меняется.

6. Запуск

bash
docker compose up -d --build
docker compose logs -f strapi

В логах ждём строку с адресом админки. Открываем https://admin.example.ru/admin и создаём первого администратора. Дальше полезные команды:

bash
# бэкап базы
docker compose exec db pg_dump -U strapi strapi | gzip > backup-$(date +%F).sql.gz

# бэкап медиа
docker compose cp strapi:/opt/app/public/uploads ./uploads-$(date +%F)

# обновление после правок в коде
docker compose build strapi && docker compose up -d strapi

Контент с локальной машины на сервер переносим командой transfer, а не архивом: в админке сервера создаём transfer-токен (Settings, Transfer Tokens) и запускаем в локальном проекте

bash
npm run strapi transfer -- --to https://admin.example.ru/admin --to-token ВАШ_ТОКЕН

Экспорт в архив тоже есть (npm run strapi export), но на проектах с большими медиа архивы у нас несколько раз оказывались битыми при импорте, transfer таких проблем не давал.

7. Next.js в том же compose

Добавляем сервис фронта:

yaml
  web:
    build: ./web
    restart: always
    environment:
      STRAPI_INTERNAL_URL: http://strapi:1337
      NEXT_PUBLIC_STRAPI_URL: https://admin.example.ru
    ports:
      - "127.0.0.1:3000:3000"
    depends_on:
      strapi:
        condition: service_healthy
    networks:
      - internal

Две переменные вместо одной, и это главное. Серверные компоненты и generateStaticParams ходят в Strapi по STRAPI_INTERNAL_URL, то есть по имени контейнера внутри сети. Браузер картинки и клиентские запросы делает по NEXT_PUBLIC_STRAPI_URL, публичному домену. Второй блок server в Nginx проксирует основной домен на web:3000.

Ошибки, которые мы ловили

ОшибкаПричинаЧто делать
`connect ECONNREFUSED 127.0.0.1:1337` в логах webФронт в контейнере ходит в Strapi по localhost или по внешнему домену, который на этом сервере резолвится в сам хостСерверные запросы по `http://strapi:1337`, публичный домен только для браузера
`getaddrinfo ENOTFOUND strapi`Сервисы в разных сетях или compose-проектах, имя не резолвитсяОдна сеть в `networks`, либо `external: true` для общей сети между проектами
Сборка Next.js падает с "Could not initialize the Strapi Client"На этапе сборки не задан адрес Strapi (пустой baseURL), переменная окружения не попала в buildПередать адрес Strapi в сборку через `build.args` и `ARG` в Dockerfile, а если страницы берут данные при сборке, поднять Strapi до `next build`
`413 Request Entity Too Large` при загрузке в медиатекуЛимит Nginx 1 МБ`client_max_body_size` в блоке server
Админка после деплоя отдаёт старую версию или пустую страницуНе пересобран образ, `npm run build` не выполнялся`docker compose build strapi`, проверить, что build не отключён в CI
403 при загрузке картинок в медиатеку, при этом API работаетНа сервере включена блокировка зарубежного трафика, а часть запросов от админки идёт через внешний CDN или проксиСмотреть, кто именно отдаёт 403 (Nginx, фаервол хостера, Strapi), исключить свой домен из геоблокировки
Картинки в записях есть, файлы 404Том uploads не смонтирован, файлы остались в старом контейнереСмонтировать том, восстановить файлы из бэкапа
Strapi стартует, но ссылки на медиа `http://localhost:1337/uploads/...`Не задан `url` в `config/server``PUBLIC_URL` и `proxy.koa: true`

Не хотите сами

Если нужно развернуть Strapi 5 на вашем сервере, перенести с чужого хостинга или разобраться, почему текущий деплой падает, напишите нам. Посмотрим проект и за день скажем, сколько займёт работа. Поддержка и аудит существующих проектов на Strapi у нас на strapi.expert, разработка новых на strapi.pro.

Частые вопросы

Есть ли официальный Docker-образ Strapi?

Готового образа для Strapi 5 нет, старый strapi/strapi на Docker Hub относится к версии 3 и не обновляется. Образ собирается из вашего проекта по Dockerfile, пример есть в официальной документации, и именно его мы берём за основу.

Почему админка не собирается в Docker с NODE_ENV=production?

Потому что npm ci с NODE_ENV=production не ставит devDependencies, а они нужны для сборки админки. В Dockerfile переменная NODE_ENV=production выставляется после npm ci, перед npm run build. Это прямо написано в документации Strapi и это самая частая ошибка в чужих Dockerfile.

Где хранить загруженные файлы?

В отдельном томе, смонтированном в /opt/app/public/uploads. Без тома файлы живут внутри контейнера и пропадают при пересборке. Для проектов с большим объёмом медиа лучше S3-совместимое хранилище российского провайдера через официальный провайдер @strapi/provider-upload-aws-s3.

Как перенести контент с локальной машины на сервер?

Командой strapi transfer с transfer-токеном, созданным в админке сервера. Экспорт в архив тоже работает, но на проектах с большими медиа у нас архивы бились, поэтому transfer надёжнее.

Можно ли запускать Strapi и Next.js в одном docker-compose?

Да, мы так и делаем. Главное, чтобы серверные запросы Next.js шли к Strapi по имени контейнера внутри сети Docker, а не по внешнему домену, иначе получите ECONNREFUSED.

Сколько ресурсов нужно серверу?

Для Strapi 5 с PostgreSQL и Nginx без фронта хватает 2 ядер и 4 ГБ памяти. На сборку образа памяти уходит больше, чем на работу, поэтому на 2 ГБ сборка может упасть.

Обсудим ваш проект

Нажимая кнопку, вы соглашаетесь на обработку персональных данных для ответа на заявку.