---
title: "Self-host n8n 3.0"
description: "Try the n8n 3.0 beta on exe.dev, or upgrade your n8n 2.x VM with a backup first. Private editor, PostgreSQL, task runners, and public webhooks."
url: https://exe.dev/deploy/n8n-3
category: "Automation"
updated: 2026-10-30
---

# Self-host n8n 3.0

The next n8n on its own VM. Try 3.0 before it's stable.

- **Software:** n8n 3.0 (https://n8n.io)
- **Source:** https://github.com/n8n-io/n8n
- **License:** Sustainable Use License
- **Time:** 15 minutes
- **Example VM name:** `my-n8n-3` (yours will differ; on the VM, `hostname` prints it)
- **Machine-readable:** https://exe.dev/deploy/n8n-3/manifest.json

## What you get

- n8n 3.0 on its own VM, 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.

Running on the VM:

- **n8n 3.0 beta**: Workflow editor and engine, port 5678, data in `~/n8n/n8n-data`
- **Task runners**: JavaScript and Python, one minute per Code node run
- **PostgreSQL 18**: Workflows, credentials, encryption keys, and history, data in `~/n8n/postgres`
- **Webhook proxy**: nginx, keeping the editor off the public URL, port 8000

How it is reached:

- https://my-n8n-3.exe.xyz:5678/ (private): Your editor
- https://my-n8n-3.exe.xyz/webhook/ (public): Workflow endpoints, only after you enable public access

## Deploy with Shelley

Open https://exe.dev/new?prompt=Set%20up%20n8n%203.0%20on%20this%20VM%20by%20following%20the%20exe.dev%20recipe%20at%20https%3A%2F%2Fexe.dev%2Fdeploy%2Fn8n-3.md%20from%20top%20to%20bottom.%0A%0AThe%20recipe%27s%20example%20VM%20name%20is%20my-n8n-3.%20This%20VM%27s%20name%20is%20the%20output%20of%20%60hostname%60%3A%20use%20it%20wherever%20the%20recipe%20writes%20my-n8n-3.%20Skip%20the%20step%20that%20creates%20the%20VM%3B%20you%20are%20on%20it.%0A%0AYou%20are%20done%20when%20all%20of%20these%20are%20true.%20Check%20each%20one%20yourself%3A%0A-%20n8n%203.0%20on%20its%20own%20VM%2C%20with%20a%20private%20editor%20and%20webhooks%20you%20can%20make%20public.%0A-%20A%20Hello%20webhook%2C%20a%20five-minute%20Heartbeat%2C%20and%20ready-to-use%20AI%20credentials.%0A-%20Nightly%20backups%2C%20including%20the%20key%20that%20unlocks%20your%20saved%20credentials.%0A-%20A%20backup%20restored%20and%20checked%20before%20you%20start.%0A%0ARead%20the%20whole%20recipe%2C%20including%20the%20closed%20details.%20For%20initial%20setup%2C%0Afollow%20%22Install%20by%20hand%22%20first%3A%20containers%2C%20example%20workflows%2C%20AI%0Acredentials%2C%20backup%20timer%2C%20and%20scratch%20restore%20check.%20The%20opening%0Awalkthrough%20is%20for%20the%20owner%20afterwards.%20Do%20not%20run%20the%20Upgrade%2C%20Restore%2C%0Aor%20Update%20instructions%20during%20initial%20setup.%0AWork%20as%20exedev%20in%20~%2Fn8n.%20Never%20overwrite%20an%20existing%20~%2Fn8n%2F.env%3A%20it%0Aholds%20the%20encryption%20key.%20Check%20it%20matches%20n8n-data%2Fconfig%2C%20and%20that%0AN8N_VERSION%20is%203.x.%0ADo%20not%20create%20the%20owner%20account%3B%20the%20owner%20does%20that%20in%20the%20browser.%0ALeave%20public%20sharing%20off.%20Verify%20the%20Hello%20webhook%20at%20localhost%3A8000%0Aand%20confirm%20%2F%20and%20%2Frest%2Fsettings%20return%20404%20there.%20Wait%20up%20to%20five%0Aminutes%20for%20a%20successful%20Heartbeat%20execution.%20Check%20both%20AI%20credentials.%0AVerify%20docker.service%20is%20enabled%20and%20all%20containers%20use%20unless-stopped%3B%0Ado%20not%20reboot.%20Restore%20the%20backup%20only%20in%20the%20scratch%20directory%2C%20check%0Acredentials%20decrypt%2C%20then%20remove%20the%20scratch%20stack.%0AFinish%20with%20the%20private%20editor%20URL%2C%20a%20reminder%20that%20n8n%203.0%20is%20a%20beta%2C%0Aand%20the%20OPTIONAL%20command%20the%20owner%20can%20run%20on%20their%20own%20computer%20to%0Aallow%20public%20webhooks%3A%0Assh%20exe.dev%20share%20set-public%20my-n8n-3%0A%0AFinish%20with%20the%20URLs%20I%20should%20open.%0A to create a VM whose agent, Shelley, follows this recipe. From a terminal:

```sh
curl -fsSL https://exe.dev/deploy/n8n-3/prompt.txt | ssh exe.dev new --prompt=/dev/stdin
```

Or follow the steps below by hand.

> [!WARNING]
> **n8n 3.0 is a beta, not a stable release yet.** n8n expects it to be
> stable in early December 2026. For the stable 2.x setup, see
> [Self-host n8n](/deploy/n8n-2).

n8n connects your apps into little chains of “when this happens, do that.”
Version 3.0 clears out old nodes, gives Code nodes a one-minute limit, and
rotates the keys behind your saved credentials. Try it on a fresh VM, or
upgrade the VM you set up with [our n8n 2.x recipe](/deploy/n8n-2).

## Open the editor

When Shelley finishes, open the editor URL it gives you. It'll look like
`https://my-n8n-3.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](https://docs.n8n.io/build-your-first-workflow)
or pick a [template](https://n8n.io/workflows/). Save, **Publish**, and off it goes.
Older templates may use nodes that 3.0 removed; n8n won't publish those.
Schedules start in UTC, and a Code node gets one minute per run.

## 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:

```bash
ssh exe.dev share set-public my-n8n-3
curl -X POST https://my-n8n-3.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-3`.

## 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:

```bash
scp -r my-n8n-3.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.
Something stuck? On the VM, try `cd ~/n8n && docker compose logs --tail=100`.

## Coming from n8n 2.x?

Upgrading is one way: 3.0 migrates the database and changes how
credentials are encrypted, so the only way back is a backup. Before you start:

- **Check your workflows.** On 2.x, open **Settings → Migration Report**.
  It lists workflows that will break, such as ones using the removed
  Function, Cron, Interval, or Item Lists nodes, and can migrate some for
  you. Removed nodes stop a workflow from running on 3.0.
- **Mind the one-minute limit.** Code nodes that need longer fail on 3.0
  unless you [raise the limit](#install-n8n-with-postgresql).
- **Slack Trigger** workflows need the app's signing secret in their
  Slack credential, or they stop.

Then take a backup and upgrade, as below. Your credentials, workflows,
and execution history come along. For everything else, read n8n's
[breaking changes](https://docs.n8n.io/changelog/v30-breaking-changes).

## The nuts and bolts

Happy clicking Deploy? You can stop here. These are the same setup steps
Shelley follows, plus the upgrade and maintenance commands for later.

<details id="create-the-vm" data-manual-setup class="dp-details">
<summary>Install by hand</summary>

### Create a VM

On your computer (pick another name if `my-n8n-3` is taken):

```bash
ssh exe.dev new --name=my-n8n-3
ssh my-n8n-3.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:

```bash
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 3.x release. During the beta that's the newest 3.x
image on Docker Hub; n8n's `latest` and `beta` tags still mean 2.x.

```bash
mkdir -p ~/n8n/n8n-data ~/n8n/postgres ~/n8n/workflows
cd ~/n8n
if [ ! -e .env ]; then
  N8N_VERSION=$(curl -fsSL 'https://hub.docker.com/v2/repositories/n8nio/runners/tags?page_size=100&name=3.' \
    | jq -r '[.results[].name | select(test("^3\\.[0-9]+\\.[0-9]+$"))] | sort_by(split(".") | map(tonumber)) | last // empty')
  case "$N8N_VERSION" in 3.*) ;; *) N8N_VERSION=3.0.1 ;; 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: `3.0.1` or newer, never 4.x. Schedules
default to UTC; change `GENERIC_TIMEZONE` in `.env` if you'd like a
different default.

Create the four-service Compose stack:

```bash
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
      N8N_RUNNERS_TASK_TIMEOUT: ${N8N_RUNNERS_TASK_TIMEOUT:-60}
    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}
      N8N_RUNNERS_TASK_TIMEOUT: ${N8N_RUNNERS_TASK_TIMEOUT:-60}
    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
```

n8n 3.0 stops a Code node after 60 seconds. Both n8n and the runners
read the limit, so it lives in one place: to allow five minutes, add
`N8N_RUNNERS_TASK_TIMEOUT=300` to `.env` and run `docker compose up -d`.

exe.dev's [HTTPS proxy](/docs/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.

```bash
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](#troubleshooting).

```bash
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:

```bash
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:

```bash
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:

```bash
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:

```bash
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.

```bash
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:

```bash
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:

```bash
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`:

```bash
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](/docs/integrations-llm):

```bash
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.

```bash
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.

```bash
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:

```bash
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:

```bash
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):

```bash
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](#open-the-editor).

</details>

<details id="upgrade-from-2x" class="dp-details">
<summary>Upgrade a 2.x VM to 3.0</summary>

These steps upgrade a VM set up with [our n8n 2.x recipe](/deploy/n8n-2),
here called `my-n8n`. Clear the Migration Report first, as described
[above](#coming-from-n8n-2x).

### Update to the latest 2.x

n8n recommends upgrading from the newest 2.x release; its Migration
Report knows the most. On the VM:

```bash
cd ~/n8n
grep ^N8N_VERSION= .env
curl -fsSL https://registry.npmjs.org/-/package/n8n/dist-tags | jq -r .stable
```

If the two versions differ, follow the 2.x recipe's
[Update n8n](/deploy/n8n-2#update) steps with the second one, then check
the Migration Report again.

### Back up

Take a fresh backup, and copy it **off the VM**. It is your only way back
to 2.x. On the VM:

```bash
~/n8n/backup.sh
```

On your computer:

```bash
scp -r my-n8n.exe.xyz:n8n-backups .
```

Note the newest file's name, such as `n8n-20261030-210512.tar.gz`. It
holds the database, your data, and `.env` with its 2.x `N8N_VERSION`.

> [!TIP]
> Want a dress rehearsal? `ssh exe.dev cp my-n8n my-n8n-rehearsal` copies
> the whole VM; upgrade the copy first. Its published workflows run too,
> so delete it when you're done: `ssh exe.dev rm my-n8n-rehearsal`.

### Upgrade

On the VM, pin the newest 3.x release and restart with it. Migrations
take a minute or so; don't stop n8n while they run.

```bash
cd ~/n8n
v=$(curl -fsSL 'https://hub.docker.com/v2/repositories/n8nio/runners/tags?page_size=100&name=3.' \
  | jq -r '[.results[].name | select(test("^3\\.[0-9]+\\.[0-9]+$"))] | sort_by(split(".") | map(tonumber)) | last // empty')
case "$v" in 3.*) ;; *) v=3.0.1 ;; esac
sed -i "s/^N8N_VERSION=.*/N8N_VERSION=$v/" .env
grep ^N8N_VERSION= .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
```

Expect `{"status":"ok"}` and the 3.x version. Look for workflows that
didn't start, and check that saved credentials still decrypt:

```bash
cd ~/n8n
docker compose logs n8n | grep -A1 'cannot load'
docker compose exec -T n8n n8n export:credentials --all --decrypted 2>/dev/null \
  | tail -n 1 | jq -r '.[] | "\(.name): decrypted"'
```

No output from the first command is good news. Otherwise it names the
removed nodes, and the Migration Report lists the workflows using them.
Replace those nodes (Function becomes Code, Cron and Interval become
Schedule Trigger), then publish each workflow again. Then run
`~/n8n/backup.sh` for a first 3.0 backup.

Code nodes now stop after a minute. To raise the limit, replace
`compose.yaml` with [the one above](#install-n8n-with-postgresql), add
`N8N_RUNNERS_TASK_TIMEOUT=300` to `.env`, and run `docker compose up -d`.

### Go back to 2.x

Changing `N8N_VERSION` back isn't supported. Instead,
[restore](#restore) the backup you took before upgrading; its `.env`
brings 2.x back with it. Anything that changed on 3.0 since is lost.

</details>

<details id="back-up-and-restore" class="dp-details">
<summary>Back up and restore</summary>

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**. On 3.0 the database holds your credentials'
working keys, and `.env` holds the key that unlocks them. Copy backups
off the VM as shown above.

### Restore

On a new VM, enable Docker and install `jq` as in
[Install](#install-n8n-with-postgresql). Create `~/n8n-backups` there, then
copy your backup over from your computer:

```bash
scp n8n-20261030-210512.tar.gz my-n8n-3.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:

```bash
cd ~/n8n && docker compose down
mv ~/n8n ~/n8n.before-restore
```

Unpack the backup and set this VM's hostname:

```bash
mkdir ~/n8n
tar -xzf ~/n8n-backups/n8n-20261030-210512.tar.gz -C ~/n8n
cd ~/n8n
sed -i "s/^N8N_HOSTNAME=.*/N8N_HOSTNAME=$(hostname).exe.xyz/" .env
grep ^N8N_VERSION= .env
```

The backup brings its own n8n version, so a 2.x backup restores 2.x.
Load the database before starting n8n:

```bash
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.

</details>

<details id="update" class="dp-details">
<summary>Update n8n</summary>

Pick a newer 3.x version from n8n's
[release notes](https://docs.n8n.io/release-notes/). Beta releases can
change things between versions, so read each one. On the VM, back up,
then edit **only `N8N_VERSION`** in `.env`; both n8n and its runners use it:

```bash
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](#restore)—changing the image tag alone won't undo
database migrations. When 3.0 goes stable, update to that release the
same way.

</details>

<details id="troubleshooting" class="dp-details">
<summary>Something not working?</summary>

On the VM:

```bash
cd ~/n8n
docker compose logs --tail=100 n8n runners postgres webhooks
```

- **The main URL returns 404:** expected! Open the editor at
  `https://my-n8n-3.exe.xyz:5678/`; the main URL serves workflow endpoints only.
- **“Workflow cannot run on this instance”:** it uses a node that 3.0
  removed. Replace the node, then publish again.
- **“Task execution timed out after 60 seconds”:** a Code node needed
  more than a minute. [Raise the limit](#install-n8n-with-postgresql) in
  `.env`; setting it on only one container doesn't work.
- **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.
- **AI credentials blocked after turning on SSRF protection:** add
  `N8N_SSRF_ALLOWED_HOSTNAMES: llm.int.exe.xyz` to the n8n service.
- **Down after reboot:** run `sudo systemctl enable --now docker.service`.
- **`EACCES` on `/home/node/.n8n`:** fix data ownership with
  `sudo chown -R 1000:1000 ~/n8n/n8n-data`.

</details>

## Frequently asked questions

### Is n8n 3.0 stable?

Not yet. The beta came out on October 27, 2026, and n8n expects a stable release in early December. Until then, use [n8n 2.x](/deploy/n8n-2) for anything that matters.

### Can I go back to n8n 2.x?

Only by restoring a backup made before the upgrade. Changing the version number back isn't supported, and anything done on 3.0 since the backup is lost.

### Will my 2.x workflows still run?

Most will. Workflows that use removed nodes, such as Function, Cron, or Item Lists, stop running. Before upgrading, check **Settings → Migration Report** on your 2.x instance.

### Is n8n free?

The Community edition is free to self-host. You pay for your VM; see [exe.dev plans](https://exe.dev/pricing). AI model usage is separate.

### 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](/docs/integrations-llm).


## Related

- [Self-host n8n](https://exe.dev/deploy/n8n-2.md): Connect your apps. Let the boring bits run themselves.
- [Self-host Uptime Kuma](https://exe.dev/deploy/uptime-kuma.md): A little lookout for your websites. Green bars encouraged.
- [Run Claude Code on a remote server](https://exe.dev/deploy/claude-code.md): A computer for Claude. A breather for your laptop.
- [Host OpenClaw](https://exe.dev/deploy/openclaw.md): An AI sidekick with its own computer. Your laptop can take a nap.
- [VS Code in your browser, with code-server](https://exe.dev/deploy/code-server.md): Your editor in a tab. Your projects right where you left them.
