# How to Self-Host n8n with Docker Compose on Ubuntu 24.04

> Source: <https://dev.to/arctcloud/how-to-self-host-n8n-with-docker-compose-on-ubuntu-2404-c4i>
> Published: 2026-10-15 13:36:35+00:00

Originally published by Arct Cloud: [How to Self-Host n8n with Docker Compose on Ubuntu 24.04](https://www.arct.cloud/tutorials/self-host-n8n).

This article was prepared with AI assistance. Product references were removed and formatting adjusted for this DEV edition.

**TL;DR:**

`n8nio/runners` task runner and PostgreSQL 18, with Caddy in front for HTTPS on ports 80 and 443.`127.0.0.1:5678` only. Docker-published ports bypass UFW, so a public 5678 stays open even when UFW denies it.`N8N_ENCRYPTION_KEY`, `N8N_WEBHOOK_URL` and `N8N_PROXY_HOPS=1`. `WEBHOOK_URL` in n8n 2.35.0, and n8n 1.0 removed the `N8N_BASIC_AUTH` variables.`.env`. n8n 3.0 supports Docker-based installs only.
**Applies to:** Ubuntu 24.04 LTS · n8n 2.41.6 · PostgreSQL 18 · Caddy 2.11.7 · Docker Compose 5.6.0 · checked October 2026

n8n is a fair-code workflow automation platform that connects apps, APIs and AI models in visual workflows started by webhooks, schedules or other triggers. This tutorial self-hosts n8n 2.41.6 on Ubuntu 24.04 with Docker Compose, PostgreSQL 18, an external task runner and Caddy for HTTPS, then covers updates and backups. A VPS gives your webhooks a public HTTPS address that stays reachable when your own computer is off.

`docker` group. Follow `n8n.example.com` that points to your server's IP address, for example `203.0.113.10`.
Commands run as the sudo user. Replace values written in capitals, such as `YOUR_DOMAIN`, with your own.

Four components run on the server. Only Caddy accepts connections from the internet:

| Component | Runs as | Port | Reachable from | 
|---|---|---|---|
| Caddy | systemd service on the host | 80, 443 | Internet | 
| n8n | `n8nio/n8n:2.41.6` container | 5678 | `127.0.0.1` only | 
| Task runner | `n8nio/runners:2.41.6` container | none | Compose network | 
| PostgreSQL | `postgres:18` container | 5432 | Compose network | 

The task runner executes Code node tasks in its own container and connects to n8n's task broker on port 5679. n8n's docs recommend this external mode for production, because internal mode runs user code next to n8n and its stored credentials. The three images are about 620 MB compressed, and n8n deletes execution data older than 14 days by default.

n8n's one-line installer at `get.n8n.io` uses SQLite and serves plain HTTP on port 5678. This tutorial writes the Compose file by hand for a public server.

Caddy needs ports 80 and 443 to get a TLS certificate, often called an SSL certificate, and to serve n8n. Allow SSH before you enable UFW, or the current session drops:

```
sudo ufw allow OpenSSH
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw enable
sudo ufw status
```

Type `y` if UFW asks to confirm. The output looks similar to this:

```
Status: active

To                         Action      From
--                         ------      ----
OpenSSH                    ALLOW       Anywhere
80/tcp                     ALLOW       Anywhere
443/tcp                    ALLOW       Anywhere
OpenSSH (v6)               ALLOW       Anywhere (v6)
80/tcp (v6)                ALLOW       Anywhere (v6)
443/tcp (v6)               ALLOW       Anywhere (v6)
```

Do not open 5678. Step 4 publishes n8n on the loopback address, and Caddy reaches it there.

Compose reads variables from a `.env` file in the project directory. Create the directory:

```
mkdir ~/n8n
cd ~/n8n
```

Replace `YOUR_DOMAIN` with your subdomain, such as `n8n.example.com`, and `YOUR_TIMEZONE` with a time zone name, such as `Europe/Berlin`. Then run the block. It writes the file and generates four random secrets, and `umask 077` inside the parentheses makes `.env` readable only by your user:

```
(
umask 077
cat > .env <<EOF
N8N_VERSION=2.41.6
N8N_DOMAIN=YOUR_DOMAIN
GENERIC_TIMEZONE=YOUR_TIMEZONE
POSTGRES_USER=postgres
POSTGRES_PASSWORD=$(openssl rand -hex 24)
POSTGRES_DB=n8n
POSTGRES_NON_ROOT_USER=n8n
POSTGRES_NON_ROOT_PASSWORD=$(openssl rand -hex 24)
N8N_ENCRYPTION_KEY=$(openssl rand -hex 32)
N8N_RUNNERS_AUTH_TOKEN=$(openssl rand -hex 32)
EOF
)
```

`N8N_ENCRYPTION_KEY` encrypts every credential n8n stores. Without it, a restored database cannot decrypt your credentials, so save a copy in your password manager.

n8n connects as a PostgreSQL user without superuser rights. This script, from n8n's `withPostgres` example, creates that user. Open a new file:

```
nano init-data.sh
```

Paste the script, then save with Ctrl+O and exit with Ctrl+X:

``` bash
#!/bin/bash
set -e;

if [ -n "${POSTGRES_NON_ROOT_USER:-}" ] && [ -n "${POSTGRES_NON_ROOT_PASSWORD:-}" ]; then
  psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-EOSQL
    CREATE USER ${POSTGRES_NON_ROOT_USER} WITH PASSWORD '${POSTGRES_NON_ROOT_PASSWORD}';
    GRANT ALL PRIVILEGES ON DATABASE ${POSTGRES_DB} TO ${POSTGRES_NON_ROOT_USER};
    GRANT CREATE ON SCHEMA public TO ${POSTGRES_NON_ROOT_USER};
EOSQL
else
  echo "SETUP INFO: No Environment variables given!"
fi
```

The PostgreSQL image runs scripts in `/docker-entrypoint-initdb.d` once, when the database volume is empty. A later change to the passwords in `.env` does not update the database.

The Compose file defines the three containers, their volumes and the n8n settings:

```
nano compose.yaml
```

Paste this file, then save and exit:

```
services:
  postgres:
    image: postgres:18
    restart: unless-stopped
    environment:
      POSTGRES_USER: ${POSTGRES_USER}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
      POSTGRES_DB: ${POSTGRES_DB}
      POSTGRES_NON_ROOT_USER: ${POSTGRES_NON_ROOT_USER}
      POSTGRES_NON_ROOT_PASSWORD: ${POSTGRES_NON_ROOT_PASSWORD}
      PGDATA: /var/lib/postgresql/data
    volumes:
      - db_storage:/var/lib/postgresql/data
      - ./init-data.sh:/docker-entrypoint-initdb.d/init-data.sh:ro
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -h localhost -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
      interval: 5s
      timeout: 5s
      retries: 10

  n8n:
    image: n8nio/n8n:${N8N_VERSION}
    restart: unless-stopped
    ports:
      - "127.0.0.1:5678:5678"
    environment:
      DB_TYPE: postgresdb
      DB_POSTGRESDB_HOST: postgres
      DB_POSTGRESDB_PORT: "5432"
      DB_POSTGRESDB_DATABASE: ${POSTGRES_DB}
      DB_POSTGRESDB_USER: ${POSTGRES_NON_ROOT_USER}
      DB_POSTGRESDB_PASSWORD: ${POSTGRES_NON_ROOT_PASSWORD}
      N8N_ENCRYPTION_KEY: ${N8N_ENCRYPTION_KEY}
      N8N_HOST: ${N8N_DOMAIN}
      N8N_PROTOCOL: https
      N8N_WEBHOOK_URL: https://${N8N_DOMAIN}/
      N8N_PROXY_HOPS: "1"
      N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS: "true"
      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_storage:/home/node/.n8n
    depends_on:
      postgres:
        condition: service_healthy

  n8n-runner:
    image: n8nio/runners:${N8N_VERSION}
    restart: unless-stopped
    environment:
      N8N_RUNNERS_AUTH_TOKEN: ${N8N_RUNNERS_AUTH_TOKEN}
      N8N_RUNNERS_TASK_BROKER_URI: http://n8n:5679
    depends_on:
      - n8n

volumes:
  db_storage:
  n8n_storage:
```

What the main settings do:

`127.0.0.1:5678:5678` publishes n8n on loopback only. PostgreSQL and the task broker publish no host ports.`N8N_HOST` set the public address. Without them, webhook URLs show `http://localhost:5678/`.` N8N_PROXY_HOPS: "1"` trusts the `X-Forwarded-*` headers from one reverse proxy.`PGDATA` keeps PostgreSQL 18 data inside the volume, since version 18 changed its default data path.`N8N_VERSION`, because the runner and n8n versions must match.
Pull the images and start the stack in the background:

```
docker compose up -d
```

PostgreSQL starts first. n8n waits for its health check, creates its tables, and then starts. Check the n8n log after a minute:

```
docker compose logs --no-log-prefix n8n | grep -A1 "accessible via"
Editor is now accessible via:
https://n8n.example.com
```

If the command prints nothing, n8n is still starting. Run it again after 30 seconds.

The reverse proxy terminates TLS on port 443 and forwards requests to `127.0.0.1:5678`. The n8n editor receives live updates over WebSockets by default, so the proxy must pass them through. Use one of the two options.

Caddy obtains and renews TLS certificates automatically. Ubuntu 24.04 and 26.04 ship Caddy 2.6.2, so install the current release from Caddy's apt repository:

```
sudo apt install -y debian-keyring debian-archive-keyring apt-transport-https curl
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | sudo tee /etc/apt/sources.list.d/caddy-stable.list
sudo chmod o+r /usr/share/keyrings/caddy-stable-archive-keyring.gpg
sudo chmod o+r /etc/apt/sources.list.d/caddy-stable.list
sudo apt update
sudo apt install caddy
```

The package starts Caddy as a systemd service named `caddy`. Replace the default site configuration:

```
sudo nano /etc/caddy/Caddyfile
```

Delete the existing lines and paste this, with your domain in place of `YOUR_DOMAIN`:

```
YOUR_DOMAIN {
    reverse_proxy 127.0.0.1:5678 {
        flush_interval -1
    }
}
```

Caddy proxies WebSockets and sets the `X-Forwarded-For`, `X-Forwarded-Proto` and `X-Forwarded-Host` headers by default. `flush_interval -1`, from n8n's own Caddyfile, sends responses to the browser without buffering. Validate the file and reload Caddy:

```
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
```

The validate output ends with `Valid configuration`.

If the server already runs Nginx with a Certbot certificate, as in the [Nginx, PostgreSQL, and Redis guide](https://www.arct.cloud/blog/install-nginx-postgresql-redis-ubuntu-24-04), use this `location` block in the server block for `YOUR_DOMAIN`. Ubuntu's Nginx packages, 1.24 on 24.04 and 1.28 on 26.04, need `proxy_http_version 1.1` for the WebSocket upgrade. `client_max_body_size 16m` raises Nginx's 1 MB default request body limit to match n8n's default 16 MiB payload limit:

```
location / {
    proxy_pass http://127.0.0.1:5678;
    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "upgrade";
    proxy_set_header Host $host;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Host $host;
    proxy_set_header X-Forwarded-Proto $scheme;
    proxy_buffering off;
    proxy_read_timeout 3600s;
    client_max_body_size 16m;
}
```

Test the configuration with `sudo nginx -t`, then apply it with `sudo systemctl reload nginx`.

Open `https://YOUR_DOMAIN` in your browser. n8n shows the owner setup form, and the first person to complete it becomes the instance owner. Until you complete this form, anyone who opens the address can claim the instance, so do this step immediately after Step 6. Enter your email address, name and a strong password.

n8n removed basic auth in version 1.0, and no supported setting turns off the login screen. Without SMTP settings, you send invite links by hand and users cannot reset their passwords.

List the running services:

```
docker compose ps --services --status running
```

The output lists `n8n`, `n8n-runner` and `postgres`. Check that n8n is connected to the database and ready:

```
curl -s http://127.0.0.1:5678/healthz/readiness
{"status":"ok"}
```

From your own computer, confirm that port 5678 is closed to the internet:

```
curl --max-time 5 http://YOUR_SERVER_IP:5678
```

Replace `YOUR_SERVER_IP` with the server's address, such as `203.0.113.10`. The request fails or times out; a page means the port is public. Check memory use per container:

```
docker stats --no-stream --format "table {{.Name}}\t{{.MemUsage}}"
```

Memory use rises with large JSON or binary data, Code nodes, workflows that run at the same time, and manual executions, because n8n copies their data for the editor.

n8n releases a new minor version most weeks, and its docs recommend updating at least once a month. Back up first, because n8n migrates the database when a new version starts.

Find the current stable number on [n8n's GitHub releases page](https://github.com/n8n-io/n8n/releases), where beta builds are marked as pre-releases. Set it in `.env`, recreate the containers and remove the old images:

```
cd ~/n8n
sed -i 's/^N8N_VERSION=.*/N8N_VERSION=YOUR_NEW_VERSION/' .env
docker compose pull
docker compose up -d
docker image rm n8nio/n8n:YOUR_OLD_VERSION n8nio/runners:YOUR_OLD_VERSION
```

Replace `YOUR_NEW_VERSION` with the stable release number and `YOUR_OLD_VERSION` with the version you replaced, which `grep N8N_VERSION .env` shows before the change. The runner image reads the same line, and `docker image rm` deletes the two old images.

Read the [v3.0 breaking changes](https://docs.n8n.io/changelog/v30-breaking-changes.md) before you move from 2.x to 3.0. This setup already uses Docker and an external task runner. In 3.0, the default Code node task timeout drops from 300 to 60 seconds.

`postgres:18` pulls PostgreSQL minor releases. A new major version needs a dump and restore; changing the image tag alone fails with `database files are incompatible with server`.

A complete n8n backup has two parts: the PostgreSQL database and the `.n8n` folder in the `n8n_storage` volume. Keep the `.env` file with them, because it holds the encryption key and the database passwords:

```
mkdir -p ~/n8n-backups
cd ~/n8n
STAMP=$(date +%F-%H%M)
docker compose exec -T postgres pg_dump -U postgres -d n8n -Fc > ~/n8n-backups/n8n-db-$STAMP.dump
docker compose cp n8n:/home/node/.n8n ~/n8n-backups/n8n-files-$STAMP
cp .env compose.yaml init-data.sh ~/n8n-backups/
chmod -R go-rwx ~/n8n-backups
```

`pg_dump -Fc` writes a compressed dump while n8n keeps running, and `docker compose cp` copies the folder out of the container. `STAMP` holds the date and time, so two runs on the same day write separate files.

To run the backup on a schedule, save the commands in a script such as `~/n8n/backup.sh` with `#!/bin/bash` as the first line, make it executable with `chmod +x ~/n8n/backup.sh`, and call the script from cron or a systemd timer. Do not paste the commands into a crontab line, because cron treats `%` as a line break. Copy `~/n8n-backups` to another machine, for example with `rsync` over SSH.

To restore the database on the same server, stop n8n and the runner, load the dump, and start them again:

```
docker compose stop n8n n8n-runner
docker compose exec -T postgres pg_restore -U postgres -d n8n --clean --if-exists < ~/n8n-backups/n8n-db-YOUR_TIMESTAMP.dump
docker compose start n8n n8n-runner
```

Replace `YOUR_TIMESTAMP` with the date and time in the backup file name. Never run `docker compose down -v` in this project: `-v` deletes both volumes.

| Symptom | Cause | Fix | 
|---|---|---|
| Caddy returns 502 and logs `dial tcp 127.0.0.1:5678: connect: connection refused` | n8n is stopped or still starting | `docker compose ps` , then`docker compose logs n8n` | 
| `Database is not ready!` in the browser, or`There was an error initializing DB` in the n8n log | PostgreSQL is down or rejects the n8n user | `docker compose logs postgres` , and compare the passwords in`.env` | 
| `password authentication failed for user "n8n"` | The database volume is older than the passwords in `.env` , so`init-data.sh` did not set them | On a new install with no workflows: `docker compose down` ,`docker volume rm n8n_db_storage` , then`docker compose up -d` | 
| `Mismatching encryption keys. The encryption key in the settings file /home/node/.n8n/config does not match the N8N_ENCRYPTION_KEY env var.` | `N8N_ENCRYPTION_KEY` in`.env` changed after the first start | Put the original key back in `.env` , then`docker compose up -d` | 
| Browser certificate error on `YOUR_DOMAIN` | The DNS A record points elsewhere, or port 80 is closed | `getent hosts YOUR_DOMAIN` ,`sudo ufw status` ,`sudo journalctl -u caddy -n 50` | 

n8n's sizing example lists 320 MB to 2 GB of memory for n8n, and an idle n8n Cloud instance uses about 100 MB. PostgreSQL and the task runner add to that, and large files or Code nodes raise it. The n8n Assistant sandbox needs at least 4 GB RAM and 2 vCPU.

Yes, the self-hosted Community Edition is free to use under the Sustainable Use License, a fair-code license n8n created in 2022. It allows internal business, personal and non-commercial use, and n8n describes the code as source-available.

Set N8N_VERSION in the .env file to the new release, then run docker compose pull and docker compose up -d in the project folder. The task runner image uses the same tag. Back up the database first, because n8n migrates it on start.

Use PostgreSQL on a server that runs workflows all day. n8n's Docker Compose guide calls SQLite fine for trying things out and recommends Postgres for production. Existing SQLite data does not move to Postgres on its own.

n8n builds webhook URLs from N8N_PROTOCOL, N8N_HOST and N8N_PORT unless N8N_WEBHOOK_URL is set. Behind a reverse proxy, set N8N_WEBHOOK_URL to your HTTPS address and N8N_PROXY_HOPS to 1. N8N_WEBHOOK_URL replaced WEBHOOK_URL in n8n 2.35.0.

Yes. The n8n image runs under Docker Desktop and serves the editor at [http://localhost:5678](http://localhost:5678). Outside services cannot reach webhooks on a laptop without a tunnel, so workflows that receive webhooks run on a server with a public HTTPS address.

Licensed under [CC BY-NC-SA 4.0](https://creativecommons.org/licenses/by-nc-sa/4.0/).
