n8n self-hosted в production: docker-compose, nginx, ретраи и три грабли

Страницы:  1

Ответить
 

Professor Seleznov



pic
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 рядом:
# n8n
DOMAIN_N8N=n8n.example.ru
DOMAIN_PORTAINER=portainer.example.ru
LE_EMAIL=you@example.com
N8N_ENCRYPTION_KEY=сгенерируйте_32_символа_случайных
# Postgres
POSTGRES_DB=n8n
POSTGRES_USER=n8n
POSTGRES_PASSWORD=сгенерируйте_сильный_пароль
# Redis
REDIS_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.0WEBHOOK_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-backup
0 3 * * * root docker exec -t n8n-postgres pg_dumpall -c -U n8n | gzip > /var/backups/n8n/n8n-$(date +\%F).sql.gz
0 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: queue
QUEUE_BULL_REDIS_HOST: redis
QUEUE_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/bash
docker compose up -d
sleep 5
for i in {1..10}; do
curl -fs https://n8n.example.ru/healthz && break
sleep 2
done
echo "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-proxy
WEBHOOK_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.yml
prometheus:
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, не разводите серверный зоопарк.
Полезные ссылки Если что-то сломается, что не описано здесь, - смотрите логи контейнера через docker logs n8n -f --tail=200. В 90% случаев причина видна сразу: либо упала PostgreSQL (нет места на диске, рост таблицы executions), либо webhook не доходит из-за неверного WEBHOOK_URL, либо timeouted внешний API (увеличить proxy_read_timeout в nginx).
Эта конфигурация обкатана на проде в одной студии чат-ботов, делающей в среднем 50-100к операций в месяц на n8n. Бывало всё из того, что описано выше - и каждая грабля стоила нескольких часов отладки. Надеюсь, кому-то сэкономит время.-Источник
 
Loading...
Error