Automate your work with n8n and rootless Podman

n8n 2 on Debian 13, the workflow tool that connects your apps and runs tasks on webhooks and schedules, in rootless Podman with PostgreSQL behind Caddy, with telemetry switched off and the owner account kept yours.

Tested on n8n 2.41.6 on Debian 13 (trixie) on a Melonslab server Updated October 2, 2026

Recommended server for this guide

VC-S Micro · 2 vCPU · 8 GB Memory · 250 GB Storage

Month to month, no lock-in 7-day money-back guarantee

€7.99/mo

Deploy now
On this page

What you will set up

n8n runs workflows: when something happens, it does a series of steps for you. A workflow starts from a trigger, such as a web request (a webhook), a schedule or a new email, and continues with nodes that call other services, change data, write files or run a little JavaScript. n8n has ready-made nodes for hundreds of services, and you build workflows in a browser by connecting boxes.

n8n is developed by n8n GmbH, a company in Berlin, Germany. It is distributed under n8n's own Sustainable Use License, which is source-available rather than an OSI-approved open-source licence: it lets you use and modify n8n for your own internal business purposes or for non-commercial and personal use, and pass it on to others only free of charge for non-commercial purposes; files marked .ee, for the enterprise features, need a paid licence. By default n8n sends usage data, your IP address included, to n8n GmbH, and asks n8n's servers for new versions, templates and lists of nodes; n8n's privacy policy says that some recipients of such data may be in the United States. Step 8 lists what goes where, and the lines in step 3 switch it off.

Here n8n runs as a pod under a user of its own called n8n, behind Caddy from the Podman guide: n8n itself, PostgreSQL for its data, and a separate container for the code that Code nodes run. This is the setup n8n's own production example uses, rather than the SQLite file n8n falls back to on its own.

Every step below was run on a fresh Melonslab VC-P Alloy (2 vCPU, 8 GB) with Debian 13:

  • The owner account was created over an SSH tunnel before n8n could be reached from the internet. Afterwards, the setup page answered Instance owner already setup to everyone.
  • A webhook received a POST from another network and wrote it to a file on the server, and a scheduled workflow wrote the time, in Swedish time, every five minutes. Both kept working after a reboot.
  • With the settings in step 3, n8n made no connections to n8n's servers, from the server or from the browser, while it started, sat idle and was used.
  • n8n saw each visitor's real address: one address that hit the limit for password resets was blocked, while others were not.
  • Workflows could not read files outside their own folder, could not read n8n's settings or keys, and could not reach programs on the server that listen only on 127.0.0.1.
  • The database and the encryption key were restored from a backup, and the saved credentials could be read again.
  • An update from 2.41.5 to 2.41.6 went through with the steps in step 11.

The pod used about 380 MB of memory: n8n 300 to 360 MB, PostgreSQL 15 MB, and the code runner 5 MB until a Code node runs.

Before you start

You need:

  • a server set up as in the Podman guide, with Caddy running, and secured as in the security guide;
  • an A record and an AAAA record for n8n.example.com pointing at your server;
  • SSH access to the server from your own computer.

The examples use 203.0.113.10 for the server and n8n.example.com for its name. Replace them with your own throughout.

1. Create the user

As root:

useradd -m -s /bin/bash n8n
loginctl enable-linger n8n
machinectl shell n8n@

Everything up to step 5 runs as n8n.

2. Create the secrets

openssl rand -hex 32 | tr -d '\n' | podman secret create n8n-encryption-key -
openssl rand -hex 24 | tr -d '\n' | podman secret create n8n-db-password -
openssl rand -hex 32 | tr -d '\n' | podman secret create n8n-runners-token -

The first is n8n's encryption key. n8n encrypts every credential you save, such as API keys and passwords for other services, with it. Without the key, a backup of the database is useless for credentials: they cannot be decrypted, and you have to enter them all again. Generating it yourself, instead of letting n8n make one, means it lives where you can see it and back it up. The second secret is the database password, and the third lets n8n and its code runner recognise each other.

Podman keeps the secrets in ~/.local/share/containers/storage/secrets/filedriver/secretsdata.json, which only n8n can read. To show the key:

podman secret inspect --showsecret --format '{{.SecretData}}' n8n-encryption-key

Copy it into your password manager now. At its first start, n8n also writes the key into /home/node/.n8n/config in its n8n-data volume.

3. Define the pod

mkdir -p ~/.config/containers/systemd ~/files
podman unshare chown 1000:1000 ~/files
cd ~/.config/containers/systemd

~/files is a folder that workflows can read and write. n8n runs as user 1000 in its container, and podman unshare chown gives the folder to that user.

Create n8n.pod:

[Pod]
PodName=n8n
# Only Caddy, on this server, can reach n8n: the port is not open to the internet.
PublishPort=127.0.0.1:8111:5678
# Lets workflows reach this server's own sites, n8n included, through Caddy.
AddHost=n8n.example.com:host-gateway

[Install]
WantedBy=default.target

Create n8n-db.container:

[Container]
ContainerName=n8n-db
Image=docker.io/library/postgres:18-alpine
Pod=n8n.pod
Volume=n8n-db:/var/lib/postgresql
Environment=POSTGRES_USER=n8n POSTGRES_DB=n8n
Secret=n8n-db-password,type=env,target=POSTGRES_PASSWORD
AutoUpdate=registry

[Service]
Restart=always

Create n8n-app.container:

[Unit]
After=n8n-db.service

[Container]
ContainerName=n8n-app
Image=docker.io/n8nio/n8n:2.41.6
Pod=n8n.pod
Volume=n8n-data:/home/node/.n8n
Volume=%h/files:/home/node/.n8n-files
Environment=N8N_HOST=n8n.example.com N8N_PROTOCOL=https N8N_WEBHOOK_URL=https://n8n.example.com/ N8N_PROXY_HOPS=1
Environment=GENERIC_TIMEZONE=Europe/Stockholm TZ=Europe/Stockholm
Environment=DB_TYPE=postgresdb DB_POSTGRESDB_HOST=127.0.0.1 DB_POSTGRESDB_DATABASE=n8n DB_POSTGRESDB_USER=n8n
Environment=N8N_RUNNERS_MODE=external
Environment=N8N_DIAGNOSTICS_ENABLED=false N8N_VERSION_NOTIFICATIONS_ENABLED=false N8N_TEMPLATES_ENABLED=false N8N_HIRING_BANNER_ENABLED=false
Environment=N8N_DISABLED_MODULES=mcp-registry N8N_VERIFIED_PACKAGES_ENABLED=false
Secret=n8n-db-password,type=env,target=DB_POSTGRESDB_PASSWORD
Secret=n8n-encryption-key,type=env,target=N8N_ENCRYPTION_KEY
Secret=n8n-runners-token,type=env,target=N8N_RUNNERS_AUTH_TOKEN

[Service]
Restart=always

And n8n-runners.container:

[Unit]
After=n8n-app.service

[Container]
ContainerName=n8n-runners
Image=docker.io/n8nio/runners:2.41.6
Pod=n8n.pod
Environment=GENERIC_TIMEZONE=Europe/Stockholm TZ=Europe/Stockholm
Secret=n8n-runners-token,type=env,target=N8N_RUNNERS_AUTH_TOKEN

[Service]
Restart=always

What the settings do:

  • N8N_HOST, N8N_PROTOCOL and N8N_WEBHOOK_URL give n8n its public address, so the editor shows webhook addresses with https://n8n.example.com/ instead of http://localhost:5678/. N8N_WEBHOOK_URL replaces the older WEBHOOK_URL, which n8n has called deprecated since 2.35.
  • N8N_PROXY_HOPS=1 tells n8n that one proxy, Caddy, stands in front of it, so it takes each visitor's address from the X-Forwarded-For header that Caddy sets. n8n does not write visitor addresses to its log, but it uses them to limit sign-in attempts and password resets per address. Without this line, every visitor has Caddy's address, and one visitor who reaches a limit locks out everyone (see Troubleshooting).
  • GENERIC_TIMEZONE is the time zone for schedules and dates in workflows, and TZ the one for the container itself.
  • N8N_RUNNERS_MODE=external runs the code of Code nodes in the separate n8n-runners container, as n8n recommends. The other mode, which runs it inside n8n, is deprecated.
  • The two lines with N8N_DIAGNOSTICS_ENABLED and N8N_DISABLED_MODULES stop n8n's connections to n8n's servers. Step 8 explains each one.
  • n8n's sign-in cookie is marked secure by default, so browsers only send it over HTTPS; there is nothing to set for that.

n8n stays on an exact version, because its database changes with most versions and an update should start with a backup (step 11). PostgreSQL follows version 18 by itself through podman-auto-update.timer. Start it:

systemctl --user daemon-reload
systemctl --user start n8n-pod
systemctl --user enable --now podman-auto-update.timer

The images take about 2.1 GB of disk. After the first start, podman logs n8n-app ends with Editor is now accessible via: https://n8n.example.com.

4. Create the owner account over an SSH tunnel

Until an owner account exists, whoever opens n8n first can create it, and that account owns everything. Caddy does not show n8n to anyone yet, so create the account through an SSH tunnel, which only you have.

On your own computer, open a tunnel to n8n's port on the server, and keep it open:

ssh -L 8111:127.0.0.1:8111 root@203.0.113.10

Open http://localhost:8111 in your browser. It shows Set up owner account. Fill in Email, First Name, Last Name and Password (at least 8 characters, with a number and a capital letter), and choose Next. Leave I want to receive security and product updates unticked unless you want email from n8n. n8n then asks a few questions under Customize n8n to you, and opens the editor. Close the tunnel when you are done.

The sign-in cookie is secure, but browsers accept it over http://localhost. We tested this in Chromium.

5. Put Caddy in front

Go back to root with exit, switch to machinectl shell caddy@, and add this block at the end of ~/Caddyfile:

n8n.example.com {
    reverse_proxy 127.0.0.1:8111
}

Restart Caddy with systemctl --user restart caddy, and sign in at https://n8n.example.com.

From another computer, check that nobody else can set up an owner now:

curl -s -X POST https://n8n.example.com/rest/owner/setup -H 'content-type: application/json' \
  -d '{"email":"someone@example.com","firstName":"A","lastName":"B","password":"Password123"}'
{"code":400,"message":"Instance owner already setup"}

6. Receive a webhook

This workflow takes a POST to https://n8n.example.com/webhook/contact, say from a contact form, and saves what was sent as a file in ~/files on the server. Copy this text:

{
  "nodes": [
    {
      "name": "Webhook",
      "type": "n8n-nodes-base.webhook",
      "typeVersion": 2.1,
      "position": [0, 0],
      "parameters": { "httpMethod": "POST", "path": "contact", "options": {} }
    },
    {
      "name": "Convert to File",
      "type": "n8n-nodes-base.convertToFile",
      "typeVersion": 1.1,
      "position": [220, 0],
      "parameters": { "operation": "toJson", "mode": "each", "options": {} }
    },
    {
      "name": "Read/Write Files from Disk",
      "type": "n8n-nodes-base.readWriteFile",
      "typeVersion": 1.1,
      "position": [440, 0],
      "parameters": {
        "operation": "write",
        "fileName": "=/home/node/.n8n-files/contact-{{ $now.toFormat('yyyyMMdd-HHmmss') }}.json",
        "options": {}
      }
    }
  ],
  "connections": {
    "Webhook": { "main": [[{ "node": "Convert to File", "type": "main", "index": 0 }]] },
    "Convert to File": { "main": [[{ "node": "Read/Write Files from Disk", "type": "main", "index": 0 }]] }
  }
}

In n8n, choose Build a workflow (or the + at the top left), click the empty canvas and paste with Ctrl+V. The three nodes appear: Webhook, Convert to File and Read/Write Files from Disk. Click the name My workflow at the top and call it Contact form. Then choose Publish at the top right, and Publish again in the Publish workflow dialog. A workflow only answers webhooks and runs on schedules once it is published.

From your own computer:

curl -X POST https://n8n.example.com/webhook/contact \
  -H 'content-type: application/json' -d '{"name":"Anna","message":"Hello"}'
{"message":"Workflow was started"}

As n8n on the server, ls ~/files shows a new contact-...json file. It holds the body you sent and the request's headers, including x-forwarded-for with your own address. The Executions tab at the top of the workflow lists each run.

7. Run something on a schedule

This workflow adds a line with the time to ~/files/heartbeat.log every five minutes. It also shows that Code nodes work, because a Code node writes the line:

{
  "nodes": [
    {
      "name": "Schedule Trigger",
      "type": "n8n-nodes-base.scheduleTrigger",
      "typeVersion": 1.4,
      "position": [0, 0],
      "parameters": { "rule": { "interval": [{ "field": "minutes", "minutesInterval": 5 }] } }
    },
    {
      "name": "Code",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [220, 0],
      "parameters": { "jsCode": "return [{ json: { line: $now.toFormat('yyyy-MM-dd HH:mm:ss ZZZZ') + '\\n' } }];" }
    },
    {
      "name": "Convert to File",
      "type": "n8n-nodes-base.convertToFile",
      "typeVersion": 1.1,
      "position": [440, 0],
      "parameters": { "operation": "toText", "sourceProperty": "line", "options": {} }
    },
    {
      "name": "Read/Write Files from Disk",
      "type": "n8n-nodes-base.readWriteFile",
      "typeVersion": 1.1,
      "position": [660, 0],
      "parameters": { "operation": "write", "fileName": "/home/node/.n8n-files/heartbeat.log", "options": { "append": true } }
    }
  ],
  "connections": {
    "Schedule Trigger": { "main": [[{ "node": "Code", "type": "main", "index": 0 }]] },
    "Code": { "main": [[{ "node": "Convert to File", "type": "main", "index": 0 }]] },
    "Convert to File": { "main": [[{ "node": "Read/Write Files from Disk", "type": "main", "index": 0 }]] }
  }
}

Paste it into a new workflow, call it Heartbeat, and publish it as in step 6. After five minutes, as n8n:

cat ~/files/heartbeat.log
2026-10-02 19:10:31 GMT+2

GMT+2 is Swedish summer time, from GENERIC_TIMEZONE.

8. Know what it connects to

We watched the server's outbound connections, and the browser's requests, with n8n's default settings and then with the lines from step 3.

With the defaults, n8n contacts these services, all run by n8n GmbH:

  • telemetry.n8n.io: usage data. According to n8n's documentation, this covers your n8n version, some settings, the server's operating system, memory and processors, an anonymous instance ID, the shape of each workflow (which nodes, how they are connected), the domains and paths that HTTP Request nodes call, error messages from failed runs, how the editor is used, and your IP address. It does not cover credentials or the data your workflows handle. The editor in your browser sends its events through your own n8n, which passes them on, and loads its tracking script from cdn-rs.n8n.io.
  • ph.n8n.io: n8n's PostHog service, which the server contacted at start.
  • api.n8n.io: new versions, a "what's new" list and banners (asked by your browser, with your instance ID), workflow templates, the list of community nodes n8n has checked, and a catalogue of MCP servers that the server downloads at every start and then every 8 hours.

The three names point at Cloudflare. n8n's code names the software behind the first two as RudderStack and PostHog; n8n's privacy policy says that some recipients of this data may be outside the EU, in particular in the United States, and does not name them.

The lines in step 3 switch these off:

  • N8N_DIAGNOSTICS_ENABLED=false stops the usage data, N8N_VERSION_NOTIFICATIONS_ENABLED=false the version check, and N8N_TEMPLATES_ENABLED=false the templates. These are the three that n8n's documentation names. N8N_HIRING_BANNER_ENABLED=false removes a job advert that n8n prints in the browser's console. With these four, the browser made no requests outside your own n8n, and telemetry.n8n.io and ph.n8n.io were not contacted.
  • The server still fetched from api.n8n.io at every start, and many times while we used the editor. N8N_DISABLED_MODULES=mcp-registry switches off the MCP server catalogue, and N8N_VERIFIED_PACKAGES_ENABLED=false the list of checked community nodes. According to n8n's documentation, you can still install other community nodes by their npm name; we did not test that.

With all of them, the server made no connections to n8n's servers while it started, stood idle, and while we signed in, built and ran workflows and searched for nodes. The browser made one: the Community nodes page searches the npm package index at api.npms.io when you open it. n8n did not contact its licence server, license.n8n.io, during our tests. Your workflows of course connect to whatever services you set them up to use.

9. Limit what workflows can do

Anyone who can edit workflows can make n8n send requests, read and write files and run code. n8n limits this by default, and we tested what a workflow can reach:

  • Execute Command, the node that runs shell commands, is not loaded by default, and neither is Local File Trigger. Do not enable them: a command would run inside the n8n container, where the encryption key and the database password are in the environment.
  • Code nodes run in the n8n-runners container, where JavaScript has no process object and can only load the crypto and moment modules, so the code cannot read n8n's environment. Expressions that read environment variables, such as {{ $env.N8N_ENCRYPTION_KEY }}, fail with access to env vars denied.
  • Read/Write Files from Disk only reaches /home/node/.n8n-files, which is ~/files on the server. /etc/passwd and n8n's own config answered Access to the file is not allowed.
  • A workflow can reach the internet, PostgreSQL in its own pod (which needs the password it cannot read), and n8n.example.com through Caddy, thanks to the AddHost line. Other sites on this server need an AddHost line each, or their names lead back into the pod. It could not reach a program on the server that listens only on 127.0.0.1, which is how the other app guides publish their ports. It could reach a program that listens on all addresses, at host.containers.internal, even though ufw closes that port to the internet: ufw only filters what comes from outside.
  • Everything runs as the n8n user, which cannot read other users' files on the server, and as user IDs inside the containers that belong to no one else.

To block more nodes, set NODES_EXCLUDE in n8n-app.container. It replaces n8n's default list, so keep the two default entries in it. Write the line with single quotes around all of it:

Environment='NODES_EXCLUDE=["n8n-nodes-base.executeCommand", "n8n-nodes-base.localFileTrigger", "n8n-nodes-base.ssh"]'

Without the single quotes, systemd removes the double quotes, n8n gets a list it cannot read, and Execute Command becomes available. We tested both. After systemctl --user daemon-reload and systemctl --user restart n8n-app, check with podman exec n8n-app printenv NODES_EXCLUDE: the names must be in double quotes.

10. Back up

What you need to restore n8n is the database and the encryption key. As n8n, create ~/backup.sh:

#!/bin/sh
# Dumps n8n's database and saves its encryption key into ~/backup.
set -e
umask 077
cd ~/backup
podman exec n8n-db pg_dump -U n8n -Fc n8n > n8n-db.dump
podman secret inspect --showsecret --format "{{.SecretData}}" n8n-encryption-key > n8n-encryption-key

Run it every night with a timer. Create ~/.config/systemd/user/n8n-backup.service:

[Unit]
Description=Back up n8n's database and encryption key

[Service]
Type=oneshot
ExecStart=%h/backup.sh

And ~/.config/systemd/user/n8n-backup.timer:

[Unit]
Description=Back up n8n every night

[Timer]
OnCalendar=*-*-* 03:00
Persistent=true

[Install]
WantedBy=timers.target
mkdir -m 700 ~/backup
chmod 700 ~/backup.sh
systemctl --user daemon-reload
systemctl --user enable --now n8n-backup.timer
systemctl --user start n8n-backup.service
ls ~/backup

ls shows n8n-db.dump and n8n-encryption-key. The key file opens every saved credential, so keep the backup as safe as the server. To get it off the server every night, along with ~/files, use restic on /home/n8n.

To restore, on a new server or after losing the volumes, do it before step 5, while n8n cannot be reached from the internet. Put the key back, start the pod so that n8n creates its database, stop n8n, and replace the database with the backup:

tr -d '\n' < ~/backup/n8n-encryption-key | podman secret create n8n-encryption-key -
systemctl --user start n8n-pod
systemctl --user stop n8n-app
podman exec -i n8n-db pg_restore -U n8n -d n8n --clean --if-exists < ~/backup/n8n-db.dump
systemctl --user start n8n-app

We ran these steps on the test server after deleting both volumes and the key: the owner account, both workflows and a saved credential came back, the credential could be decrypted, and the webhook answered again.

11. Update

n8n publishes a new minor version most weeks, and recommends updating at least once a month. Use the newest version that is not marked Pre-release on n8n's releases page, which is also what Docker Hub's stable tag points to, and read n8n's release notes for breaking changes. Back up (step 10), then change the version of both n8n images, which must always match, and restart:

~/backup.sh
cd ~/.config/containers/systemd
sed -i 's/:2.41.6$/:2.42.0/' n8n-app.container n8n-runners.container
systemctl --user daemon-reload
systemctl --user restart n8n-pod
podman image rm docker.io/n8nio/n8n:2.41.6 docker.io/n8nio/runners:2.41.6

n8n updates its database when it starts, and its log says Recorded version change: 2.41.6 -> 2.42.0. We tested this from 2.41.5 to 2.41.6. Going back to an older version after that needs the backup.

Troubleshooting

podman pull from docker.n8n.io fails with toomanyrequests: You have reached your unauthenticated pull rate limit. n8n's own registry name passes requests on to Docker Hub, and it hit the limit on our server, while docker.io worked. The runner image is not on docker.n8n.io at all. Use the docker.io/n8nio/... names from step 3.

A webhook answers Cannot POST /webhook/contact. n8n is still starting, which took about 45 seconds after a reboot on our server, or the workflow is not published. Wait for Editor is now accessible in podman logs n8n-app, and check that the workflow shows as published.

Everyone gets Too many requests after one visitor tried too many sign-ins or password resets, and podman logs n8n-app shows ERR_ERL_UNEXPECTED_X_FORWARDED_FOR. N8N_PROXY_HOPS=1 is missing, so n8n counts every visitor as Caddy. Add it as in step 3 and restart n8n. We triggered this on purpose: with the setting, only the address that hit the limit was blocked.

A workflow that calls https://n8n.example.com fails with ECONNREFUSED. Inside the pod, the server's own address belongs to the pod. The AddHost line in n8n.pod sends the name to Caddy instead; check that it has your name, then run systemctl --user daemon-reload and systemctl --user restart n8n-pod.

Execute Command appears in the node list. NODES_EXCLUDE is set without single quotes around the whole line. See step 9.

Read/Write Files from Disk says Access to the file is not allowed. The path is outside /home/node/.n8n-files. Use that path in the node, and look for the file in ~/files on the server.

A file written by a workflow cannot be deleted from ~/files. It belongs to the container's user. As n8n, use podman unshare rm ~/files/name.

Run it on your own server

VC-S Micro

€7.99/mo

vCPU
2
Memory
8 GB
Storage
250 GB
Transfer
10 TB
Standard
HDD · RAID 10
  • Full root access
  • Native /64 IPv6
  • RAID-protected storage
  • Malmö, Sweden
  • Month to month, no lock-in
  • 7-day money-back guarantee
All guides