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

от автора

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 это всё закрывает за ноль часов в месяц.

Логика конфигурации:

  1. n8n релизит обновления часто (2-4 раза в месяц), и в подавляющем большинстве это патчи интеграций, фиксы багов, плюс security

  2. Breaking changes в minor-релизах внутри одной major-серии (2.x) практически отсутствуют — конфиги, env-переменные, API стабильны

  3. Watchtower обновляет только если в Docker Hub появился новый image — не дёргает контейнер просто так

  4. --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-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 имеет смысл, когда:

  1. Объём операций превышает 1000 в день — расходы на cloud начинают расти быстрее, чем VPS

  2. Есть требование 152-ФЗ — данные клиентов должны физически быть в РФ

  3. У вас есть инженер на 1-2 часа в неделю на поддержку

  4. Нужна интеграция с внутренней инфраструктурой (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. Бывало всё из того, что описано выше — и каждая грабля стоила нескольких часов отладки. Надеюсь, кому-то сэкономит время.

ссылка на оригинал статьи https://habr.com/ru/articles/1033716/