empostigo/n8n-compose-field-guide
Contribute to empostigo/n8n-compose-field-guide development by creating an account on GitHub.
1 stars · 0 forks · 0 watchers · Shell
README
Docker Compose for n8n: securing and hardening a production deployment
🇫🇷 Français | 🇬🇧 English A self-hosted n8n stack that survives a server reboot, doesn't leak secrets into git history or docker inspect, and doesn't 502 on first boot — Docker Compose, Caddy, and PostgreSQL, with every decision explained and validated end-to-end on a live VPS. validate-stack.sh: 16/16 checks passing — healthy services, secrets off docker inspect, TLS issued, webhook reachable from the outside.Table of contents
Project structure docker-compose.yml file format Pinned versions Secrets vs. configuration separation Data persistence: bind mounts and named volumes Network isolation Healthchecks and startup order Validation before deployment Setting up the stack Common mistakes
The sections that follow walk through deploying an n8n stack in production on a VPS: a Caddy reverse proxy, n8n itself, and PostgreSQL as the persistent database.
1. Project structure n8n-stack/ ├── docker-compose.yml └── services/ ├── caddy/ │ ├── Caddyfile # reverse proxy config │ ├── caddy.yml # compose fragment │ ├── data/ # TLS certificates (Let's Encrypt) │ └── logs/ ├── n8n/ │ ├── n8n.yml # compose fragment │ ├── .env.example # committed template, values to adapt │ ├── .env # actual config (0600) — in .gitignore │ └── secrets/ # *.txt (0600) — in .gitignore │ ├── n8n_encryption_key.txt │ └── n8n_db_password.txt └── postgres/ ├── postgres.yml # compose fragment ├── .env.example # committed template, values to adapt ├── .env # actual config (0600) — in .gitignore ├── secrets/ # *.txt (0600) — in .gitignore │ └── root_db_password.txt └── data/ # /var/lib/postgresql (PG18+)
2. docker-compose.yml file format Each service has its own compose fragment. The root docker-compose.yml assembles them:
docker-compose.yml
name: n8n-stackinclude: - services/caddy/caddy.yml - services/n8n/n8n.yml - services/postgres/postgres.yml
networks: front: driver: bridge data: driver: bridge internal: true name: n8n-stack Sets the Compose project name — the prefix used for containers, networks, and volumes, and what isolates one project's resources from another deployment's. Without this attribute, the project name falls back to the name of the directory containing the compose file. Set explicitly in the file, name: makes the namespace independent of the local path — everyone on the team gets the same project name, regardless of where they cloned the repo. No version: in this example, deliberately: it's a deprecated field since Compose V2, kept only for compatibility with older files. Compose always uses the latest schema regardless of what version: would say, and its only effect today is to trigger a warning if present. include: Lets you split the compose configuration into one file per service, instead of stacking everything into a single docker-compose.yml. At startup, Docker Compose loads each listed fragment and merges them into a single model.
services/postgres/postgres.yml — ./data resolves from services/postgres/
services: postgres: image: postgres:18.4-alpine3.24 volumes: - ./data:/var/lib/postgresql # → services/postgres/data/ The key property: each fragment resolves its relative paths from its own directory. The bind mount ./data in services/postgres/postgres.yml points to services/postgres/data/ on the host: the path resolves relative to the fragment's location, not the project root. That's what keeps the per-service layout coherent: each fragment is self-contained. Without include:, the alternative is passing multiple files with -f (docker compose -f fileA.yml -f fileB.yml up). The problem: all relative paths across all files are then resolved from the directory of the first file passed, which breaks the layout as soon as fragments live in separate subdirectories.3. Pinned versions services: postgres: image: postgres:18.4-alpine3.24 # pinned version Pinning versions guarantees that a deployment done today is identical to one done next week: here, you're guaranteed to get PostgreSQL 18.4-alpine3.24, no matter when docker compose up runs. The latest tag doesn't guarantee this reproducibility: two pulls at different times can resolve to different images. And it doesn't mean "latest version" — it's simply the tag assigned by default when an image is built: docker build -t my-image . # equivalent to -t my-image:latest docker build -t my-image:1.2.3 . # explicit tag: 1.2.3, not latest With several services tagged latest, version inconsistencies pile up silently. Pinning a version doesn't prevent updating — it makes the update intentional rather than accidental.
4. Secrets vs. configuration separation The .env file is often the first instinct — and it's fine for non-sensitive config. For secrets, Docker Compose has a dedicated mechanism. The rule:
Data type Storage Injection into the container
Non-sensitive config .env per service env_file: in compose
Secrets (keys, passwords, tokens) services//secrets/.txt secrets: → /run/secrets/
Never put a secret in plaintext in a .yml or .env. secrets/*.txt and .env (the real one, not .env.example) must be in .gitignore. These files must never be committed. For details on what belongs in .env vs. secrets/, see the appendix. How Docker Compose secrets work Each service declares and consumes its own secret in its own fragment:
services/n8n/n8n.yml
secrets: n8n_encryption_key: file: ./secrets/n8n_encryption_key.txt n8n_db_password: file: ./secrets/n8n_db_password.txtservices: n8n: secrets: - n8n_encryption_key - n8n_db_password environment: N8N_ENCRYPTION_KEY_FILE: /run/secrets/n8n_encryption_key DB_POSTGRESDB_PASSWORD_FILE: /run/secrets/n8n_db_password n8n_encryption_key deserves a special mention: it's what encrypts every credential stored in the database (API keys, OAuth tokens, passwords for third-party connections configured in workflows). Losing it makes every existing credential permanently unrecoverable — even with full, decrypted access to the PostgreSQL database itself, which only stores the encrypted version. No recovery procedure exists: back it up separately (a password manager, for instance), in addition to its presence in secrets/.
services/postgres/postgres.yml
secrets: root_db_password: file: ./secrets/root_db_password.txtservices: postgres: secrets: - root_db_password environment: POSTGRES_PASSWORD_FILE: /run/secrets/root_db_password Declaring and consuming a secret are two distinct steps, visible in each of the two fragments above. The top-level secrets: block declares the secret: it gives it a name and links it to its source file on the host. The service's secrets: list consumes the secret by referencing its declared name. A secret that's declared but missing from a service's list stays invisible to that service — even if both live in the same fragment. Note: Docker mounts the secret via tmpfs — an in-memory filesystem. The file is never written to the host's disk. When the container stops, the secret disappears from memory. The _FILE convention: an application responsibility, not Docker's Docker does exactly one thing with secrets:: mount the file at /run/secrets/<name>. That's it. It doesn't know what the application does with it, and doesn't care. N8N_ENCRYPTION_KEY_FILE, DB_POSTGRESDB_PASSWORD_FILE, POSTGRES_PASSWORD_FILE aren't Docker mechanisms — n8n and the official postgres entrypoint each separately chose to recognize a _FILE suffix on certain variables and read the designated file's content themselves at startup. It's an application-level convention popularized by official Docker images (Compose itself documents it on the WordPress + MySQL example), but it's neither universal nor guaranteed: some images don't support it at all. Before wiring a secret into a new image, check its documentation to confirm it actually supports the _FILE suffix for the variable in question. Without that support, secrets: does mount the file, but nothing in the image will read it on its own. Two options:
The extended syntax's target:, if the application expects a fixed hardcoded path — no environment variable to provide, just the right file in the right place: secrets: - source: my_secret target: /etc/app/password
A custom entrypoint, that reads /run/secrets/<name> and exports the variable the application actually expects, before launching the main process.
The trailing newline trap Creating the secret file with echo "value" > file.txt adds an invisible trailing newline. Some images tolerate it — the official postgres entrypoint trims the value it reads — others don't. printf '%s' "value" > file.txt removes the problem at the source, without depending on whether the image on the other end is forgiving. How env_file: works env_file: is resolved on the host side, by the docker compose client — the binary launched from the command line, as opposed to the daemon (dockerd) that actually manages containers. The client runs with the operator's Unix permissions: it reads the .env file, extracts the VAR=value pairs, and passes the already-resolved result to the daemon via its API, which creates the container with those variables. The .env file never crosses that boundary: the process running inside the container inherits its environment variables the same way it would inherit PATH or HOME — no file is opened at that point, so no UID inside the container is ever involved. That's the reverse of how secrets work, described above: a file: secret is actually mounted as a file inside the container (via tmpfs), which makes the source file's permission on the host relevant once it's propagated. For .env, that question never comes up. File permissions on the host
Secrets — readable only by the owner
chmod 0600 services//secrets/.txt.env files — same operator, same logic
chmod 0600 services/*/.env 0600 on secrets: neither the group nor others can read the file. If the host is accessed, API keys stay inaccessible without owning the file. 0600 on .env follows the same logic: the process launching docker compose reads the file with the current user's rights, so as long as it's the same operator editing the files and running the command, nothing requires going beyond 0600. No sensitive data should live in .env anyway, regardless of the mode chosen. Contents of an .env.example fileIdentity and URL (n8n behind Caddy)
N8N_HOST=n8n.example.com N8N_PORT=5678 N8N_PROTOCOL=https WEBHOOK_URL=https://n8n.example.com/ N8N_PROXY_HOPS=1 GENERIC_TIMEZONE=Europe/Paris N8N_DIAGNOSTICS_ENABLED=falseDatabase
DB_TYPE=postgresdb DB_POSTGRESDB_HOST=postgres DB_POSTGRESDB_PORT=5432 DB_POSTGRESDB_USER=n8n DB_POSTGRESDB_DATABASE=n8n N8N_PORT=5678 stays n8n's internal listening port — never exposed on the host (see §6), only Caddy can reach it via the Docker network. Without WEBHOOK_URL, n8n builds its own public URLs (editor, webhooks) by concatenating N8N_PROTOCOL + N8N_HOST + N8N_PORT, which gives https://n8n.example.com:5678 — a port unreachable from the outside. Any external integration calling that webhook would fail silently. WEBHOOK_URL sets the public URL explicitly, with no port. N8N_PROXY_HOPS=1 tells n8n there's exactly one reverse proxy in front of it, so it trusts the X-Forwarded-* headers Caddy adds automatically. GENERIC_TIMEZONE doesn't set the server's timezone — that's the TZ variable's job, which controls what date and system commands return inside the container. GENERIC_TIMEZONE sets the timezone n8n uses to evaluate its scheduled nodes (Schedule Trigger, cron expressions). Its default value is America/New_York, not UTC: without setting it explicitly, a trigger scheduled for 9am fires at 9am New York time — several hours before 9am Paris time. By default, n8n collects anonymous data even when self-hosted — workflow execution counts and an instance "pulse" sent every 6 hours to n8n's servers. Not workflow content or credentials, but an outbound flow many people don't expect on a stack they thought was isolated. N8N_DIAGNOSTICS_ENABLED=false turns it off; N8N_VERSION_NOTIFICATIONS_ENABLED=false additionally stops the new-version check. Creating a secret file printf '%s' 'a-randomly-generated-password' > services/postgres/secrets/root_db_password.txt Unlike .env, a secret file contains no variable name or = sign — just the raw value, no quotes, no trailing newline (see "The trailing newline trap" above). That's exactly the value Docker mounts as-is at /run/secrets/<name>.5. Data persistence: bind mounts and named volumes Bind mount directories A bind mount maps a specific host path to a path inside the container (./services/caddy/Caddyfile:/etc/caddy/Caddyfile). If a bind mount is declared and the directory doesn't exist, Docker creates it as root. If the container starts directly as non-root, the write fails immediately with a permissions error. In this stack, only postgres is affected: Caddy runs as root, so no permission mismatch is possible on its bind mount; n8n uses a named volume rather than a bind mount (see next section). The precaution therefore only applies to services/postgres/data — and a plain mkdir -p isn't enough: the directory must belong precisely to the UID postgres runs as in the alpine image (70), or the entrypoint fails to create its own subdirectories there:
Run before docker compose up
mkdir -p services/postgres/data sudo chown -R 70:70 services/postgres/data Named volumes: an alternative The official n8n image illustrates the case of a missing service directory: by default it runs as the node user (UID/GID 1000), with no root startup step. A bind mount declared at /home/node/.n8n without the host directory existing beforehand gets assigned to root by Docker, and n8n immediately fails with an EACCES error on that path. Named volumes avoid this trap by construction. On first start, Docker initializes an empty named volume by copying the content of the corresponding path from the image — permissions included. If the image has already prepared that directory with the right owner, which is the case for the official n8n image's /home/node/.n8n, the named volume inherits that permission directly, with no mkdir -p needed on the host side. This is, in fact, the option n8n's official documentation uses in its own Docker Compose deployment example, which we follow here. services: n8n: volumes: - n8n_data:/home/node/.n8nvolumes: n8n_data: The trade-off: the volume lives at /var/lib/docker/volumes/<project>_n8n_data/_data, a path managed by Docker and readable only by the root account on the host. No direct ls or grep on the logs without going through docker exec or a temporary container — and backing it up requires knowing the right command rather than a plain rsync or tar on an identifiable directory. Docker doesn't offer a native backup command for a named volume. The documented technique is to mount the volume in a temporary container, alongside a host directory, and archive its content there: mkdir -p backups docker run --rm \ -v n8n-stack_n8n_data:/data:ro \ -v $(pwd)/backups:/backup \ alpine tar czf /backup/n8n_data_$(date +%Y%m%d).tar.gz -C /data . The :ro (read-only) mount protects the source volume during the read. Restoring just reverses the operation — tar xzf instead of tar czf, into an empty volume. Unlike a bind mount, which can target either a file or a directory depending on what exists on the host side, a named volume always attaches as a full directory inside the container, with no host path involved at all — its actual storage stays opaque, entirely managed by Docker. There's no way to extract or drop in a single file directly. Retrieving one specific file inside it requires this detour: mount the whole volume in a temporary container, then filter within the tar. In short: bind mount when you want direct, readable access with standard tools (ls, tar, rsync) — even if that means creating the directory before up to avoid the EACCES trap. Named volume when the image has already prepared the right owner for its data directory, as with n8n — backup then requires a temporary container, but you no longer have permissions to manage on the host side.
6. Network isolation The principle: expose only what needs to be exposed n8n needs to be reachable from outside — it's the interface used day to day — so caddy, the reverse proxy that talks to the outside world, must be able to reach it. postgres, on the other hand, has no reason to be reachable from outside: only n8n uses it. Two distinct uses, so two distinct networks:
front: caddy ↔ n8n — lets caddy relay external requests to the n8n UI. data: n8n ↔ postgres — private network, where postgres stays isolated from both caddy and the outside world.
networks: front: # Caddy ↔ n8n data: # n8n ↔ postgres internal: true
services: caddy: networks: [front] ports: - "80:80" - "443:443"
n8n: networks: [front, data] # the only service present on both networks
postgres: networks: [data] # never on the front network Mechanically, this separation holds because Docker Compose creates an isolated bridge network for each entry under networks:. Since caddy is never attached to data, and postgres never to front, they share no network: caddy has neither DNS resolution nor a route to postgres. internal: true on data removes all default outbound routes for that network (host / internet) — a targeted protection, not full isolation. The actual path to postgres remains n8n: it's the only service present on both front and data, so it's the mandatory bridge between the outside world and the database. Securing caddy and n8n therefore matters at least as much as the network segmentation itself — the caddy → n8n → postgres chain stays a valid attack path, segmentation or not. Which is why section 4, secrets/configuration separation, matters: network segmentation limits who can reach whom, but doesn't protect the credentials themselves once a legitimate link in the chain is compromised. Ports: only Caddy needs one ports: publishes a container port to the host — and therefore potentially to the outside world, or to any other process running on the VPS. caddy needs it (80:80, 443:443) because external connections come in through it. n8n and postgres have no ports: entry, and don't need one: inter-service communication doesn't go through the host, it goes through the shared Docker network, where each service is already reachable by the others via its name as a hostname. http://n8n:5678 # from caddy postgres://postgres:5432 # from n8n
No hardcoded IPs. DNS resolution is handled automatically by Docker.
7. Healthchecks and startup order docker compose up starts the containers, but doesn't guarantee that one service is ready before another sends it requests. Typical symptom: Caddy gets its TLS certificate and starts proxying to n8n before n8n has finished its database migrations on first start — result: a few 502s (dial tcp ... connect: connection refused) in the first seconds, which then clear up on their own. Transient, but avoidable. n8n: /healthz/readiness, not /healthz n8n exposes three monitoring routes: /healthz (200 if the instance responds — says nothing about the database's state), /healthz/readiness (200 only if the database is connected and migrations are applied), and /metrics. For a healthcheck that needs to guarantee n8n can actually handle a request, /healthz/readiness is the one to use — /healthz would respond 200 before migrations finish, which wouldn't fix the very race condition we're trying