blog.back_article_list

n8n security best practices for a self-hosted VPS

n8n security best practices for a self-hosted VPS

The last n8n audit I ran on my own instance came back with two findings: one credential that no workflow uses anymore and a workflow that hadn't executed in over 90 days. That's a good result for a box that has been running n8n since the 1.x days, and most of the credit goes to defaults that n8n 2.0 turned on for everyone in December 2025. What follows is the list I work through on any self-hosted n8n install, ordered by how much each item reduces exposure, with the 2.x variable names and the version notes you need if you're upgrading an older setup. The examples assume Docker Compose on Ubuntu 24.04 behind Caddy, but nothing here is Caddy-specific.

Keep port 5678 off the public interface

Publish n8n's port only on the loopback address, or don't publish it at all when the reverse proxy is in the same compose project. With Caddy in the same project the n8n service needs no ports: block whatsoever, since Caddy reaches it as n8n:5678 over the internal Docker network. If your proxy runs on the host instead, bind to 127.0.0.1:

services:
  n8n:
    image: n8nio/n8n:2.38.5
    ports:
      - "127.0.0.1:5678:5678"

The host firewall closes whatever Docker leaves open. If Postgres or Redis live on a second machine, the guide to n8n private networking on a VPS has the rules for the private interface, and those two ports should never be reachable from anywhere else. For a single VPS, UFW with the defaults below is enough:

sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow OpenSSH
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw enable

The n8n VPS template deploys n8n with Docker and Caddy already wired together, so TLS and the loopback binding are done before the first login, and for a hand-built setup the Nginx server block with the websocket upgrade headers is in the n8n Nginx reverse proxy tutorial. Behind either proxy, n8n needs N8N_PROXY_HOPS=1 so it trusts the forwarded headers from exactly one hop, plus N8N_WEBHOOK_URL (the old WEBHOOK_URL name still works but has logged a deprecation warning since 2.35).

Owner account, invited users and 2FA

There is no basic auth in n8n anymore and there hasn't been since 1.0, so a tutorial that shows N8N_BASIC_AUTH_ACTIVE is describing software you can't download. The first browser visit to a fresh instance shows a signup screen where you create the owner account. The password rule is at least eight characters with one number and one capital letter. Go a lot longer than eight and store it in a password manager the same day, because the owner is the only account that can run some of the recovery commands mentioned further down.

Enable two-factor authentication

2FA is free in the community edition. Each user turns it on under Settings > Personal, it uses TOTP so any authenticator app works, and N8N_MFA_ENABLED defaults to true so there is nothing to configure server-side. Forcing every user to enable it is a different feature: that's a security policy controlled by N8N_MFA_ENFORCED_ENABLED, which needs a Business or Enterprise license. On a community instance you ask people nicely and check the user list.

If someone loses their recovery codes, the CLI has a way out. Run it from inside the container as the node user:

docker compose exec -u node n8n n8n mfa:disable [email protected]

Invites, SMTP and single sign-on

Inviting a second user works without SMTP, you copy the invite link from the UI and send it yourself, but password resets don't. SAML, OIDC and LDAP all require a paid self-hosted license, and the comparison of n8n SSO options goes through what each tier unlocks and what a community instance can do with an identity-aware proxy in front of the editor instead. What every instance needs once it has more than one account is mail: set N8N_EMAIL_MODE=smtp with the N8N_SMTP_* variables, otherwise nobody can reset a password.

Security defaults that changed in n8n 2.0

Six settings flipped to a safer default when 2.0 shipped. If you are still on a 1.x compose file with explicit values for any of them, delete your override and let the default apply.

Variable2.x defaultEffect
N8N_BLOCK_ENV_ACCESS_IN_NODEtrue$env is unavailable in Code nodes and expressions
NODES_EXCLUDEExecute Command, Local File TriggerBoth nodes are hidden from the editor
N8N_RESTRICT_FILE_ACCESS_TO~/.n8n-filesRead/Write Files nodes can't leave that directory
N8N_ENFORCE_SETTINGS_FILE_PERMISSIONStrueThe config file holding the encryption key is forced to 0600
N8N_SKIP_AUTH_ON_OAUTH_CALLBACKfalseOAuth credential callbacks need a logged-in session
N8N_GIT_NODE_DISABLE_BARE_REPOStrueThe Git node refuses bare repositories

When the n8n 2.0 breaking changes page and an env var reference table disagree, the breaking changes page is the one that's right. The reference tables on docs.n8n.io still print the 1.x defaults for a few of these, N8N_BLOCK_ENV_ACCESS_IN_NODE among them, and that mismatch has confused more than one upgrade thread on the forum.

Re-enabling Execute Command

NODES_EXCLUDE="[]" brings back every excluded node. I don't do it. Execute Command runs a shell inside the n8n container as the same user that holds the encryption key and the database connection string, and any editor account can use it. When a workflow needs shell access, an SSH node against a separate box with a scoped key does the job with less at stake.

Task runners in external mode for Code node isolation

Every Code node in 2.x executes on a task runner. The default is internal mode, where the runner is a child process of n8n with the same uid and gid, and the docs describe that mode as insecure by design. External mode moves the runner into its own container from the n8nio/runners image, talking to n8n's task broker on port 5679. This is the biggest single change on the list for anyone who lets more than one person write JavaScript in the editor.

services:
  n8n:
    image: n8nio/n8n:2.38.5
    environment:
      - N8N_RUNNERS_MODE=external
      - N8N_RUNNERS_BROKER_LISTEN_ADDRESS=0.0.0.0
      - N8N_RUNNERS_AUTH_TOKEN=${RUNNERS_TOKEN}

  runners:
    image: n8nio/runners:2.38.5-distroless
    user: "65532:65532"
    read_only: true
    tmpfs:
      - /tmp
    environment:
      - N8N_RUNNERS_TASK_BROKER_URI=http://n8n:5679
      - N8N_RUNNERS_AUTH_TOKEN=${RUNNERS_TOKEN}
    depends_on:
      - n8n

The runner tag has to match the n8n tag exactly. The -distroless variant has no shell or package manager inside, the 65532 uid is the nobody user the hardening docs ask for and the read-only root filesystem with a tmpfs on /tmp is the rest of that same recommendation. Port 5679 stays internal to the compose network, nothing publishes it. The one thing this setup removes is N8N_RUNNERS_ENABLED, which was deprecated in 2.0 because runners are always on now; if it's still in your file it does nothing.

Queue mode complicates this a little: each worker container needs its own runners sidecar, so a main plus two workers means three runner containers, each with the same token and its own broker URI pointing at the worker it serves.

SSRF protection for the HTTP Request node

Since 2.12.0 n8n can validate outbound requests from user-controlled nodes and refuse anything that resolves to a private, loopback or link-local address, including after a redirect. It's opt-in:

N8N_SSRF_PROTECTION_ENABLED=true
N8N_SSRF_ALLOWED_HOSTNAMES=ollama,*.internal.example.com
N8N_SSRF_ALLOWED_IP_RANGES=10.0.1.0/24

Turn it on and the first workflow that calls Ollama on a private IP (or a Postgres REST layer on the compose network) will fail with an SSRF error. That's the feature working. The precedence, per the SSRF protection docs, is hostname allowlist first, then IP allowlist, then the blocklist, so an allowed hostname that resolves to a blocked range still goes through. Allowlist those hostnames or ranges instead of turning the protection back off, because the scenario it stops is an editor user pointing the HTTP Request node at the cloud metadata endpoint or at Redis on 6379.

Public API and Swagger

N8N_PUBLIC_API_DISABLED=true and N8N_PUBLIC_API_SWAGGERUI_DISABLED=true. Both default to false. The REST API is authenticated with an X-N8N-API-KEY header, and API key scopes are Enterprise-only, so on a community instance any key is a full-access key. If nothing calls the API, switch it off. An instance that deploys workflows from a GitHub Actions job through the API is the usual reason to keep it on, in which case the key belongs in the repository's secrets with a 90-day expiry set on the n8n side.

Community nodes and the verified flag

Community packages install from npm through Settings > Community Nodes, owner or admin only, and the installer makes you tick a box acknowledging that you're loading unverified code from a public registry. Four variables control this. N8N_COMMUNITY_PACKAGES_ENABLED switches the whole feature, N8N_UNVERIFIED_PACKAGES_ENABLED and N8N_VERIFIED_PACKAGES_ENABLED split it by n8n's vetting status. N8N_COMMUNITY_PACKAGES_PREVENT_LOADING keeps already-installed packages from loading at startup, which is the kill switch for a package you've decided you no longer trust.

On instances where I'm not the only editor I set N8N_UNVERIFIED_PACKAGES_ENABLED=false. Verified nodes are reviewed by n8n, can't have runtime dependencies and since May 2026 have to be published from GitHub Actions with a provenance statement, which is a reasonable floor. Unverified ones are whatever the npm author uploaded. A community node runs inside the main n8n process with full access to the database and the encryption key, so this is the supply chain, and I'd rather read the source of the one node I need than open the door to all of them.

Telemetry and version notifications

N8N_DIAGNOSTICS_ENABLED=false stops the diagnostic events, which per n8n's privacy page include the node types in each workflow graph, execution counts, UI usage and error messages of failed runs (without payloads), sent along with an anonymous instance ID and the instance's IP. N8N_VERSION_NOTIFICATIONS_ENABLED=false stops the version check. I turn off the first and leave the second on, because a banner saying 2.39 is out is how I remember to update, and the version check on its own sends very little.

Encryption key custody

The key that encrypts every stored credential is either the N8N_ENCRYPTION_KEY you set or one n8n generated on first launch and wrote into the config file in the data volume. The guide to rotating the n8n encryption key covers the official rotation feature, what happens when the key is lost and the queue mode rule that every worker carries the same value. The short version for this checklist: set it explicitly, keep a copy outside the server and never let the env value and the file disagree, otherwise n8n refuses to start.

Pinned image versions and a monthly update

n8n ships a minor release most weeks. The image tag stable tracks the newest of those and beta tracks the newest pre-release, and 2.0 renamed them from latest and next, which still resolve. Pin the exact version in compose, currently 2.38.5, and update on a fixed day; mine is the first weekday of each month:

docker compose pull
docker compose down
docker compose up -d
docker compose logs -f n8n

Before that, read the release notes for anything between the version you're on and the version you're going to, because minors can carry deprecations (the WEBHOOK_URL rename in 2.35 was one). If you still run n8n from npm on a VPS, the move to Compose is described in the install n8n on Ubuntu 24.04 walkthrough. Do it before October 2026, when n8n 3.0 arrives with Docker as the only supported way to self-host.

CrowdSec or Fail2ban in front of the proxy

Everything above hardens n8n itself. The proxy still takes the traffic, and a public n8n domain gets scanned constantly, so a bouncer that reads the proxy log and bans repeat offenders at the firewall, the setup in the CrowdSec AppSec with Nginx guide, removes most of that noise before it reaches n8n at all. Mine sees a steady trickle of requests for /webhook-test/ paths that never existed, presumably from a scraped tutorial somewhere, plus the usual WordPress probes against a server that has never run PHP. Fail2ban with a custom filter on the Nginx access log does a cheaper version of the same job.

Rate limiting login attempts at the proxy is a good idea too. n8n's login endpoint is /rest/login, and a limit_req zone on that one location in Nginx costs nothing and turns a credential-stuffing run into a few 429s.

Backups as a security control

A restore you've tested is the recovery path for the ransomware case, the bad upgrade case and the "someone deleted the production workflow" case, and it makes the encryption key question above less scary because the key is part of the set. The n8n backup and restore guide lists what has to be in the set: the database dump, the key, the binary data folder if you use filesystem mode, the compose file and the env file. Encrypt the archive before it leaves the server. A backup of the credentials table plus its key, sitting in plain object storage, is the same thing as the credentials themselves.

Run n8n audit on a schedule

The audit command reports on five categories: credentials, database, filesystem, nodes and instance. The instance section is the one that catches config drift, it flags unprotected webhooks, missing security settings and an outdated version. It's read-only and takes a few seconds:

docker compose exec -u node n8n n8n audit

The abandoned-workflow threshold defaults to 90 days through N8N_SECURITY_AUDIT_DAYS_ABANDONED_WORKFLOW. A cron entry on the host can run it once a month, the day before the update, and diff the output against the previous month's. There's also a POST /audit call on the public API if you'd rather have a workflow do it, though that needs the API enabled, which the section above argued against without a reason.

I haven't measured how long the audit takes on an instance with tens of thousands of stored executions. On mine, with pruning set to two weeks, it's under ten seconds, and I'd expect the credentials and nodes checks to scale with workflow count rather than execution count, but that's a guess.

Automate faster, for less

Bring your winning ideas to life with AMD power, NVMe speed and unmetered bandwidth.