n8n is a workflow automation platform that connects APIs, databases and services through a visual editor. The default single-container setup with SQLite is fine for testing, but in production you want a real database, workers that run executions in parallel, and a stable public URL for webhooks. In this tutorial you will deploy n8n on Ubuntu 24.04 in queue mode with PostgreSQL and Redis, publish it over HTTPS, scale the workers, and configure webhooks, error handling and backups.
Prerequisites
To follow this guide you need:
- A server running Ubuntu 24.04 LTS with at least 2 vCPUs and 4 GB of RAM, for example a CubePath VPS.
- A non-root user with
sudoprivileges. - Docker Engine and the Docker Compose plugin installed from Docker's official repository.
- A domain name with a DNS
Arecord pointing to your server, for examplen8n.your_domain. Replaceyour_domainwith your own domain throughout the guide. - Ports 80 and 443 open in your firewall.
How queue mode works
In queue mode n8n splits into separate processes that share the same database:
| Component | Role |
|---|---|
Main (n8n) | Serves the editor and the REST API, receives webhooks and runs triggers and schedules. It does not run production executions itself |
| Redis | Holds the queue of pending executions |
Workers (n8n worker) | Take executions from the queue, run them and write results to PostgreSQL |
| PostgreSQL | Stores workflows, credentials and execution history. Queue mode does not support SQLite |
Because workers are stateless, you can add more of them when executions start to wait in the queue.
Step 1 - Creating the project directory and secrets
Create a directory for the deployment:
sudo mkdir -p /opt/n8n
cd /opt/n8n
Generate a database password and an encryption key. n8n encrypts every stored credential with the encryption key, and the main process and all workers must use the same one:
openssl rand -hex 16
openssl rand -hex 32
Create the environment file:
sudo nano /opt/n8n/.env
N8N_DOMAIN=n8n.your_domain
POSTGRES_PASSWORD=your_db_password
N8N_ENCRYPTION_KEY=your_encryption_key
GENERIC_TIMEZONE=Europe/Madrid
Paste the two random values and set your own time zone, which n8n uses for Schedule triggers. Protect the file:
sudo chmod 600 /opt/n8n/.env
WarningKeep a copy of
N8N_ENCRYPTION_KEYin a password manager. If you lose it, all credentials stored in n8n become unreadable, even if you restore the database.
Step 2 - Writing the Compose file
Create the Compose file:
sudo nano /opt/n8n/compose.yaml
x-n8n-env: &n8n-env
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}
EXECUTIONS_MODE: queue
QUEUE_BULL_REDIS_HOST: redis
QUEUE_BULL_REDIS_PORT: 6379
QUEUE_HEALTH_CHECK_ACTIVE: "true"
GENERIC_TIMEZONE: ${GENERIC_TIMEZONE}
TZ: ${GENERIC_TIMEZONE}
EXECUTIONS_DATA_PRUNE: "true"
EXECUTIONS_DATA_MAX_AGE: 336
EXECUTIONS_DATA_PRUNE_MAX_COUNT: 50000
services:
postgres:
image: postgres:16
restart: unless-stopped
environment:
POSTGRES_DB: n8n
POSTGRES_USER: n8n
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U n8n -d n8n"]
interval: 5s
retries: 10
redis:
image: redis:7-alpine
restart: unless-stopped
volumes:
- redis_data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
retries: 10
n8n:
image: docker.n8n.io/n8nio/n8n:latest
restart: unless-stopped
environment:
<<: *n8n-env
N8N_HOST: ${N8N_DOMAIN}
N8N_PROTOCOL: https
WEBHOOK_URL: https://${N8N_DOMAIN}/
N8N_PROXY_HOPS: 1
OFFLOAD_MANUAL_EXECUTIONS_TO_WORKERS: "true"
ports:
- "127.0.0.1:5678:5678"
volumes:
- n8n_data:/home/node/.n8n
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
n8n-worker:
image: docker.n8n.io/n8nio/n8n:latest
restart: unless-stopped
command: worker --concurrency=10
environment:
<<: *n8n-env
volumes:
- n8n_data:/home/node/.n8n
depends_on:
- n8n
volumes:
postgres_data:
redis_data:
n8n_data:
The settings that matter most:
- The
x-n8n-envblock is a YAML anchor, so the main process and the workers get identical database, queue and encryption settings. WEBHOOK_URLis the public URL that n8n shows for webhook nodes. Without it, webhook URLs point tolocalhost:5678.N8N_PROXY_HOPS: 1tells n8n it sits behind one reverse proxy, so it trusts theX-Forwarded-*headers from Caddy.EXECUTIONS_DATA_PRUNEwithEXECUTIONS_DATA_MAX_AGE: 336deletes execution history older than 14 days (the value is in hours) and caps it at 50,000 executions, which keeps PostgreSQL from growing without limit.OFFLOAD_MANUAL_EXECUTIONS_TO_WORKERSsends test runs from the editor to the workers too, so the main process only serves the UI and receives events.--concurrency=10lets each worker run up to 10 executions at the same time.- Port 5678 is bound to
127.0.0.1because Docker publishes ports around UFW. Only the reverse proxy should be public.
Tip
latestis fine for a first install, but in production pin a specific version tag (for exampledocker.n8n.io/n8nio/n8n:1.x.y) and use the same tag for the main process and the workers.
Step 3 - Starting the stack
Start all services:
sudo docker compose up -d
Check their state:
sudo docker compose ps
NAME IMAGE SERVICE STATUS PORTS
n8n-n8n-1 docker.n8n.io/n8nio/n8n:latest n8n Up 20 seconds 127.0.0.1:5678->5678/tcp
n8n-n8n-worker-1 docker.n8n.io/n8nio/n8n:latest n8n-worker Up 19 seconds
n8n-postgres-1 postgres:16 postgres Up 31 seconds (healthy) 5432/tcp
n8n-redis-1 redis:7-alpine redis Up 31 seconds (healthy) 6379/tcp
The first start runs database migrations and can take a minute. Follow the main process log until the editor is ready:
sudo docker compose logs -f n8n
n8n-1 | Editor is now accessible via:
n8n-1 | https://n8n.your_domain
Press Ctrl+C to stop following the log. Confirm that the worker connected to the queue:
sudo docker compose logs n8n-worker --tail 20
n8n-worker-1 | n8n worker is now ready
n8n-worker-1 | * Version: 1.x.y
n8n-worker-1 | * Concurrency: 10
Step 4 - Publishing n8n over HTTPS with Caddy
Webhooks from external services need a valid certificate, and the editor uses a WebSocket connection that the proxy must pass through. Caddy handles both with no extra configuration. Install it:
sudo apt update
sudo apt install caddy
Replace the default site configuration:
sudo nano /etc/caddy/Caddyfile
n8n.your_domain {
reverse_proxy 127.0.0.1:5678
}
Open the web ports and reload Caddy:
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo systemctl reload caddy
Check the health endpoint through the proxy:
curl -s https://n8n.your_domain/healthz
{"status":"ok"}
Open https://n8n.your_domain in your browser and create the owner account. This first account has full control over the instance, so use a strong password and enable two-factor authentication from your user settings.
Step 5 - Creating a production webhook
A webhook workflow is the easiest way to confirm that the whole chain (proxy, main process, queue, worker, database) works. In the editor:
- Create a workflow and add a Webhook node.
- Set HTTP Method to
POSTand Path tosignup. - Under Authentication, choose Header Auth and create a credential with the header name
X-Webhook-Tokenand a long random value. Requests without the header are rejected. - Set Respond to Using 'Respond to Webhook' Node, then add a Respond to Webhook node after it with Respond With set to JSON. Switch the Response Body field to Expression and enter:
{"status": "received", "email": "{{ $json.body.email }}"}
The Webhook node shows two URLs. The Test URL (/webhook-test/signup) only works while you click Listen for test event in the editor. The Production URL (/webhook/signup) works once the workflow is published (activated). Save and publish the workflow, then call the production URL:
curl -s -X POST https://n8n.your_domain/webhook/signup \
-H "X-Webhook-Token: your_webhook_token" \
-H "Content-Type: application/json" \
-d '{"email": "[email protected]"}'
{"status":"received","email":"[email protected]"}
Open Executions in the workflow. The run appears there, executed by the worker.
Step 6 - Handling errors and retries
Failures in production workflows should be retried when they are transient and reported when they are not. n8n gives you three layers.
Retries on a node. Open a node that calls an external API (for example, HTTP Request), go to its Settings tab and enable Retry On Fail. Set Max Tries to 3 and Wait Between Tries to a few seconds. This absorbs timeouts and short outages.
Error behavior on a node. In the same Settings tab, On Error decides what happens after the last retry fails:
- Stop Workflow: the default, the execution fails.
- Continue: pass the error on as regular output and keep going.
- Continue (using error output): adds a second, error output to the node, so you can route failed items to a different branch, for example to log them and skip them.
An error workflow. For failures you want to hear about, create a new workflow that starts with an Error Trigger node, followed by a notification node (Email, Slack, Telegram or an HTTP Request to your chat system). Use expressions like these in the message:
Workflow {{ $json.workflow.name }} failed
Node: {{ $json.execution.lastNodeExecuted }}
Error: {{ $json.execution.error.message }}
Execution: {{ $json.execution.url }}
Save it, then open each production workflow, go to Settings and select it as the Error Workflow. To test it, make a node fail on purpose (for example, an HTTP Request to a non-existent host) and run the published workflow.
Step 7 - Reusing logic with sub-workflows
When several workflows repeat the same steps, such as sending a formatted alert, move those steps into a sub-workflow:
- Create a workflow that starts with the When Executed by Another Workflow trigger and define the input fields it expects, for example
channelandmessage. - Build the shared steps after the trigger. The output of the last node is returned to the caller.
- In the calling workflow, add an Execute Sub-workflow node (named Execute Workflow in older versions), select the sub-workflow and map the input fields.
By default the caller waits for the sub-workflow to finish and receives its output. Turn off Wait For Sub-Workflow Completion in the node options for fire-and-forget calls. In queue mode the sub-workflow runs on the same worker as its caller.
Step 8 - Scaling the workers
Each worker runs up to --concurrency executions at the same time. When executions start to wait (they appear as Queued in the executions list) and the server still has free CPU and memory, add workers:
cd /opt/n8n
sudo docker compose up -d --scale n8n-worker=3
sudo docker compose ps n8n-worker
NAME IMAGE SERVICE STATUS
n8n-n8n-worker-1 docker.n8n.io/n8nio/n8n:latest n8n-worker Up 12 minutes
n8n-n8n-worker-2 docker.n8n.io/n8nio/n8n:latest n8n-worker Up 5 seconds
n8n-n8n-worker-3 docker.n8n.io/n8nio/n8n:latest n8n-worker Up 5 seconds
Watch resource usage with sudo docker stats --no-stream before adding more. Workflows that process large files or big JSON payloads need memory more than CPU, so lower --concurrency for them rather than adding workers. Workers can also run on other servers, as long as they reach the same PostgreSQL and Redis and use the same encryption key.
Step 9 - Backing up n8n
Everything important is in PostgreSQL, plus the encryption key in .env. Create a compressed database dump:
sudo mkdir -p /var/backups/n8n
cd /opt/n8n
sudo docker compose exec -T postgres pg_dump -U n8n -Fc n8n | sudo tee /var/backups/n8n/n8n-$(date +%F).dump > /dev/null
Check that the file is a valid dump by listing its contents:
sudo docker compose exec -T postgres pg_restore --list < /var/backups/n8n/n8n-$(date +%F).dump | head -n 5
For a copy that you can review or keep in Git, export every workflow to its own JSON file with the n8n CLI:
sudo docker compose exec n8n n8n export:workflow --backup --output=/home/node/.n8n/backups/workflows/
Schedule the dump with a systemd timer or cron and copy /var/backups/n8n and /opt/n8n/.env off the server with your usual backup tool.
Troubleshooting
Webhook URLs in the editor show http://localhost:5678. WEBHOOK_URL is missing or wrong. Fix .env or compose.yaml, then recreate the container with sudo docker compose up -d --force-recreate n8n.
The editor keeps showing "Connection lost". The WebSocket connection is being cut. With the Caddyfile above it works out of the box; if you use another proxy, make sure it forwards the Upgrade and Connection headers.
Executions stay "Queued" forever. No worker is consuming the queue. Check sudo docker compose logs n8n-worker for Redis or database connection errors.
Workers fail with "Mismatching encryption keys". The worker has a different N8N_ENCRYPTION_KEY from the main process, or an old key saved in /home/node/.n8n/config. All processes must share the value from .env.
The database keeps growing. Confirm that pruning is enabled with sudo docker compose exec n8n env | grep EXECUTIONS_DATA, and lower EXECUTIONS_DATA_MAX_AGE if needed. Large binary data in executions also adds up; disable Save successful production executions in the settings of high-volume workflows.
Upgrading
Read the release notes for breaking changes, back up the database, then change the image tag for both n8n and n8n-worker and recreate the containers:
cd /opt/n8n
sudo docker compose pull
sudo docker compose up -d
The main process runs database migrations on start. Check its logs before sending traffic back to it.
Conclusion
You now run n8n in queue mode with PostgreSQL and Redis behind HTTPS, with workers you can scale, authenticated webhooks, retries and an error workflow, and database backups. Next, install community nodes from Settings > Community nodes to add integrations, move secrets into credentials instead of workflow parameters, and add dedicated webhook processes (n8n webhook) if incoming webhook traffic grows beyond what the main process can handle.
