When this is done you'll have three containers on one Ubuntu 24.04 server: n8n 2.38.5, PostgreSQL 17 and Caddy, all described in a single compose file, with n8n reachable over HTTPS on your own domain and port 5678 never exposed to the internet. The encryption key is set before the first start so credentials survive a rebuild. The whole thing updates with two commands. I've run this exact layout for LumaDock's own WHMCS automations since we moved to n8n 2.x, so every choice below has a reason attached. If you'd rather skip the twenty minutes, the n8n VPS template deploys with Docker and Caddy pre-configured on Ubuntu, picked during ordering. The rest of this page then explains what you were given.
Requirements for n8n on Ubuntu 24.04
A fresh Ubuntu 24.04 server with root or sudo access, a domain (or subdomain) with an A record pointing at the server's public IP and ports 80 and 443 free. n8n's own Hetzner guide calls a 2 vCPU / 2 GB machine "sufficient for most usage", which matches what I see: a single instance idles around 300 to 400 MB of RAM and Postgres adds a couple hundred more. 4 GB gives you room for the n8n Assistant sandbox stack later if you want it, and for Code nodes that build large arrays.
DNS has to resolve before you start the stack, because Caddy requests the certificate on first boot and will retry in a loop if the record isn't there yet. Set the A record first, then come back.
Step 1: Install Docker and the Compose plugin
Ubuntu's own docker.io package works but lags behind, so install from Docker's apt repository. These are the commands from the Docker install docs for Ubuntu, unchanged:
sudo apt update
sudo apt install -y ca-certificates curl
sudo install -m 0755 -d /etc/apt/keyrings
sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
sudo chmod a+r /etc/apt/keyrings/docker.asc
sudo tee /etc/apt/sources.list.d/docker.sources <<EOF
Types: deb
URIs: https://download.docker.com/linux/ubuntu
Suites: $(. /etc/os-release && echo "${UBUNTU_CODENAME:-$VERSION_CODENAME}")
Components: stable
Architectures: $(dpkg --print-architecture)
Signed-By: /etc/apt/keyrings/docker.asc
EOF
sudo apt update
sudo apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
Add your user to the docker group so you can drop the sudo, then log out and back in (or run newgrp docker) for it to apply:
sudo usermod -aG docker $USER
docker compose version
The second command should print a v2 version string. n8n 2.x expects the docker compose plugin, and the old hyphenated docker-compose v1 binary is not something I'd put on a new server in 2026.
Step 2: Create the project folder and the .env file
Everything lives in one directory. I use /opt/n8n because backups and cron jobs are easier to reason about when the path doesn't depend on which user ran the install.
sudo mkdir -p /opt/n8n
sudo chown $USER:$USER /opt/n8n
cd /opt/n8n
Generate the encryption key
n8n encrypts every stored credential with a key it generates on first launch and writes into the .n8n folder. If that folder ever goes away without a backup, the credentials in your database become unreadable while the workflows themselves stay fine. Setting N8N_ENCRYPTION_KEY yourself, before the first start, means the key is in a file you control and back up. Any string works, but a 64 character hex value is easy to generate:
openssl rand -hex 32
Now create /opt/n8n/.env with the four values the compose file will read. Replace the domain, timezone and both secrets:
N8N_DOMAIN=n8n.example.com
GENERIC_TIMEZONE=Europe/London
POSTGRES_PASSWORD=change-this-to-a-long-random-string
N8N_ENCRYPTION_KEY=paste-the-openssl-output-here
Lock the file down with chmod 600 .env. It holds the database password and the key that decrypts every credential you'll ever store.
Step 3: Write the Docker Compose file for n8n, Postgres and Caddy
Save this as /opt/n8n/compose.yaml. No version: key, that's been ignored by Compose v2 for years and only produces a warning now.
services:
postgres:
image: postgres:17
restart: unless-stopped
environment:
- POSTGRES_USER=n8n
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
- POSTGRES_DB=n8n
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U n8n -d n8n"]
interval: 5s
timeout: 5s
retries: 10
n8n:
image: n8nio/n8n:2.38.5
restart: unless-stopped
depends_on:
postgres:
condition: service_healthy
environment:
- DB_TYPE=postgresdb
- DB_POSTGRESDB_HOST=postgres
- DB_POSTGRESDB_PORT=5432
- DB_POSTGRESDB_DATABASE=n8n
- DB_POSTGRESDB_USER=n8n
- DB_POSTGRESDB_PASSWORD=${POSTGRES_PASSWORD}
- N8N_ENCRYPTION_KEY=${N8N_ENCRYPTION_KEY}
- N8N_HOST=${N8N_DOMAIN}
- N8N_PORT=5678
- N8N_PROTOCOL=https
- N8N_EDITOR_BASE_URL=https://${N8N_DOMAIN}
- N8N_WEBHOOK_URL=https://${N8N_DOMAIN}/
- N8N_PROXY_HOPS=1
- GENERIC_TIMEZONE=${GENERIC_TIMEZONE}
- TZ=${GENERIC_TIMEZONE}
- NODE_ENV=production
volumes:
- n8n_data:/home/node/.n8n
caddy:
image: caddy:2
restart: unless-stopped
depends_on:
- n8n
ports:
- "80:80"
- "443:443"
- "443:443/udp"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
- caddy_config:/config
volumes:
postgres_data:
n8n_data:
caddy_data:
caddy_config:
Notice there is no ports: entry on the n8n service. Caddy reaches it as n8n:5678 over the default network Compose creates for the project, so nothing on the host or the internet can talk to n8n directly. That's deliberate. Most compose examples floating around (including one I wrote in 2025, sorry) publish 5678:5678 alongside the proxy, which means the editor is reachable over plain HTTP by IP the whole time, and Docker's published ports bypass UFW so the firewall wouldn't save you.
The image is pinned to 2.38.5, the stable release on the day I'm writing this. The stable and beta tags exist (they replaced latest and next in 2.0) and I never use them in compose, because n8n ships a minor release most weeks and I want to choose when a migration runs against my database.
What each n8n environment variable does
N8N_ENCRYPTION_KEY is the credential key from step 2. N8N_HOST, N8N_PORT and N8N_PROTOCOL describe the public address; N8N_EDITOR_BASE_URL is where users open the editor, and N8N_WEBHOOK_URL is the base n8n uses when it displays and registers webhook URLs. That last one was called WEBHOOK_URL until 2.35.0; the old name still works and logs a deprecation warning. N8N_PROXY_HOPS=1 tells n8n there is exactly one proxy in front of it, so client IPs come from the forwarded headers Caddy sets. GENERIC_TIMEZONE is what the Schedule Trigger uses and defaults to America/New_York if you leave it out, which is how a "run at 02:00" job ends up running at 07:00 in London.
Two things you won't find in this file that older guides add. N8N_RUNNERS_ENABLED=true is deprecated since 2.0 because runners have been the default since then, so Code nodes already run outside the main process. And there is no basic auth block; N8N_BASIC_AUTH_* was removed in 1.0 and the owner account you create in step 6 is the login.
Step 4: Caddyfile for automatic HTTPS
Create /opt/n8n/Caddyfile:
n8n.example.com {
reverse_proxy n8n:5678 {
flush_interval -1
}
}
Swap in your domain. Caddy obtains the Let's Encrypt certificate on first start, renews it on its own and redirects port 80 to 443 without being told to. The flush_interval -1 line is the setting n8n's own Caddy examples use; it disables response buffering so the editor's push connection and any streaming responses (chat, MCP) reach the browser as they're written. Caddy also sets X-Forwarded-For, X-Forwarded-Proto and X-Forwarded-Host by default, which are the three headers n8n's reverse proxy configuration page asks for. There's nothing to add for websockets or forwarding. If a container can't be reached at n8n:5678 you'll see it as a 502 from Caddy, and the usual cause is the n8n service still running migrations.
Prefer nginx on the host? The n8n nginx reverse proxy guide has the server block with the upgrade headers and certbot steps, and the compose file above stays the same apart from publishing n8n on 127.0.0.1:5678 and dropping the caddy service.
Step 5: Firewall rules with UFW
Allow SSH, HTTP and HTTPS, deny the rest:
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow OpenSSH
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 443/udp
sudo ufw enable
sudo ufw status verbose
The UDP rule is for HTTP/3, which Caddy serves by default; skip it if you don't care. One more time because it bites people: UFW does not filter ports Docker publishes with ports:, Docker writes its own iptables rules ahead of UFW's chain. What keeps n8n off the internet here is the missing ports: entry on its service.
Step 6: Start n8n and create the owner account
cd /opt/n8n
docker compose up -d
docker compose logs -f n8n
Postgres comes up first, the healthcheck passes, then n8n runs its migrations (a minute or so on a first boot) and logs that the editor is available. Watch Caddy's log too if the certificate doesn't appear within a minute: docker compose logs caddy will show the ACME challenge failing if DNS hasn't propagated or port 80 is blocked upstream.
Open https://n8n.example.com. There is no login page on a fresh instance. You get a setup form that creates the owner account: email, name and a password of at least 8 characters with one number and one capital letter. That owner is the only user until you invite others under Settings; invitations work without SMTP because you can copy the invite link out of the UI. I turn on two-factor auth under Settings, Personal straight after setup, it's free in the community edition and the editor is a public HTTPS endpoint now.
Quick sanity check from the server itself:
docker compose exec n8n wget -qO- http://localhost:5678/healthz
A {"status":"ok"} response means the process is up. It says nothing about the database; /healthz/readiness also checks the DB connection and migrations, which matters more once you point an uptime monitor at it.
Update n8n with Docker Compose
Because the tag is pinned, updating is an edit plus a restart. Take a backup first; a pg_dump of the database and a copy of .env is the minimum and the n8n backup and restore guide covers the full set. Read the 2.x release notes for the versions you're skipping, since a jump across several minors can include more than one migration. Then change 2.38.5 in compose.yaml to the new version and run:
docker compose pull
docker compose down
docker compose up -d
Migrations run on start and are one-way, so the backup before the edit is the rollback. n8n's own advice is to update at least once a month. I do it on the first working day of the month and never on a Friday, which is a rule I adopted after a Friday update, a schema migration that took longer than expected and a weekend of invoice reminders not going out. Nothing broke permanently. It was just slow, and I was the only one who could see the log.
The one-line installer as a quick alternative
n8n publishes a script that does a minimal Docker install in one command:
curl -fsSL https://get.n8n.io | sh
It writes a compose.yml and .env into ./n8n, creates a Docker volume and starts n8n on port 5678 with SQLite, and sh -s -- --upgrade later pulls a newer version. It's fine for a throwaway box or for trying 2.x on a laptop. The one-line setup page lists the flags it accepts, including --version to pin. I don't use it for anything with a domain because it configures no reverse proxy and no TLS, so you'd bolt Caddy or nginx on afterwards and end up maintaining the compose file it generated anyway.
Next steps after the install
The stack above already uses Postgres, and if you're wondering why not the SQLite default the PostgreSQL vs SQLite comparison for n8n goes through where each one stops being a good idea, with the migration commands for later. Execution data pruning is on by default with a 14 day window; the settings in the guide on pruning n8n executions decide how big that Postgres volume gets, so read it before the first month is over. The n8n security checklist covers the 2.0 defaults you now benefit from (blocked env access in Code nodes, Execute Command disabled, settings file permissions enforced) and what's still on you. Everything else in this series, queue mode, monitoring, backups, hangs off the self-hosted n8n VPS guide, which is where I'd go next if none of the three above is your immediate problem.
One thing I haven't tested: an in-place jump from postgres:17 to postgres:18 under this compose file. The 18 image changed its data directory layout, so treat that as a proper major-version upgrade with a dump and restore, not a tag edit. I'll stay on 17 until it leaves n8n's supported range, which by their November rotation rule isn't soon.

