
n8n self-hosted в production: docker-compose, nginx, ретраи и три грабли
n8n запускается одной командой docker run и через пять минут вы видите логин-форму. Это маркетинговый ролик. Реальный production-конфиг — с persistent storage, корректными webhook-URL, ретраями, бэкапами PostgreSQL и мониторингом — выглядит сильно иначе. В этой статье — конфигурация, которую я держу на 12 проектах в течение полутора лет. Плюс три грабли, на которые наступал лично.
Все примеры — community-edition, без коммерческой лицензии. На проде у меня сейчас крутится 2.19.5, но в image: стоит n8nio/n8n:latest плюс Watchtower (про него ниже) — он подтягивает свежий образ ночью. Внутри 2.x API/env-переменные стабильны, рекомендую :latest + Watchtower на проектах где простой 5 минут утром не критичен, и закреплённый минор (:2.19.5) — на проектах где даунтайм нельзя.
Полный production-стек
Я не пишу ручной nginx-конфиг. Не из лени, а потому что nginxproxy/nginx-proxy + nginxproxy/acme-companion делают то же самое сильно проще: новый контейнер с правильными VIRTUAL_HOST / LETSENCRYPT_HOST метками — сам подхватывается, сам получает сертификат, сам обновляется. Плюс Watchtower для авто-обновления образов ночью, Portainer для веб-GUI Docker, Redis для queue mode.
Маленькая историческая ремарка: если открываете старые туториалы и видите там jwilder/nginx-proxy и jrcs/letsencrypt-nginx-proxy-companion — это те же образы, проект просто переехал в namespace nginxproxy/* и теперь поддерживается ZeroSSL. Старые имена технически ещё работают (как и у меня в одном legacy-проекте), но активный maintain и свежие релизы там, куда я указал. На новой инсталляции берите nginxproxy/*.
Файл docker-compose.yml целиком (минимальный для статьи):
services: # ──────────── Реверс-прокси + HTTPS (auto-config через labels) proxy: image: nginxproxy/nginx-proxy:alpine container_name: nginx-proxy restart: unless-stopped ports: - "80:80" - "443:443" volumes: - /var/run/docker.sock:/tmp/docker.sock:ro - nginx_certs:/etc/nginx/certs - nginx_vhost:/etc/nginx/vhost.d - nginx_html:/usr/share/nginx/html networks: [internal] letsencrypt: image: nginxproxy/acme-companion container_name: nginx-le restart: unless-stopped env_file: .env environment: - NGINX_PROXY_CONTAINER=nginx-proxy volumes: - /var/run/docker.sock:/var/run/docker.sock:ro - nginx_certs:/etc/nginx/certs - nginx_vhost:/etc/nginx/vhost.d - nginx_html:/usr/share/nginx/html depends_on: [proxy] networks: [internal] # ──────────── PostgreSQL (доступен локально для SSH-туннеля) postgres: image: postgres:15-alpine container_name: n8n-postgres restart: unless-stopped env_file: .env environment: - POSTGRES_DB - POSTGRES_USER - POSTGRES_PASSWORD volumes: - pg_data:/var/lib/postgresql/data ports: - "127.0.0.1:5432:5432" networks: [internal] # ──────────── Redis (для queue mode) redis: image: redis:7-alpine container_name: n8n-redis restart: unless-stopped env_file: .env command: ["redis-server", "--requirepass", "${REDIS_PASSWORD}"] volumes: - redis_data:/data networks: [internal] # ──────────── n8n n8n: image: n8nio/n8n:latest container_name: n8n-app restart: unless-stopped env_file: .env environment: - DB_TYPE=postgresdb - DB_POSTGRESDB_HOST=postgres - DB_POSTGRESDB_PORT=5432 - DB_POSTGRESDB_DATABASE - DB_POSTGRESDB_USER - DB_POSTGRESDB_PASSWORD - N8N_ENCRYPTION_KEY - N8N_DEFAULT_BINARY_DATA_MODE=filesystem - N8N_PROTOCOL=https - N8N_EDITOR_BASE_URL=https://${DOMAIN_N8N}/ - WEBHOOK_URL=https://${DOMAIN_N8N}/ - N8N_PROXY_HOPS=1 - N8N_SECURE_COOKIE=false - VIRTUAL_HOST=${DOMAIN_N8N} - VIRTUAL_PORT=5678 - CLIENT_MAX_BODY_SIZE=64m - LETSENCRYPT_HOST=${DOMAIN_N8N} - LETSENCRYPT_EMAIL=${LE_EMAIL} - GENERIC_TIMEZONE=Europe/Moscow - TZ=Europe/Moscow - NODE_FUNCTION_ALLOW_BUILTIN=crypto volumes: - n8n_data:/home/node/.n8n depends_on: [postgres, proxy] networks: [internal] # ──────────── Portainer (веб-GUI Docker) portainer: image: portainer/portainer-ce:latest container_name: portainer restart: unless-stopped env_file: .env environment: - VIRTUAL_HOST=${DOMAIN_PORTAINER} - LETSENCRYPT_HOST=${DOMAIN_PORTAINER} - LETSENCRYPT_EMAIL=${LE_EMAIL} - VIRTUAL_PORT=9000 volumes: - /var/run/docker.sock:/var/run/docker.sock - portainer_data:/data depends_on: [proxy, letsencrypt] networks: [internal] # ──────────── Watchtower (авто-обновления контейнеров) watchtower: image: containrrr/watchtower container_name: watchtower restart: unless-stopped command: --schedule "0 0 3 * * *" --cleanup volumes: - /var/run/docker.sock:/var/run/docker.sock networks: [internal]volumes: pg_data: redis_data: n8n_data: nginx_certs: nginx_vhost: nginx_html: portainer_data:networks: internal: driver: bridge
И .env рядом:
# n8nDOMAIN_N8N=n8n.example.ruDOMAIN_PORTAINER=portainer.example.ruLE_EMAIL=you@example.comN8N_ENCRYPTION_KEY=сгенерируйте_32_символа_случайных# PostgresPOSTGRES_DB=n8nPOSTGRES_USER=n8nPOSTGRES_PASSWORD=сгенерируйте_сильный_пароль# RedisREDIS_PASSWORD=сгенерируйте_сильный_пароль
Несколько моментов которые сильно не очевидны новичкам.
Никакого version: "3.8" в начале. Полностью устаревший атрибут, его выпилили из Compose Spec ещё в 2023-м. Docker Compose v2 на свежих машинах либо выдаёт жирный warning, либо отказывается стартовать (obsolete attribute). Просто не пишите эту строку, схему файла Compose определит сам. Если копируете чужие туториалы и видите version: в первой строке — удаляйте.
n8n никаких портов наружу не пробрасывает. Контейнер слушает :5678 внутри сети internal, наружу его пробрасывает только nginx-proxy через VIRTUAL_HOST метку. Это работает потому что nginx-proxy смонтирован к /var/run/docker.sock и сам определяет какие сервисы куда роутить. На свежей машине после docker compose up -d через пару минут на https://n8n.example.ru уже валидный TLS — companion сам пошёл в Let’s Encrypt и взял сертификат.
Postgres 15-alpine. Тут оговорка: официальный репозиторий n8n-io/n8n-hosting сейчас в эталонных примерах использует postgres:16. На новой инсталляции имеет смысл брать именно 16. У меня в проде 15 не из принципа, а исторически: систему ставил полтора года назад, тогда 15 была свежей мажорной версией, всё работает, и pg_upgrade ради профита которого здесь нет — это даунтайм без выигрыша. n8n спокойно живёт на 13/14/15/16, под капотом TypeORM-схема без специфики мажора. Если у вас уже что-то стоит — не трогайте без необходимости. Если ставите с нуля — берите 16.
N8N_ENCRYPTION_KEY генерируется один раз и не меняется. Этим ключом n8n шифрует credentials в БД. Если поменяете — все ранее сохранённые токены/пароли в credentials превратятся в нечитаемый мусор, и придётся переподключать все интеграции руками. Сгенерируйте через openssl rand -hex 32 и сохраните в безопасное место. У меня хранится в 1Password плюс распечатан и лежит в офисе.
N8N_PROXY_HOPS=1 — n8n верит первому X-Forwarded-For заголовку для определения реального IP клиента (нужно для логов и rate-limit’ов). Если поставить больше — получите подмену IP через подделанные заголовки, если меньше — в логах увидите только IP nginx-proxy.
CLIENT_MAX_BODY_SIZE=64m — иначе тихий 413 на любом файле тяжелее 1 МБ. По умолчанию nginx режет тело запроса на 1 МБ. Webhook с PDF, фотографией или голосовухой больше этого размера получит 413 Request Entity Too Large от nginx до того, как до n8n вообще что-то долетит. Самое подлое — в n8n executions это видно как обрыв на webhook-узле без понятной причины: статус успешный (потому что nginx-проблема, не n8n), но binary.data пустой. Особенно больно ловит при работе с Telegram через getFile + загрузку контента (документы до 20 МБ, видео и голосовые — до 50 МБ). У nginxproxy/nginx-proxy лимит выставляется через env-переменную CLIENT_MAX_BODY_SIZE прямо на сервисе-backend’е — прокси сам подставит в vhost. Глобально на прокси тоже можно (та же переменная на контейнере proxy), но per-service гибче: статический сайт и webhook-инстанс редко требуют одинаковых лимитов.
Watchtower: зачем :latest это нормально (и почему критично для агентств)
Стандартный совет «всегда пинить минорную версию» в production — правильный для критичных систем где у вас есть инженер на постоянной поддержке. Но самый частый реальный сценарий self-hosted n8n в B2B — другой: студия/агентство развернуло инстанс под клиента, сдало его в эксплуатацию, и обслуживание после релиза или прекратилось, или ведётся фрагментарно по запросам. В таком сценарии стандартный совет ломается на ровном месте, и я объясню почему.
n8n — это публичный веб-интерфейс плюс runner кода. Регулярно (несколько раз в год) выходят критические security-обновления, закрывающие реальные уязвимости: SSRF через HTTP Request узлы, прокидывание credentials, prototype pollution в payload-парсерах, баги авторизации. История security advisories n8n на GitHub открытая, можете полистать.
Когда вы лично каждый день заходите в UI n8n под своим проектом — вы увидите верхнюю плашку «новая версия» сразу, как только она появится в Docker Hub, и при появлении в release notes слова Security оперативно её накатите. Когда тот же инстанс отдан клиенту, который в UI не заходит вообще, а доработки на нём не ведутся — этой плашки никто не увидит. Сертификаты пере-выпускаются автоматически, контейнер «работает», но внутри живёт незакрытая уязвимость, которая через полгода может стать чьим-то трофеем. Если у клиента n8n торчит наружу (а в 90% случаев да — туда же приходят webhook’и), это вопрос времени.
Watchtower эту дыру закрывает структурно: ночью в 03:00 он сам тянет свежий образ из Docker Hub, гасит и поднимает контейнер, всё. Никакой плашки не нужно — просто работает на той версии, что вышла последней. Стоимость — минутный даунтаут утром раз в несколько недель, который никто не заметит. Цена ущерба от не накатанной security-фиксы — на порядки выше.
Экономика для агентств, которые администрируют десяток инсталляций: n8n релизит 2-4 обновления в месяц. Это 20-40 рестартов в месяц на 10 проектов, если делать руками. По 10-15 минут на каждый (зайти, проверить changelog, рестартнуть, прогреть, убедиться что цепочки живые) — 5-10 часов в месяц просто на «не запустить уязвимый контейнер у клиента». Watchtower с расписанием --schedule "0 0 3 * * *" --cleanup это всё закрывает за ноль часов в месяц.
Логика конфигурации:
-
n8n релизит обновления часто (2-4 раза в месяц), и в подавляющем большинстве это патчи интеграций, фиксы багов, плюс security
-
Breaking changes в minor-релизах внутри одной major-серии (2.x) практически отсутствуют — конфиги, env-переменные, API стабильны
-
Watchtower обновляет только если в Docker Hub появился новый image — не дёргает контейнер просто так
-
--cleanupудаляет старые образы после успешного обновления — диск не забивается на 100 ГБ за полгода
За полтора года на десятке клиентских проектов Watchtower уронил систему один раз — при переходе с 1.x на 2.x была необходимость в ручной миграции. После этого я закрепил major через :2-latest вместо просто :latest:
image: n8nio/n8n:2-latest
Минорные апдейты внутри 2.x идут автоматически, переход на 3.x когда выйдет — буду делать руками с предварительной проверкой. Грубая прикидка соотношения «затрат на ручное обслуживание ÷ риск пропустить security»: раз в полгода поднять упавший после автоапдейта workflow на одном проекте дешевле, чем 5-10 часов в месяц ручных обновлений десятка контейнеров, плюс риск, что в одном из них тихо живёт CVE, который мы не накатили потому что в UI к клиенту никто не заходит.
Грабля номер один: WEBHOOK_URL
Самая распространённая ошибка новичков — webhook-узел сгенерировал URL вида http://localhost:5678/webhook/abc123, и человек тыкает его в ручку API. Понятно, что не работает.
Корень проблемы: переменная WEBHOOK_URL в env. Если её не задать или задать неверно, n8n использует значение по умолчанию (на основе N8N_HOST). У меня были случаи, когда сервер слушал 0.0.0.0, WEBHOOK_URL не был задан, и весь production окей дёргал HTTP-эндпоинт без TLS — пока однажды партнёрский сервис не перешёл на строгую SSL-проверку и всё легло.
Проверка после деплоя:
curl -s https://n8n.example.ru/healthz# {"status":"ok"}
И в самом интерфейсе создайте тестовый Webhook-узел, посмотрите URL который он показывает в правой панели. Если там http://localhost:5678/... — WEBHOOK_URL не подхватился, рестарт контейнера обязателен.
Отдельный случай: N8N_EDITOR_BASE_URL и WEBHOOK_URL могут быть разными доменами, и это не баг, а фича. У меня в проде так:
N8N_EDITOR_BASE_URL=https://n8n.example.ru/WEBHOOK_URL=https://tg.example.ru/
Это нужно когда webhook-эндпоинты выставлены через отдельный CDN/туннель (про cloudflared дальше будет отдельная глава, там как раз про этот случай).
Бэкапы PostgreSQL
n8n хранит всю историю выполнений и конфигурацию workflow в PostgreSQL. Потеря БД — потеря всего, что вы настраивали месяцами. Бэкап через pg_dump в crontab:
# /etc/cron.d/n8n-backup0 3 * * * root docker exec -t n8n-postgres pg_dumpall -c -U n8n | gzip > /var/backups/n8n/n8n-$(date +\%F).sql.gz0 4 * * 0 root find /var/backups/n8n/ -name "*.sql.gz" -mtime +30 -delete
Каждое утро в 03:00 — полный дамп, в 04:00 в воскресенье — чистка старых файлов (хранится месяц). Дамп жмётся в gzip, занимает порядка 5-10 МБ на 200 активных workflow.
Восстановление:
gunzip < /var/backups/n8n/n8n-2026-05-08.sql.gz | docker exec -i n8n-postgres psql -U n8n
Делал три раза за полтора года — всегда отрабатывало. Один раз потеряли неделю работ из-за того, что бэкап делался, но не копировался на внешний сервер. Мораль: бэкапы должны лежать минимум в двух местах. У меня сейчас локальный + еженочный rsync на S3-совместимое хранилище у Beget’а.
Грабля номер два: очередь выполнений и память
n8n по умолчанию хранит все executions в БД. На активных workflow таблица execution_entity растёт быстро — у одного клиента она достигла 18 ГБ за 4 месяца, n8n начал тормозить и валиться по OOM. Решение в env:
EXECUTIONS_DATA_PRUNE: "true"EXECUTIONS_DATA_MAX_AGE: 168 # часов = 7 днейEXECUTIONS_DATA_PRUNE_MAX_COUNT: 10000
После включения n8n чистит данные старше 7 дней, лимитирует общее число до 10000. На моём проде таблица стабилизировалась на 1.2 ГБ.
Дополнительный момент: если у вас много параллельных workflow с тяжёлой логикой, переходите на режим очередей с Redis:
EXECUTIONS_MODE: queueQUEUE_BULL_REDIS_HOST: redisQUEUE_BULL_REDIS_PORT: 6379
И добавляете worker-сервисы в docker-compose. Без queue mode параллельный лимит ограничен N8N_CONCURRENCY_PRODUCTION_LIMIT (по умолчанию -1 = без лимита, но всё в одном Node-процессе — на пиках падает).
Грабля номер три: webhook-задержки на холодном старте
После рестарта контейнера первый webhook-вызов может ждать ответа 5-10 секунд — n8n инициализирует runtime, читает workflow из БД, прогревает кеш узлов. Если ваш партнёрский сервис ставит таймаут 5 секунд (это многие платёжки) — он считает webhook неуспешным и иногда повторяет запрос.
Что важно знать с Watchtower: контейнер обновляется ночью в 03:00, дальше до первого webhook-запроса проходит несколько часов (бизнес-партнёры просыпаются). Первый утренний запрос неизбежно холодный и медленный. На критичных проектах я после рестарта явно прогреваю n8n самостоятельно.
Решение: warm-up-скрипт, который дёргает healthcheck-эндпоинт сразу после старта:
#!/bin/bashdocker compose up -dsleep 5for i in {1..10}; do curl -fs https://n8n.example.ru/healthz && break sleep 2doneecho "n8n ready"
Альтернативно — на стороне webhook-источника поставить ретрай с экспоненциальным backoff, если у партнёра это возможно. Ещё один вариант (если у вас Watchtower) — повесить cron на 03:05 на сервере с этим warm-up-скриптом сразу после ночного апдейта. Тогда даже первый утренний запрос будет от уже разогретого n8n.
Российская специфика: cloudflared для Telegram webhook
Не очевидный для большинства туториалов момент. Telegram Bot API не принимает webhook’и на IP-адреса российских хостингов — после серии политических событий и обновлений списков. Это значит что прямой setWebhook на n8n.example.ru где example.ru указывает на IP вашего РФ-VPS — не сработает. TG-API ответит {"ok":false,"error_code":400,"description":"Bad Request: bad webhook"} либо «успешно» зарегистрирует, но события приходить не будут.
Решение — Cloudflare Tunnel. Контейнер cloudflared устанавливает исходящее соединение к CF Edge, и Telegram бьёт в CF (не имеющий привязки к РФ-IP), а CF проксирует через Tunnel внутрь вашего n8n. С точки зрения TG webhook лежит на cloudflare-домене:
# добавить в docker-compose.yml cloudflared: image: cloudflare/cloudflared:latest container_name: cloudflared restart: unless-stopped command: tunnel --no-autoupdate run environment: - TUNNEL_TOKEN=${CLOUDFLARED_TOKEN} networks: [internal] depends_on: [n8n]
Tunnel token берётся в CF Dashboard: Zero Trust → Networks → Tunnels → Create. Внутри туннеля настраиваете один public hostname (например tg.example.ru или hooks.example.ru) и роутите его на http://n8n:5678. Сертификат CF выдаёт сам, ничего настраивать не нужно.
В env при этом:
N8N_EDITOR_BASE_URL=https://n8n.example.ru/ # прямой через nginx-proxyWEBHOOK_URL=https://tg.example.ru/ # через CF Tunnel
То есть UI работает по прямой ссылке без CF (быстрее), а webhook-эндпоинты в Telegram-нодах получают URL через туннель. Не-Telegram интеграции (Tilda, AmoCRM, CRM) при этом продолжают принимать запросы по основному домену, потому что n8n слушает webhook независимо от того по какому host header пришёл запрос.
Стоимость — бесплатно для нашего use-case (просто туннелирование без CF Access авторизации). Cloudflare явных публичных лимитов на bandwidth/RPS для free tier не объявляет; на webhook-нагрузках за пол года я никаких ограничений не встречал, даже на проекте с пиками 100+ TG-сообщений в минуту. Latency Tunnel’а добавляет к webhook’у +50-100 мс, в нашем случае это незаметно. Альтернатива — VPS за границей с проксированием на РФ — дороже, сложнее, чаще обрывается.
Ретраи внутри workflow
Стандартный узел HTTP Request не делает ретраи автоматически. Если внешний API ответил 502 — workflow упадёт, и без обработки ошибок это приведёт к потере данных.
Минимальная обёртка через узел Error Trigger или через настройки самого узла:
HTTP Request settings: Retry On Fail: ON Max Tries: 3 Wait Between Tries: 5000 # ms
Этого достаточно для 80% случаев. Для критичных операций (платежи, отправка SMS) добавляю отдельный path через узел If:
[HTTP Request] → [If: status >= 500] ↓ true [Wait 30s] → [HTTP Request retry] → [Postgres: log] ↓ false [Continue normal flow]
Логирование в Postgres даёт возможность поднять историю фейлов и расследовать проблемы постфактум. У меня в сервисной таблице:
CREATE TABLE n8n_failures ( id BIGSERIAL PRIMARY KEY, workflow_id TEXT NOT NULL, node_name TEXT NOT NULL, error_text TEXT, payload JSONB, occurred_at TIMESTAMPTZ DEFAULT NOW());CREATE INDEX ON n8n_failures (occurred_at DESC);CREATE INDEX ON n8n_failures (workflow_id);
Раз в неделю прогоняю агрегацию по workflow_id, node_name — вижу узлы с топ-ошибками и фикшу.
Мониторинг
n8n с N8N_METRICS: true отдаёт Prometheus-эндпоинт на /metrics. Минимальный stack — Prometheus + Grafana + Alertmanager:
# docker-compose.monitoring.ymlprometheus: image: prom/prometheus volumes: - ./prometheus.yml:/etc/prometheus/prometheus.yml ports: - "127.0.0.1:9090:9090"grafana: image: grafana/grafana ports: - "127.0.0.1:3000:3000" environment: GF_AUTH_ANONYMOUS_ENABLED: "true"
В prometheus.yml:
scrape_configs: - job_name: n8n static_configs: - targets: ["n8n:5678"] metrics_path: /metrics
Ключевые метрики, по которым стоит ставить алерты:
-
n8n_active_workflows— если упало до 0, что-то сломалось -
n8n_workflow_failed_total— рост говорит о проблеме с интеграцией -
Высокий
n8n_node_running_time_secondsна конкретных узлах — узкое место
На простых инсталляциях вместо Prometheus достаточно uptime-кобота, который дёргает /healthz каждые 60 секунд и шлёт в Telegram при недоступности.
Когда self-hosted не нужен
После всего написанного честный итог: если у вас 10-30 простых workflow в месяц, вы платите за VPS 700 рублей, и нет команды на DevOps — берите cloud n8n.io, тариф Starter за 20 евро в месяц. Получите managed-сервис с автообновлениями, бэкапами и поддержкой. На 30-100 операций в день экономия времени окупает разницу в цене.
Self-hosted имеет смысл, когда:
-
Объём операций превышает 1000 в день — расходы на cloud начинают расти быстрее, чем VPS
-
Есть требование 152-ФЗ — данные клиентов должны физически быть в РФ
-
У вас есть инженер на 1-2 часа в неделю на поддержку
-
Нужна интеграция с внутренней инфраструктурой (private API, базы данных за корпоративным VPN)
Иначе — пользуйтесь готовым cloud, не разводите серверный зоопарк.
Полезные ссылки
-
Официальная документация: docs.n8n.io
-
Список интеграций: n8n.io/integrations
-
Community-форум: community.n8n.io
-
Шаблоны workflow: n8n.io/workflows
-
Production checklist в официальной доке: docs.n8n.io/hosting/scaling/
Если что-то сломается, что не описано здесь, — смотрите логи контейнера через docker logs n8n -f --tail=200. В 90% случаев причина видна сразу: либо упала PostgreSQL (нет места на диске, рост таблицы executions), либо webhook не доходит из-за неверного WEBHOOK_URL, либо timeouted внешний API (увеличить proxy_read_timeout в nginx).
Эта конфигурация обкатана на проде в одной студии чат-ботов, делающей в среднем 50-100к операций в месяц на n8n. Бывало всё из того, что описано выше — и каждая грабля стоила нескольких часов отладки. Надеюсь, кому-то сэкономит время.
ссылка на оригинал статьи https://habr.com/ru/articles/1033716/