{"slug": "how-to-self-host-n8n-with-docker-compose-on-ubuntu-24-04", "title": "How to Self-Host n8n with Docker Compose on Ubuntu 24.04", "summary": "A developer published a step-by-step guide for self-hosting n8n 2.41.6 on Ubuntu 24.04 using Docker Compose, PostgreSQL 18, an external n8nio/runners task runner, and Caddy for HTTPS. The setup keeps n8n bound to 127.0.0.1:5678 behind Caddy on ports 80 and 443, and warns that Docker-published ports bypass UFW, so a publicly exposed 5678 stays reachable even when UFW denies it. It also notes that n8n 1.0 removed the N8N_BASIC_AUTH variables and that n8n 3.0 supports Docker-based installs only.", "body_md": "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).\n\nThis article was prepared with AI assistance. Product references were removed and formatting adjusted for this DEV edition.\n\n**TL;DR:**\n\n`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.\n**Applies to:** Ubuntu 24.04 LTS · n8n 2.41.6 · PostgreSQL 18 · Caddy 2.11.7 · Docker Compose 5.6.0 · checked October 2026\n\nn8n 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.\n\n`docker` group. Follow `n8n.example.com` that points to your server's IP address, for example `203.0.113.10`.\nCommands run as the sudo user. Replace values written in capitals, such as `YOUR_DOMAIN`, with your own.\n\nFour components run on the server. Only Caddy accepts connections from the internet:\n\n| Component | Runs as | Port | Reachable from | \n|---|---|---|---|\n| Caddy | systemd service on the host | 80, 443 | Internet | \n| n8n | `n8nio/n8n:2.41.6` container | 5678 | `127.0.0.1` only | \n| Task runner | `n8nio/runners:2.41.6` container | none | Compose network | \n| PostgreSQL | `postgres:18` container | 5432 | Compose network | \n\nThe 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.\n\nn8n'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.\n\nCaddy 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:\n\n```\nsudo ufw allow OpenSSH\nsudo ufw allow 80/tcp\nsudo ufw allow 443/tcp\nsudo ufw enable\nsudo ufw status\n```\n\nType `y` if UFW asks to confirm. The output looks similar to this:\n\n```\nStatus: active\n\nTo                         Action      From\n--                         ------      ----\nOpenSSH                    ALLOW       Anywhere\n80/tcp                     ALLOW       Anywhere\n443/tcp                    ALLOW       Anywhere\nOpenSSH (v6)               ALLOW       Anywhere (v6)\n80/tcp (v6)                ALLOW       Anywhere (v6)\n443/tcp (v6)               ALLOW       Anywhere (v6)\n```\n\nDo not open 5678. Step 4 publishes n8n on the loopback address, and Caddy reaches it there.\n\nCompose reads variables from a `.env` file in the project directory. Create the directory:\n\n```\nmkdir ~/n8n\ncd ~/n8n\n```\n\nReplace `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:\n\n```\n(\numask 077\ncat > .env <<EOF\nN8N_VERSION=2.41.6\nN8N_DOMAIN=YOUR_DOMAIN\nGENERIC_TIMEZONE=YOUR_TIMEZONE\nPOSTGRES_USER=postgres\nPOSTGRES_PASSWORD=$(openssl rand -hex 24)\nPOSTGRES_DB=n8n\nPOSTGRES_NON_ROOT_USER=n8n\nPOSTGRES_NON_ROOT_PASSWORD=$(openssl rand -hex 24)\nN8N_ENCRYPTION_KEY=$(openssl rand -hex 32)\nN8N_RUNNERS_AUTH_TOKEN=$(openssl rand -hex 32)\nEOF\n)\n```\n\n`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.\n\nn8n connects as a PostgreSQL user without superuser rights. This script, from n8n's `withPostgres` example, creates that user. Open a new file:\n\n```\nnano init-data.sh\n```\n\nPaste the script, then save with Ctrl+O and exit with Ctrl+X:\n\n``` bash\n#!/bin/bash\nset -e;\n\nif [ -n \"${POSTGRES_NON_ROOT_USER:-}\" ] && [ -n \"${POSTGRES_NON_ROOT_PASSWORD:-}\" ]; then\n  psql -v ON_ERROR_STOP=1 --username \"$POSTGRES_USER\" --dbname \"$POSTGRES_DB\" <<-EOSQL\n    CREATE USER ${POSTGRES_NON_ROOT_USER} WITH PASSWORD '${POSTGRES_NON_ROOT_PASSWORD}';\n    GRANT ALL PRIVILEGES ON DATABASE ${POSTGRES_DB} TO ${POSTGRES_NON_ROOT_USER};\n    GRANT CREATE ON SCHEMA public TO ${POSTGRES_NON_ROOT_USER};\nEOSQL\nelse\n  echo \"SETUP INFO: No Environment variables given!\"\nfi\n```\n\nThe 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.\n\nThe Compose file defines the three containers, their volumes and the n8n settings:\n\n```\nnano compose.yaml\n```\n\nPaste this file, then save and exit:\n\n```\nservices:\n  postgres:\n    image: postgres:18\n    restart: unless-stopped\n    environment:\n      POSTGRES_USER: ${POSTGRES_USER}\n      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}\n      POSTGRES_DB: ${POSTGRES_DB}\n      POSTGRES_NON_ROOT_USER: ${POSTGRES_NON_ROOT_USER}\n      POSTGRES_NON_ROOT_PASSWORD: ${POSTGRES_NON_ROOT_PASSWORD}\n      PGDATA: /var/lib/postgresql/data\n    volumes:\n      - db_storage:/var/lib/postgresql/data\n      - ./init-data.sh:/docker-entrypoint-initdb.d/init-data.sh:ro\n    healthcheck:\n      test: [\"CMD-SHELL\", \"pg_isready -h localhost -U ${POSTGRES_USER} -d ${POSTGRES_DB}\"]\n      interval: 5s\n      timeout: 5s\n      retries: 10\n\n  n8n:\n    image: n8nio/n8n:${N8N_VERSION}\n    restart: unless-stopped\n    ports:\n      - \"127.0.0.1:5678:5678\"\n    environment:\n      DB_TYPE: postgresdb\n      DB_POSTGRESDB_HOST: postgres\n      DB_POSTGRESDB_PORT: \"5432\"\n      DB_POSTGRESDB_DATABASE: ${POSTGRES_DB}\n      DB_POSTGRESDB_USER: ${POSTGRES_NON_ROOT_USER}\n      DB_POSTGRESDB_PASSWORD: ${POSTGRES_NON_ROOT_PASSWORD}\n      N8N_ENCRYPTION_KEY: ${N8N_ENCRYPTION_KEY}\n      N8N_HOST: ${N8N_DOMAIN}\n      N8N_PROTOCOL: https\n      N8N_WEBHOOK_URL: https://${N8N_DOMAIN}/\n      N8N_PROXY_HOPS: \"1\"\n      N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS: \"true\"\n      GENERIC_TIMEZONE: ${GENERIC_TIMEZONE}\n      TZ: ${GENERIC_TIMEZONE}\n      N8N_RUNNERS_MODE: external\n      N8N_RUNNERS_AUTH_TOKEN: ${N8N_RUNNERS_AUTH_TOKEN}\n      N8N_RUNNERS_BROKER_LISTEN_ADDRESS: 0.0.0.0\n    volumes:\n      - n8n_storage:/home/node/.n8n\n    depends_on:\n      postgres:\n        condition: service_healthy\n\n  n8n-runner:\n    image: n8nio/runners:${N8N_VERSION}\n    restart: unless-stopped\n    environment:\n      N8N_RUNNERS_AUTH_TOKEN: ${N8N_RUNNERS_AUTH_TOKEN}\n      N8N_RUNNERS_TASK_BROKER_URI: http://n8n:5679\n    depends_on:\n      - n8n\n\nvolumes:\n  db_storage:\n  n8n_storage:\n```\n\nWhat the main settings do:\n\n`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.\nPull the images and start the stack in the background:\n\n```\ndocker compose up -d\n```\n\nPostgreSQL starts first. n8n waits for its health check, creates its tables, and then starts. Check the n8n log after a minute:\n\n```\ndocker compose logs --no-log-prefix n8n | grep -A1 \"accessible via\"\nEditor is now accessible via:\nhttps://n8n.example.com\n```\n\nIf the command prints nothing, n8n is still starting. Run it again after 30 seconds.\n\nThe 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.\n\nCaddy 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:\n\n```\nsudo apt install -y debian-keyring debian-archive-keyring apt-transport-https curl\ncurl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg\ncurl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | sudo tee /etc/apt/sources.list.d/caddy-stable.list\nsudo chmod o+r /usr/share/keyrings/caddy-stable-archive-keyring.gpg\nsudo chmod o+r /etc/apt/sources.list.d/caddy-stable.list\nsudo apt update\nsudo apt install caddy\n```\n\nThe package starts Caddy as a systemd service named `caddy`. Replace the default site configuration:\n\n```\nsudo nano /etc/caddy/Caddyfile\n```\n\nDelete the existing lines and paste this, with your domain in place of `YOUR_DOMAIN`:\n\n```\nYOUR_DOMAIN {\n    reverse_proxy 127.0.0.1:5678 {\n        flush_interval -1\n    }\n}\n```\n\nCaddy 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:\n\n```\nsudo caddy validate --config /etc/caddy/Caddyfile\nsudo systemctl reload caddy\n```\n\nThe validate output ends with `Valid configuration`.\n\nIf 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:\n\n```\nlocation / {\n    proxy_pass http://127.0.0.1:5678;\n    proxy_http_version 1.1;\n    proxy_set_header Upgrade $http_upgrade;\n    proxy_set_header Connection \"upgrade\";\n    proxy_set_header Host $host;\n    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n    proxy_set_header X-Forwarded-Host $host;\n    proxy_set_header X-Forwarded-Proto $scheme;\n    proxy_buffering off;\n    proxy_read_timeout 3600s;\n    client_max_body_size 16m;\n}\n```\n\nTest the configuration with `sudo nginx -t`, then apply it with `sudo systemctl reload nginx`.\n\nOpen `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.\n\nn8n 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.\n\nList the running services:\n\n```\ndocker compose ps --services --status running\n```\n\nThe output lists `n8n`, `n8n-runner` and `postgres`. Check that n8n is connected to the database and ready:\n\n```\ncurl -s http://127.0.0.1:5678/healthz/readiness\n{\"status\":\"ok\"}\n```\n\nFrom your own computer, confirm that port 5678 is closed to the internet:\n\n```\ncurl --max-time 5 http://YOUR_SERVER_IP:5678\n```\n\nReplace `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:\n\n```\ndocker stats --no-stream --format \"table {{.Name}}\\t{{.MemUsage}}\"\n```\n\nMemory 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.\n\nn8n 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.\n\nFind 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:\n\n```\ncd ~/n8n\nsed -i 's/^N8N_VERSION=.*/N8N_VERSION=YOUR_NEW_VERSION/' .env\ndocker compose pull\ndocker compose up -d\ndocker image rm n8nio/n8n:YOUR_OLD_VERSION n8nio/runners:YOUR_OLD_VERSION\n```\n\nReplace `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.\n\nRead 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.\n\n`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`.\n\nA 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:\n\n```\nmkdir -p ~/n8n-backups\ncd ~/n8n\nSTAMP=$(date +%F-%H%M)\ndocker compose exec -T postgres pg_dump -U postgres -d n8n -Fc > ~/n8n-backups/n8n-db-$STAMP.dump\ndocker compose cp n8n:/home/node/.n8n ~/n8n-backups/n8n-files-$STAMP\ncp .env compose.yaml init-data.sh ~/n8n-backups/\nchmod -R go-rwx ~/n8n-backups\n```\n\n`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.\n\nTo 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.\n\nTo restore the database on the same server, stop n8n and the runner, load the dump, and start them again:\n\n```\ndocker compose stop n8n n8n-runner\ndocker compose exec -T postgres pg_restore -U postgres -d n8n --clean --if-exists < ~/n8n-backups/n8n-db-YOUR_TIMESTAMP.dump\ndocker compose start n8n n8n-runner\n```\n\nReplace `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.\n\n| Symptom | Cause | Fix | \n|---|---|---|\n| 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` | \n| `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` | \n| `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` | \n| `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` | \n| 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` | \n\nn8n'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.\n\nYes, 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.\n\nSet 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.\n\nUse 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.\n\nn8n 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.\n\nYes. 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.\n\nLicensed under [CC BY-NC-SA 4.0](https://creativecommons.org/licenses/by-nc-sa/4.0/).", "url": "https://wpnews.pro/news/how-to-self-host-n8n-with-docker-compose-on-ubuntu-24-04", "canonical_source": "https://dev.to/arctcloud/how-to-self-host-n8n-with-docker-compose-on-ubuntu-2404-c4i", "published_at": "2026-10-15 13:36:35+00:00", "updated_at": "2026-10-15 13:49:20.507363+00:00", "lang": "en", "topics": ["ai-tools", "mlops", "developer-tools"], "entities": ["n8n", "Docker Compose", "Ubuntu", "PostgreSQL", "Caddy", "Arct Cloud"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/how-to-self-host-n8n-with-docker-compose-on-ubuntu-24-04", "markdown": "https://wpnews.pro/news/how-to-self-host-n8n-with-docker-compose-on-ubuntu-24-04.md", "text": "https://wpnews.pro/news/how-to-self-host-n8n-with-docker-compose-on-ubuntu-24-04.txt", "jsonld": "https://wpnews.pro/news/how-to-self-host-n8n-with-docker-compose-on-ubuntu-24-04.jsonld"}}