How to Deploy n8n with Docker Compose and Caddy

Share

n8n is a workflow automation platform that allows you to connect APIs, databases, messaging platforms, cloud services, and business applications.

In this tutorial, we will deploy n8n on an Ubuntu server using Docker Compose, PostgreSQL, and an existing Caddy reverse proxy.

Only Caddy will expose public ports. n8n and PostgreSQL will remain accessible only through private Docker networks.


Architecture

The deployment contains four main components:

  • Caddy receives HTTP and HTTPS traffic.
  • n8n runs the automation workflows.
  • PostgreSQL stores n8n configuration and workflow data.
  • Docker networks provide private communication between containers.

The architecture looks like this:

Internet
   |
   v
Caddy
Ports 80 and 443
   |
   v
n8n
Port 5678
   |
   v
PostgreSQL
Port 5432

Only Caddy is exposed publicly.

n8n and PostgreSQL do not publish ports directly on the server.

Step 1: Create the DNS record

Create an A record in your DNS provider.

Type: A
Name: n8n
Value: YOUR_SERVER_PUBLIC_IP

Verify the DNS record:

dig +short n8n.example.com

The command should return the public IP address of your server.

Step 2: Create the n8n project directory

Connect to the server using SSH and create the project directory:

sudo mkdir -p /data/n8n
sudo chown -R "$USER":"$USER" /data/n8n

cd /data/n8n
mkdir -p local-files

The final directory structure will be:

/data/n8n
├── docker-compose.yml
├── .env
└── local-files

Step 3: Create the shared proxy network

Caddy and n8n must be connected to the same Docker network.

Create an external network named proxy:

docker network inspect proxy >/dev/null 2>&1 || docker network create proxy

Caddy will use this network to connect to n8n using the container name:

n8n:5678

Step 4: Generate secure credentials

Generate a strong PostgreSQL password:

openssl rand -hex 32

Generate an n8n encryption key:

openssl rand -hex 32

Store these values securely.

The n8n encryption key is used to encrypt credentials stored inside n8n.

Do not change this key after creating workflows and credentials.


Step 5: Create the environment file

Create the .env file:

nano /data/n8n/.env

Add the following configuration:

POSTGRES_DB=n8n
POSTGRES_USER=n8n
POSTGRES_PASSWORD=REPLACE_WITH_STRONG_POSTGRES_PASSWORD

N8N_HOST=n8n.example.com
N8N_PORT=5678
N8N_PROTOCOL=https

N8N_EDITOR_BASE_URL=https://n8n.example.com
WEBHOOK_URL=https://n8n.example.com/

N8N_ENCRYPTION_KEY=REPLACE_WITH_SECURE_ENCRYPTION_KEY
N8N_SECURE_COOKIE=true

GENERIC_TIMEZONE=Africa/Casablanca
TZ=Africa/Casablanca

Replace:

REPLACE_WITH_STRONG_POSTGRES_PASSWORD

with the PostgreSQL password generated earlier.

Replace:

REPLACE_WITH_SECURE_ENCRYPTION_KEY

with the n8n encryption key.

Replace:

n8n.example.com

with your real domain.

Protect the file:

chmod 600 /data/n8n/.env

Step 6: Create the Docker Compose file

Create the Compose file:

nano /data/n8n/docker-compose.yml

Add:

services:
  postgres:
    image: postgres:16-alpine
    container_name: n8n-postgres
    restart: unless-stopped

    environment:
      POSTGRES_DB: ${POSTGRES_DB}
      POSTGRES_USER: ${POSTGRES_USER}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}

    volumes:
      - postgres_data:/var/lib/postgresql/data

    networks:
      - n8n_internal

    healthcheck:
      test:
        - CMD-SHELL
        - pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}
      interval: 10s
      timeout: 5s
      retries: 10

    security_opt:
      - no-new-privileges:true

  n8n:
    image: docker.n8n.io/n8nio/n8n:stable
    container_name: n8n
    restart: unless-stopped

    depends_on:
      postgres:
        condition: service_healthy

    environment:
      DB_TYPE: postgresdb
      DB_POSTGRESDB_HOST: postgres
      DB_POSTGRESDB_PORT: 5432
      DB_POSTGRESDB_DATABASE: ${POSTGRES_DB}
      DB_POSTGRESDB_USER: ${POSTGRES_USER}
      DB_POSTGRESDB_PASSWORD: ${POSTGRES_PASSWORD}

      N8N_HOST: ${N8N_HOST}
      N8N_PORT: ${N8N_PORT}
      N8N_PROTOCOL: ${N8N_PROTOCOL}

      N8N_EDITOR_BASE_URL: ${N8N_EDITOR_BASE_URL}
      WEBHOOK_URL: ${WEBHOOK_URL}
      N8N_PROXY_HOPS: 1

      N8N_ENCRYPTION_KEY: ${N8N_ENCRYPTION_KEY}
      N8N_SECURE_COOKIE: ${N8N_SECURE_COOKIE}

      N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS: "true"
      N8N_RUNNERS_ENABLED: "true"
      N8N_DIAGNOSTICS_ENABLED: "false"

      GENERIC_TIMEZONE: ${GENERIC_TIMEZONE}
      TZ: ${TZ}

    volumes:
      - n8n_data:/home/node/.n8n
      - ./local-files:/files

    expose:
      - "5678"

    networks:
      - n8n_internal
      - proxy

    security_opt:
      - no-new-privileges:true

volumes:
  postgres_data:
  n8n_data:

networks:
  n8n_internal:
    internal: true

  proxy:
    external: true

This Compose file creates:

  • One PostgreSQL container
  • One n8n container
  • Two persistent Docker volumes
  • One private internal network
  • One shared proxy network

The n8n port is not published on the host.

Caddy accesses n8n through the proxy network.


Step 7: Validate the Docker Compose file

Run:

cd /data/n8n
docker compose config

If the YAML file is valid, Docker Compose will display the final expanded configuration.

If there is a YAML error, check:

  • Indentation
  • Duplicate keys
  • Missing spaces
  • Incorrect environment variable names

Step 8: Start n8n

Pull the required images:

docker compose pull

Start the containers:

docker compose up -d

Check their status:

docker compose ps

Expected result:

NAME            STATUS
n8n             Up
n8n-postgres    Up (healthy)

Check the n8n logs:

docker compose logs --tail=100 n8n

Check the PostgreSQL logs:

docker compose logs --tail=100 postgres

Step 9: Connect Caddy to the proxy network

Check the containers connected to the proxy network:

docker network inspect proxy \
  --format '{{range $id, $container := .Containers}}{{println $container.Name}}{{end}}'

The output should include:

n8n
n8n-caddy

If the Caddy container is not connected, add the proxy network to the Caddy Compose file.

Example:

services:
  caddy:
    image: caddy:2-alpine
    container_name: n8n-caddy
    restart: unless-stopped

    ports:
      - "80:80"
      - "443:443"

    networks:
      - n8n_net
      - proxy

networks:
  n8n_net:
    driver: bridge

  proxy:
    external: true

Recreate Caddy:

docker compose up -d --force-recreate caddy

Step 10: Configure Caddy

Open the existing Caddyfile:

nano /data/n8n/Caddyfile

Add:

n8n.example.com {
    encode zstd gzip

    header {
        -Server
        X-Content-Type-Options "nosniff"
        Referrer-Policy "strict-origin-when-cross-origin"
        Strict-Transport-Security "max-age=31536000"
    }

    reverse_proxy n8n:5678
}

Replace:

n8n.example.com

with your real n8n domain.

Do not use:

reverse_proxy localhost:5678

Inside the Caddy container, localhost refers to the Caddy container itself.

Caddy must use:

reverse_proxy n8n:5678

Step 11: Validate and reload Caddy

Validate the Caddy configuration:

docker exec n8n-caddy \
  caddy validate --config /etc/caddy/Caddyfile

Reload Caddy:

docker exec n8n-caddy \
  caddy reload --config /etc/caddy/Caddyfile

Check the Caddy logs:

docker logs n8n-caddy --tail=100

Caddy will automatically request and renew the HTTPS certificate.


Step 12: Open n8n

Open the following URL:

https://n8n.example.com

Create the first n8n owner account.

Use:

  • A valid email address
  • A long and unique password
  • Two-factor authentication when available

Step 13: Verify public ports

Run:

docker ps --format 'table {{.Names}}\t{{.Ports}}'

Expected output:

n8n             5678/tcp
n8n-postgres    5432/tcp
n8n-caddy       0.0.0.0:80->80/tcp, 0.0.0.0:443->443/tcp

The n8n and PostgreSQL containers must not show:

0.0.0.0:5678
0.0.0.0:5432

Only Caddy should expose public ports.


Step 14: Test an n8n webhook

Create a new workflow.

Add a Webhook node.

Activate the workflow.

The production webhook URL should look like:

https://n8n.example.com/webhook/example

It should not use:

http://localhost:5678

The correct public webhook URL is generated using:

WEBHOOK_URL=https://n8n.example.com/

Step 15: Useful Docker Compose commands

Start the containers:

docker compose up -d

Stop the containers:

docker compose stop

Restart n8n:

docker compose restart n8n

Display the container status:

docker compose ps

Follow n8n logs:

docker compose logs -f n8n

Follow all logs:

docker compose logs -f

Stop and remove the containers:

docker compose down

This command keeps the Docker volumes.

Do not run the following command unless you want to delete all n8n and PostgreSQL data:

docker compose down -v

Step 16: Back up the PostgreSQL database

Load the environment variables:

cd /data/n8n

set -a
source .env
set +a

Create a PostgreSQL backup:

docker exec n8n-postgres \
  pg_dump \
  -U "${POSTGRES_USER}" \
  -d "${POSTGRES_DB}" \
  -Fc \
  > n8n-database-$(date +%F-%H%M).dump

Verify the backup:

ls -lh n8n-database-*.dump

Step 17: Back up the n8n data volume

Check the volume name:

docker volume ls | grep n8n

Create the backup:

docker run --rm \
  -v n8n_n8n_data:/source:ro \
  -v /data/n8n:/backup \
  alpine \
  tar -czf /backup/n8n-data-$(date +%F-%H%M).tar.gz \
  -C /source .

Store backups outside the server.

Important files to back up:

.env
docker-compose.yml
Caddyfile
PostgreSQL database dump
n8n data volume
N8N_ENCRYPTION_KEY

The encryption key is required to decrypt credentials after restoring n8n.


Step 18: Update n8n

Create a backup before updating.

Pull the newest image:

cd /data/n8n
docker compose pull

Recreate the containers:

docker compose up -d

Check the status:

docker compose ps

Check the logs:

docker compose logs --tail=100 n8n

Step 19: Basic security recommendations

Use the following security controls:

  • Expose only ports 22, 80, and 443.
  • Disable SSH password authentication.
  • Disable root SSH login.
  • Use SSH keys.
  • Keep Ubuntu updated.
  • Keep Docker images updated.
  • Use strong unique passwords.
  • Keep PostgreSQL private.
  • Keep n8n private behind Caddy.
  • Protect the .env file.
  • Back up the database regularly.
  • Store backups outside the server.
  • Avoid untrusted n8n community nodes.
  • Enable two-factor authentication.

Check the firewall:

sudo ufw status verbose

Recommended public ports:

22/tcp
80/tcp
443/tcp

Conclusion

n8n is now deployed with Docker Compose, PostgreSQL, persistent storage, and Caddy.

Only Caddy accepts public HTTP and HTTPS traffic.

n8n and PostgreSQL remain protected inside Docker networks.

This deployment provides:

  • HTTPS
  • Persistent data
  • PostgreSQL storage
  • Private container networking
  • A reusable Docker Compose configuration
  • Easier backup and update procedures