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 sudo privileges.
  • Docker Engine and the Docker Compose plugin installed from Docker's official repository.
  • A domain name with a DNS A record pointing to your server, for example n8n.your_domain. Replace your_domain with 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:

ComponentRole
Main (n8n)Serves the editor and the REST API, receives webhooks and runs triggers and schedules. It does not run production executions itself
RedisHolds the queue of pending executions
Workers (n8n worker)Take executions from the queue, run them and write results to PostgreSQL
PostgreSQLStores 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

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-env block is a YAML anchor, so the main process and the workers get identical database, queue and encryption settings.
  • WEBHOOK_URL is the public URL that n8n shows for webhook nodes. Without it, webhook URLs point to localhost:5678.
  • N8N_PROXY_HOPS: 1 tells n8n it sits behind one reverse proxy, so it trusts the X-Forwarded-* headers from Caddy.
  • EXECUTIONS_DATA_PRUNE with EXECUTIONS_DATA_MAX_AGE: 336 deletes 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_WORKERS sends test runs from the editor to the workers too, so the main process only serves the UI and receives events.
  • --concurrency=10 lets each worker run up to 10 executions at the same time.
  • Port 5678 is bound to 127.0.0.1 because Docker publishes ports around UFW. Only the reverse proxy should be public.

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:

  1. Create a workflow and add a Webhook node.
  2. Set HTTP Method to POST and Path to signup.
  3. Under Authentication, choose Header Auth and create a credential with the header name X-Webhook-Token and a long random value. Requests without the header are rejected.
  4. 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:

  1. Create a workflow that starts with the When Executed by Another Workflow trigger and define the input fields it expects, for example channel and message.
  2. Build the shared steps after the trigger. The output of the last node is returned to the caller.
  3. 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.