How to Deploy n8n with Docker Compose and Caddy
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_IPVerify the DNS record:
dig +short n8n.example.comThe 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-filesThe final directory structure will be:
/data/n8n
├── docker-compose.yml
├── .env
└── local-filesStep 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 proxyCaddy will use this network to connect to n8n using the container name:
n8n:5678Step 4: Generate secure credentials
Generate a strong PostgreSQL password:
openssl rand -hex 32Generate an n8n encryption key:
openssl rand -hex 32Store 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/.envAdd 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/CasablancaReplace:
REPLACE_WITH_STRONG_POSTGRES_PASSWORDwith the PostgreSQL password generated earlier.
Replace:
REPLACE_WITH_SECURE_ENCRYPTION_KEYwith the n8n encryption key.
Replace:
n8n.example.comwith your real domain.
Protect the file:
chmod 600 /data/n8n/.envStep 6: Create the Docker Compose file
Create the Compose file:
nano /data/n8n/docker-compose.ymlAdd:
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: trueThis 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 configIf 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 pullStart the containers:
docker compose up -dCheck their status:
docker compose psExpected result:
NAME STATUS
n8n Up
n8n-postgres Up (healthy)Check the n8n logs:
docker compose logs --tail=100 n8nCheck the PostgreSQL logs:
docker compose logs --tail=100 postgresStep 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-caddyIf 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: trueRecreate Caddy:
docker compose up -d --force-recreate caddyStep 10: Configure Caddy
Open the existing Caddyfile:
nano /data/n8n/CaddyfileAdd:
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.comwith your real n8n domain.
Do not use:
reverse_proxy localhost:5678Inside the Caddy container, localhost refers to the Caddy container itself.
Caddy must use:
reverse_proxy n8n:5678Step 11: Validate and reload Caddy
Validate the Caddy configuration:
docker exec n8n-caddy \
caddy validate --config /etc/caddy/CaddyfileReload Caddy:
docker exec n8n-caddy \
caddy reload --config /etc/caddy/CaddyfileCheck the Caddy logs:
docker logs n8n-caddy --tail=100Caddy will automatically request and renew the HTTPS certificate.
Step 12: Open n8n
Open the following URL:
https://n8n.example.comCreate 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/tcpThe n8n and PostgreSQL containers must not show:
0.0.0.0:5678
0.0.0.0:5432Only 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/exampleIt should not use:
http://localhost:5678The 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 -dStop the containers:
docker compose stopRestart n8n:
docker compose restart n8nDisplay the container status:
docker compose psFollow n8n logs:
docker compose logs -f n8nFollow all logs:
docker compose logs -fStop and remove the containers:
docker compose downThis command keeps the Docker volumes.
Do not run the following command unless you want to delete all n8n and PostgreSQL data:
docker compose down -vStep 16: Back up the PostgreSQL database
Load the environment variables:
cd /data/n8n
set -a
source .env
set +aCreate a PostgreSQL backup:
docker exec n8n-postgres \
pg_dump \
-U "${POSTGRES_USER}" \
-d "${POSTGRES_DB}" \
-Fc \
> n8n-database-$(date +%F-%H%M).dumpVerify the backup:
ls -lh n8n-database-*.dumpStep 17: Back up the n8n data volume
Check the volume name:
docker volume ls | grep n8nCreate 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_KEYThe 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 pullRecreate the containers:
docker compose up -dCheck the status:
docker compose psCheck the logs:
docker compose logs --tail=100 n8nStep 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
.envfile. - 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 verboseRecommended public ports:
22/tcp
80/tcp
443/tcpConclusion
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