Self-host n8n
Connect your apps. Let the boring bits run themselves.
Shelley handles the setup. About 15 minutes.
Prefer a terminal?
curl -fsSL https://hub.exe.dev/deploy/n8n/prompt.txt | ssh exe.dev new --prompt=/dev/stdinn8n connects your apps into little chains of “when this happens, do that.” Send new leads to Slack, tidy up a spreadsheet, or summarize your inbox with AI. Your VM keeps things ticking while your laptop naps.
What you get#
- Always-on n8n, with a private editor and webhooks you can make public.
- A Hello webhook, a five-minute Heartbeat, and ready-to-use AI credentials.
- Nightly backups, including the key that unlocks your saved credentials.
- A backup restored and checked before you start.
What's running, and where
https://my-n8n.exe.xyz:5678/
Your editor
https://my-n8n.exe.xyz/webhook/
Workflow endpoints, only after you enable public access
Deploy with Shelley#
Click Deploy and Shelley will set up n8n on a new VM, then check that it works. No config-file wrangling required.
Prefer a terminal?
curl -fsSL \
https://hub.exe.dev/deploy/n8n/prompt.txt \
| ssh exe.dev new --prompt=/dev/stdinWhat Shelley is told
Set up n8n on this VM by following the exe.dev recipe at https://hub.exe.dev/deploy/n8n.md from top to bottom.
The recipe's example VM name is my-n8n. This VM's name is the output of `hostname`: use it wherever the recipe writes my-n8n. Skip the step that creates the VM; you are on it.
You are done when all of these are true. Check each one yourself:
- Always-on n8n, with a private editor and webhooks you can make public.
- A Hello webhook, a five-minute Heartbeat, and ready-to-use AI credentials.
- Nightly backups, including the key that unlocks your saved credentials.
- A backup restored and checked before you start.
Read the whole recipe, including the closed details. For initial setup,
follow "Install by hand" first: containers, example workflows, AI
credentials, backup timer, and scratch restore check. The opening
walkthrough is for the owner afterwards. Do not run the later Restore
or Update instructions during initial setup.
Work as exedev in ~/n8n. Never overwrite an existing ~/n8n/.env: it
holds the encryption key. Check it matches n8n-data/config.
Do not create the owner account; the owner does that in the browser.
Leave public sharing off. Verify the Hello webhook at localhost:8000
and confirm / and /rest/settings return 404 there. Wait up to five
minutes for a successful Heartbeat execution. Check both AI credentials.
Verify docker.service is enabled and all containers use unless-stopped;
do not reboot. Restore the backup only in the scratch directory, check
credentials decrypt, then remove the scratch stack.
Finish with the private editor URL and the OPTIONAL command the owner
can run on their own computer to allow public webhooks:
ssh exe.dev share set-public my-n8n
Finish with the URLs I should open.
Also at /deploy/n8n/prompt.txt. The recipe it follows is /deploy/n8n.md.
Open the editor#
When Shelley finishes, open the editor URL it gives you. It'll look like
https://my-n8n.exe.xyz:5678/.
Sign in to exe.dev, then create your n8n owner account. Two logins, two locks. Turn on two-factor authentication in Settings → Personal, too.
Make something happen#
You'll find two starter workflows waiting for you:
- Hello webhook echoes back whatever you send it. Try it below.
- Heartbeat runs every five minutes. Open Executions to see it ticking, then replace its Code node with something useful—or unpublish it.
Ready for your own? Follow n8n's first-workflow tutorial or pick a template. Save, Publish, and off it goes. Check the workflow's time zone before relying on a schedule; this setup starts in UTC.
Let the outside world in#
Only using schedules? Skip this bit. To accept calls from other apps, you can make your webhook, form, and MCP endpoints public. The editor stays private. Anyone with an unprotected endpoint's URL can call it, so add Authentication to Webhook nodes that do real work.
On your computer, using your VM's name:
ssh exe.dev share set-public my-n8n
curl -X POST https://my-n8n.exe.xyz/webhook/hello \
-H 'Content-Type: application/json' -d '{"hello":"world"}'
You'll get "ok":true and your message back. Hello, automation.
To close the door again: ssh exe.dev share set-private my-n8n.
Keep your work#
Backups run nightly into ~/n8n-backups. Keep a copy off the VM—a
backup on a lost VM won't help much. On your computer:
scp -r my-n8n.exe.xyz:n8n-backups .
Keep these files private: they include the key for your saved credentials.
Don't replace ~/n8n/.env; losing that key makes those credentials unreadable.
Before an update, take a backup. Ask Shelley to help, or use the steps
below. Something stuck? On the VM, try
cd ~/n8n && docker compose logs --tail=100.
The nuts and bolts#
Happy clicking Deploy? You can stop here. These are the same setup steps Shelley follows, plus the maintenance commands for later.
Install by hand
Create a VM#
On your computer (pick another name if my-n8n is taken):
ssh exe.dev new --name=my-n8n
ssh my-n8n.exe.xyz
All remaining installation commands run on the VM, as exedev.
Commands use hostname, so your VM can have any name.
Install n8n with PostgreSQL#
Enable Docker at boot; otherwise exe.dev's on-demand Docker won't restart
these containers after a reboot. Install jq if needed:
sudo systemctl enable --now docker.service
systemctl is-enabled docker.service
command -v jq >/dev/null || sudo apt-get install -y jq
The check should print enabled. Create the directories and generate the
secrets once. This leaves an existing .env alone and pins n8n and its
runners to the same stable 2.x release:
mkdir -p ~/n8n/n8n-data ~/n8n/postgres ~/n8n/workflows
cd ~/n8n
if [ ! -e .env ]; then
N8N_VERSION=$(curl -fsSL https://registry.npmjs.org/-/package/n8n/dist-tags | jq -r .stable)
case "$N8N_VERSION" in 2.*) ;; *) N8N_VERSION=2.43.5 ;; esac
touch .env && chmod 600 .env
cat >> .env <<EOF
# n8n settings and secrets. Keep a copy somewhere safe: without
# N8N_ENCRYPTION_KEY, the credentials saved in n8n can't be decrypted.
N8N_VERSION=$N8N_VERSION
N8N_HOSTNAME=$(hostname).exe.xyz
GENERIC_TIMEZONE=UTC
N8N_ENCRYPTION_KEY=$(openssl rand -hex 32)
POSTGRES_PASSWORD=$(openssl rand -hex 24)
N8N_RUNNERS_AUTH_TOKEN=$(openssl rand -hex 32)
EOF
fi
grep ^N8N_VERSION= .env
The last line confirms the version. Schedules default to UTC; change
GENERIC_TIMEZONE in .env if you'd like a different default.
Create the four-service Compose stack:
cat > ~/n8n/compose.yaml <<'EOF'
# n8n + PostgreSQL + task runners + a webhook-only proxy.
# Secrets and versions come from .env in this directory.
name: n8n
services:
postgres:
image: postgres:18
restart: unless-stopped
environment:
POSTGRES_USER: n8n
POSTGRES_DB: n8n
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes:
- ./postgres:/var/lib/postgresql
healthcheck:
test: ["CMD-SHELL", "pg_isready -U n8n -d n8n"]
interval: 5s
timeout: 5s
retries: 10
n8n:
image: docker.n8n.io/n8nio/n8n:${N8N_VERSION}
restart: unless-stopped
ports:
- "5678:5678"
environment:
DB_TYPE: postgresdb
DB_POSTGRESDB_HOST: postgres
DB_POSTGRESDB_DATABASE: n8n
DB_POSTGRESDB_USER: n8n
DB_POSTGRESDB_PASSWORD: ${POSTGRES_PASSWORD}
N8N_ENCRYPTION_KEY: ${N8N_ENCRYPTION_KEY}
N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS: "true"
N8N_EDITOR_BASE_URL: https://${N8N_HOSTNAME}:5678/
N8N_WEBHOOK_URL: https://${N8N_HOSTNAME}/
N8N_PROXY_HOPS: "1"
GENERIC_TIMEZONE: ${GENERIC_TIMEZONE}
TZ: ${GENERIC_TIMEZONE}
N8N_RUNNERS_MODE: external
N8N_RUNNERS_AUTH_TOKEN: ${N8N_RUNNERS_AUTH_TOKEN}
N8N_RUNNERS_BROKER_LISTEN_ADDRESS: 0.0.0.0
volumes:
- ./n8n-data:/home/node/.n8n
depends_on:
postgres:
condition: service_healthy
runners:
image: n8nio/runners:${N8N_VERSION}
restart: unless-stopped
environment:
N8N_RUNNERS_TASK_BROKER_URI: http://n8n:5679
N8N_RUNNERS_AUTH_TOKEN: ${N8N_RUNNERS_AUTH_TOKEN}
depends_on:
- n8n
webhooks:
image: nginx:stable-alpine
restart: unless-stopped
ports:
- "8000:80"
volumes:
- ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
depends_on:
- n8n
EOF
exe.dev's HTTPS proxy handles TLS and login. The editor is private on port 5678; this nginx configuration exposes only workflow endpoints on port 8000. It preserves the forwarding headers and rejects non-workflow paths, including encoded paths that try to bypass the check.
cat > ~/n8n/nginx.conf <<'EOF'
# Port 8000 is the only port exe.dev can make public. Serve n8n's
# webhook, form, and MCP endpoints there, and nothing else.
server {
listen 80 default_server;
server_tokens off;
# Look n8n up at request time, so a recreated container is found.
resolver 127.0.0.11 valid=10s ipv6=off;
set $n8n http://n8n:5678;
client_max_body_size 200m;
proxy_http_version 1.1;
proxy_buffering off;
chunked_transfer_encoding off;
proxy_read_timeout 300s;
proxy_set_header Connection "";
proxy_set_header Host $host;
# Pass exe.dev's forwarding headers through unchanged, so n8n sees
# one proxy (exe.dev) here and on the editor port: N8N_PROXY_HOPS=1.
proxy_set_header X-Forwarded-For $http_x_forwarded_for;
proxy_set_header X-Forwarded-Host $http_x_forwarded_host;
proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto;
location ~ ^/(webhook|webhook-test|webhook-waiting|form|form-test|form-waiting|mcp|mcp-test)/ {
# Check the raw path too, so encoded tricks like
# /rest/x/..%2F..%2Fwebhook/ can't reach anything else.
if ($request_uri !~ ^/(webhook|webhook-test|webhook-waiting|form|form-test|form-waiting|mcp|mcp-test)/) {
return 404;
}
proxy_pass $n8n;
}
location / {
return 404;
}
}
EOF
Start the stack and wait for {"status":"ok"}. If this takes more than
five minutes, stop the loop with Ctrl-C and check the logs.
cd ~/n8n
docker compose up -d
until curl -fs http://localhost:5678/healthz/readiness; do sleep 5; done; echo
docker compose ps
All four services should be Up, with PostgreSQL healthy. Confirm its
workflow, credential, and execution tables exist:
cd ~/n8n
docker compose exec postgres psql -U n8n -d n8n -c '\dt' | grep -E 'credentials_entity|execution_entity|workflow_entity'
Keep the encryption key safe#
The key encrypts saved credentials, not the whole database. Keep
.env in your password manager as well as your backups. Check both
copies of the key agree:
cd ~/n8n
stat -c '%a %n' .env n8n-data/config
[ "$(jq -r .encryptionKey n8n-data/config)" = "$(sed -n 's/^N8N_ENCRYPTION_KEY=//p' .env)" ] && echo "keys match"
Expect 600 for each file and keys match. Never generate a replacement
key for an existing installation.
Add a webhook and a schedule#
Create Hello webhook, which echoes a POST request's JSON:
cat > ~/n8n/workflows/hello-webhook.json <<'EOF'
{
"id": "helloWebhook0001",
"name": "Hello webhook (example)",
"nodes": [
{
"id": "9a3b5f0e-4c1d-4e8a-9b7c-1f2e3d4c5b6a",
"name": "Webhook",
"type": "n8n-nodes-base.webhook",
"typeVersion": 2,
"position": [0, 0],
"webhookId": "5d0f3c8e-2b7a-4f6e-8c1d-3a9b7e5f2c40",
"parameters": {
"httpMethod": "POST",
"path": "hello",
"responseMode": "responseNode",
"options": {}
}
},
{
"id": "1c2d3e4f-5a6b-4c7d-8e9f-0a1b2c3d4e5f",
"name": "Respond to Webhook",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"position": [240, 0],
"parameters": {
"respondWith": "json",
"responseBody": "={{ JSON.stringify({ ok: true, received: $json.body, at: $now.toISO() }) }}",
"options": {}
}
}
],
"connections": {
"Webhook": {
"main": [[{ "node": "Respond to Webhook", "type": "main", "index": 0 }]]
}
},
"settings": { "executionOrder": "v1" },
"active": false
}
EOF
Create Heartbeat, which also tests JavaScript execution in the task runner:
cat > ~/n8n/workflows/heartbeat-schedule.json <<'EOF'
{
"id": "heartbeatSched01",
"name": "Heartbeat every 5 minutes (example)",
"nodes": [
{
"id": "7e6d5c4b-3a29-4180-9f7e-6d5c4b3a2918",
"name": "Every 5 minutes",
"type": "n8n-nodes-base.scheduleTrigger",
"typeVersion": 1.2,
"position": [0, 0],
"parameters": {
"rule": { "interval": [{ "field": "minutes", "minutesInterval": 5 }] }
}
},
{
"id": "2b3c4d5e-6f70-4812-93a4-b5c6d7e8f901",
"name": "Heartbeat",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [240, 0],
"parameters": {
"jsCode": "return [{ json: { heartbeat: new Date().toISOString() } }];"
}
}
],
"connections": {
"Every 5 minutes": {
"main": [[{ "node": "Heartbeat", "type": "main", "index": 0 }]]
}
},
"settings": { "executionOrder": "v1" },
"active": false
}
EOF
Import, publish, and restart so n8n picks them up. If the owner account isn't set up yet, these workflows become yours when you create it.
cd ~/n8n
docker compose cp workflows n8n:/tmp/
docker compose exec n8n n8n import:workflow --separate --input=/tmp/workflows
docker compose exec n8n n8n publish:workflow --id=helloWebhook0001
docker compose exec n8n n8n publish:workflow --id=heartbeatSched01
docker compose restart n8n
until curl -fs http://localhost:5678/healthz/readiness; do sleep 5; done; echo
Check the webhook through the proxy:
curl -s -X POST http://localhost:8000/webhook/hello \
-H 'Content-Type: application/json' -d '{"name":"exe.dev"}'; echo
Expect {"ok":true,"received":{"name":"exe.dev"},"at":"..."}.
Both the editor and its API must return 404 on this port:
curl -s -o /dev/null -w '%{http_code}\n' http://localhost:8000/
curl -s -o /dev/null -w '%{http_code}\n' http://localhost:8000/rest/settings
Within five minutes, check for a Heartbeat execution with mode trigger
and status success:
cd ~/n8n
docker compose exec postgres psql -U n8n -d n8n -c 'SELECT e.id, w.name, e.mode, e.status, e."startedAt" FROM execution_entity e JOIN workflow_entity w ON w.id = e."workflowId" ORDER BY e.id DESC LIMIT 5;'
Use AI models without an API key#
Check the VM's LLM integration:
curl -s https://llm.int.exe.xyz/v1/models | jq '.data | length'
A number means it's connected; otherwise follow the integration's setup instructions first. Import these credentials. The key is a placeholder: exe.dev authenticates the VM, and model usage is billed through the integration.
cat > /tmp/exe-llm-credentials.json <<'EOF'
[
{
"id": "exeLlmOpenAi0001",
"name": "exe.dev LLM (OpenAI API)",
"type": "openAiApi",
"data": { "apiKey": "exe-llm-integration", "url": "https://llm.int.exe.xyz/v1" }
},
{
"id": "exeLlmClaude0001",
"name": "exe.dev LLM (Anthropic API)",
"type": "anthropicApi",
"data": { "apiKey": "exe-llm-integration", "url": "https://llm.int.exe.xyz" }
}
]
EOF
cd ~/n8n
docker compose cp /tmp/exe-llm-credentials.json n8n:/tmp/
docker compose exec n8n n8n import:credentials --input=/tmp/exe-llm-credentials.json
The matching OpenAI and Anthropic model nodes can now use these credentials.
Set up nightly backups#
This script saves the database, .env, n8n data, and configuration
in one private archive. It keeps the 14 newest backups.
cat > ~/n8n/backup.sh <<'EOF'
#!/usr/bin/env bash
# Back up n8n into one tarball: a PostgreSQL dump, .env (with the
# encryption key), the Compose files, and n8n's data directory.
set -euo pipefail
umask 077
cd "$(dirname "$0")"
dest="$HOME/n8n-backups"
mkdir -p "$dest"
work=$(mktemp -d "$dest/.tmp-XXXXXX")
trap 'rm -rf "$work"' EXIT
docker compose exec -T postgres pg_dump -U n8n -d n8n --format=custom > "$work/n8n.dump"
cp -a .env compose.yaml nginx.conf backup.sh workflows n8n-data "$work/"
out="$dest/n8n-$(date -u +%Y%m%d-%H%M%S).tar.gz"
tar -czf "$out.part" -C "$work" .
chmod 600 "$out.part"
mv "$out.part" "$out"
# Keep the 14 newest backups.
ls -1t "$dest"/n8n-*.tar.gz | tail -n +15 | xargs -r rm --
echo "$out"
EOF
chmod +x ~/n8n/backup.sh
Schedule it, then take a first backup:
sudo tee /etc/systemd/system/n8n-backup.service >/dev/null <<EOF
[Unit]
Description=Back up n8n (database, .env, data) to ~/n8n-backups
Requires=docker.service
After=docker.service
[Service]
Type=oneshot
User=$USER
ExecStart=$HOME/n8n/backup.sh
EOF
sudo tee /etc/systemd/system/n8n-backup.timer >/dev/null <<'EOF'
[Unit]
Description=Back up n8n every night
[Timer]
OnCalendar=*-*-* 03:30:00
RandomizedDelaySec=30m
Persistent=true
[Install]
WantedBy=timers.target
EOF
sudo systemctl daemon-reload
sudo systemctl enable --now n8n-backup.timer
sudo systemctl start n8n-backup.service
systemctl list-timers n8n-backup.timer
ls -lh ~/n8n-backups
Check that the timer has a next run and the directory contains a tarball.
For failures: journalctl -u n8n-backup.service.
Test a backup#
Restore the newest backup into a scratch stack, not the running one, and check that its credentials still decrypt:
latest=$(ls -1t ~/n8n-backups/n8n-*.tar.gz | head -n 1)
mkdir -p /tmp/n8n-restore-test/postgres
tar -xzf "$latest" -C /tmp/n8n-restore-test
cd /tmp/n8n-restore-test
docker compose -p n8n-restore-test up -d --wait postgres
docker compose -p n8n-restore-test exec -T postgres pg_restore -U n8n -d n8n --no-owner --exit-on-error < n8n.dump
docker compose -p n8n-restore-test run --rm --no-deps n8n list:workflow
docker compose -p n8n-restore-test run --rm --no-deps -T n8n export:credentials --all --decrypted 2>/dev/null \
| tail -n 1 | jq -r '.[] | "\(.name): decrypted"'
Expect the workflow names and a decrypted line for each credential,
including both LLM credentials. Remove only this scratch copy afterwards
(and before repeating the test):
cd /tmp/n8n-restore-test && docker compose -p n8n-restore-test down
cd ~ && sudo rm -rf /tmp/n8n-restore-test
Installation done. Leave owner-account creation and public sharing to the owner; continue with Open the editor.
Back up and restore
On the VM, ~/n8n/backup.sh makes an extra backup and prints its path.
Each archive includes the database, data directory, and encryption key;
restore them together. Copy backups off the VM as shown above.
Restore#
On a new VM, enable Docker and install jq as in
Install. Create ~/n8n-backups there, then
copy your backup over from your computer:
scp n8n-20261014-033012.tar.gz my-n8n.exe.xyz:n8n-backups/
Use your backup's actual filename in this and the commands below. All remaining commands run on the VM. If n8n already runs there, stop it and keep its directory aside first:
cd ~/n8n && docker compose down
mv ~/n8n ~/n8n.before-restore
Unpack the backup and set this VM's hostname:
mkdir ~/n8n
tar -xzf ~/n8n-backups/n8n-20261014-033012.tar.gz -C ~/n8n
cd ~/n8n
sed -i "s/^N8N_HOSTNAME=.*/N8N_HOSTNAME=$(hostname).exe.xyz/" .env
Load the database before starting n8n:
cd ~/n8n
mkdir -p postgres
docker compose up -d --wait postgres
docker compose exec -T postgres pg_restore -U n8n -d n8n --no-owner --exit-on-error < n8n.dump
docker compose up -d
until curl -fs http://localhost:5678/healthz/readiness; do sleep 5; done; echo
Expect {"status":"ok"}. Repeat the key-match and local webhook checks
from installation. On a new VM, recreate the backup timer too, and enable
public sharing only if you need it. Keep ~/n8n.before-restore until
you're happy everything is back.
Update n8n
Pick a stable 2.x version from n8n's
release notes. On the VM, back up,
then edit only N8N_VERSION in .env; both n8n and its runners use it:
cd ~/n8n
./backup.sh
nano .env
docker compose pull
docker compose up -d
until curl -fs http://localhost:5678/healthz/readiness; do sleep 5; done; echo
docker compose exec n8n n8n --version
Check the reported version and try your workflows. To roll back, restore the backup—changing the image tag alone won't undo database migrations. Read the upgrade guide before changing n8n or PostgreSQL to a new major version.
Something not working?
On the VM:
cd ~/n8n
docker compose logs --tail=100 n8n runners postgres webhooks
- The main URL returns 404: expected! Open the editor at
https://my-n8n.exe.xyz:5678/; the main URL serves workflow endpoints only. - Webhook “not registered”: publish the workflow and check you're using POST and its Production URL. A Test URL needs a listening test run.
- Encryption-key errors: restore
.env,n8n-data, and the database from the same backup. Don't generate a new key. - Code nodes fail: check runner logs. Its version and auth token must match n8n's.
- Down after reboot: run
sudo systemctl enable --now docker.service. EACCESon/home/node/.n8n: fix data ownership withsudo chown -R 1000:1000 ~/n8n/n8n-data.
Frequently asked questions#
Is n8n free?
The Community edition is free to self-host. You pay for your VM; see exe.dev plans. AI model usage is separate.
Can I use it at work?
Yes, for internal business use. Offering n8n itself to customers is a different licensing question; check n8n's Sustainable Use License.
Can I use AI models?
Yes. Pick the included exe.dev LLM (OpenAI API) or exe.dev LLM (Anthropic API) credential in its matching model node. No provider key to paste; usage is billed through your LLM integration.
Can I use my own domain?
Yes. Ask Shelley to connect your custom domain and update n8n's webhook address. The editor can stay private at its existing URL.
Updated Oct 14, 2026 · Markdown · manifest.json · Something wrong? Tell us