Docker, PostgreSQL, and Cloudflare Zero Trust Tunnels
Version: 2.0
Last Updated: January 2026
Target Audience: Development Team
This guide provides production-grade configurations for running multiple isolated n8n instances (Production and Development) on a remote Ubuntu server using Docker, with secure external access through Cloudflare Zero Trust tunnels. Each instance has its own database, encryption key, and subdomain.
Prerequisites: Remote Server Access
This guide assumes you have:
- ✅ A remote Ubuntu server with Docker installed
- ✅ SSH access to the server via Cloudflare Tunnel
- ✅ SSH key authentication configured
- ✅ A domain managed by Cloudflare
To connect to your server:
ssh myserver
Note: All commands in the "SERVER" sections should be run after SSHing into your server. Commands marked "CLIENT" are run on your local machine.
Setup your SSH server access
Follow this guide to setup cloudflare tunnel SSH connection to your own server (Prerequisite) -> Guide
Table of Contents
- Architecture Overview
- Tunnel Strategy
- Ubuntu Desktop Server Preparation
- Create n8n Tunnel in Cloudflare
- Directory and File Setup
- Environment Variables Configuration
- Docker Compose Configuration
- Configure Public Hostnames
- Securing the Development Instance
- Deployment
- First-Time n8n Setup
- Verification and Testing
- Backup Procedures
- Troubleshooting
- Quick Reference
1. Architecture Overview
System Architecture
Key Design Principles
| Principle | Implementation |
|---|---|
| Data Isolation | Separate PostgreSQL databases per n8n instance |
| Credential Isolation | Unique encryption keys per instance |
| Network Segmentation | Internal backend networks for databases |
| Zero Trust Access | Cloudflare Access protects dev instance |
| No Port Exposure | All access through Cloudflare Tunnels |
| Separate Tunnels | SSH tunnel independent from n8n tunnel |
2. Tunnel Strategy
You already have a Cloudflare Tunnel running for SSH access. For n8n, we'll create a separate tunnel because:
| SSH Tunnel | n8n Tunnel |
|---|---|
Uses network_mode: host
|
Uses Docker network |
Routes to localhost:22
|
Routes to container names (n8n-prod:5678) |
| Runs standalone | Runs in docker-compose with n8n |
| Already configured ✓ | Will configure now |
Your tunnels after setup:
| Tunnel | Purpose | Hostnames |
|---|---|---|
| Existing (SSH) | Remote server access | ssh.yourdomain.com |
| New (n8n) | n8n web interfaces |
n8n-prod.yourdomain.com, n8n-dev.yourdomain.com
|
3. Ubuntu Desktop Server Preparation
Run these commands on SERVER (via SSH)
ssh myserver
3.1 Disable Sleep/Suspend (If Ubuntu Desktop)
Skip this section if running Ubuntu Server (headless).
# Mask sleep targets
sudo systemctl mask sleep.target suspend.target hibernate.target hybrid-sleep.target
# Configure logind
sudo nano /etc/systemd/logind.conf
Add:
[Login]
HandleLidSwitch=ignore
HandleLidSwitchExternalPower=ignore
HandleLidSwitchDocked=ignore
HandlePowerKey=ignore
HandleSuspendKey=ignore
IdleAction=ignore
Apply:
sudo systemctl restart systemd-logind.service
For GNOME desktop:
gsettings set org.gnome.settings-daemon.plugins.power sleep-inactive-ac-type 'nothing'
gsettings set org.gnome.settings-daemon.plugins.power sleep-inactive-ac-timeout 0
gsettings set org.gnome.desktop.session idle-delay 0
3.2 Verify Docker Installation
docker --version
docker compose version
If Docker is not installed, see Appendix A: Docker Installation.
4. Create n8n Tunnel in Cloudflare
Do this in your web browser (on CLIENT)
- Open Cloudflare Zero Trust Dashboard
- Go to Networks → Tunnels
- Click Create a tunnel
- Select Cloudflared as connector type
- Name it:
n8n-stack(or similar) - Select Docker as environment
-
Copy the token from the command shown (starts with
eyJ...)
Save this token - you'll add it to your .env file in the next section.
Don't configure public hostnames yet - we'll do that after deployment.
5. Directory and File Setup
Run these commands on SERVER (via SSH)
ssh myserver
5.1 Create Project Directory
mkdir -p ~/n8n-stack
cd ~/n8n-stack
5.2 Create .gitignore
cat > .gitignore << 'EOF'
.env
*.backup
*.dump
*.sql
*.log
EOF
5.3 Directory Structure
After setup, your directory will look like:
~/n8n-stack/
├── docker-compose.yml # Service definitions
├── .env # Secrets (chmod 600)
├── .gitignore # Excludes .env from git
└── backup.sh # Backup script (optional)
6. Environment Variables Configuration
Run these commands on SERVER (via SSH)
6.1 Generate Secure Values
cd ~/n8n-stack
# Generate encryption keys (save these!)
echo "Production Encryption Key: $(openssl rand -hex 32)"
echo "Development Encryption Key: $(openssl rand -hex 32)"
# Generate database passwords
echo "Production DB Password: $(openssl rand -base64 24)"
echo "Development DB Password: $(openssl rand -base64 24)"
Copy these values - you'll need them for the .env file.
6.2 Create Environment File
nano .env
Add the following (replace placeholders with your values):
# ============================================
# PRODUCTION DATABASE
# ============================================
POSTGRES_PROD_USER=n8n_prod
POSTGRES_PROD_PASSWORD=YOUR_GENERATED_PROD_DB_PASSWORD
POSTGRES_PROD_DB=n8n_production
# ============================================
# DEVELOPMENT DATABASE
# ============================================
POSTGRES_DEV_USER=n8n_dev
POSTGRES_DEV_PASSWORD=YOUR_GENERATED_DEV_DB_PASSWORD
POSTGRES_DEV_DB=n8n_development
# ============================================
# n8n PRODUCTION
# ============================================
N8N_PROD_HOST=n8n-prod.yourdomain.com
N8N_PROD_ENCRYPTION_KEY=YOUR_GENERATED_PROD_ENCRYPTION_KEY
# ============================================
# n8n DEVELOPMENT
# ============================================
N8N_DEV_HOST=n8n-dev.yourdomain.com
N8N_DEV_ENCRYPTION_KEY=YOUR_GENERATED_DEV_ENCRYPTION_KEY
# ============================================
# SHARED CONFIGURATION
# ============================================
TIMEZONE=Asia/Kolkata
# ============================================
# CLOUDFLARE TUNNEL (n8n tunnel, NOT SSH tunnel)
# ============================================
TUNNEL_TOKEN=YOUR_N8N_TUNNEL_TOKEN_FROM_STEP_4
Replace:
-
yourdomain.com→ Your actual domain -
YOUR_GENERATED_*→ Values from step 6.1 -
YOUR_N8N_TUNNEL_TOKEN_FROM_STEP_4→ Token from Cloudflare dashboard -
TIMEZONE→ Your timezone (e.g.,America/New_York,Europe/London)
6.3 Secure the File
chmod 600 .env
ls -la .env
# Should show: -rw------- (only owner can read/write)
6.4 Critical Warnings
⚠️ ENCRYPTION KEY WARNINGS
- Set keys BEFORE first launch - they're stored in the database on first run
- NEVER change keys after credentials are saved - you'll lose all stored credentials permanently
- Back up your .env file securely - without encryption keys, credential recovery is impossible
- Use DIFFERENT keys for prod and dev - prevents cross-instance issues
7. Docker Compose Configuration
Run on SERVER (via SSH)
cd ~/n8n-stack
nano docker-compose.yml
Paste the complete configuration:
services:
# ============================================
# PRODUCTION INSTANCE
# ============================================
postgres-prod:
image: postgres:16-alpine
container_name: n8n-postgres-prod
restart: unless-stopped
environment:
- POSTGRES_USER=${POSTGRES_PROD_USER}
- POSTGRES_PASSWORD=${POSTGRES_PROD_PASSWORD}
- POSTGRES_DB=${POSTGRES_PROD_DB}
volumes:
- postgres_prod_data:/var/lib/postgresql/data
networks:
- backend-prod
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_PROD_USER} -d ${POSTGRES_PROD_DB}"]
interval: 10s
timeout: 5s
retries: 5
start_period: 30s
n8n-prod:
image: docker.n8n.io/n8nio/n8n:latest
container_name: n8n-prod
restart: unless-stopped
environment:
# Database Configuration
- DB_TYPE=postgresdb
- DB_POSTGRESDB_HOST=postgres-prod
- DB_POSTGRESDB_PORT=5432
- DB_POSTGRESDB_DATABASE=${POSTGRES_PROD_DB}
- DB_POSTGRESDB_USER=${POSTGRES_PROD_USER}
- DB_POSTGRESDB_PASSWORD=${POSTGRES_PROD_PASSWORD}
# n8n Configuration
- N8N_HOST=${N8N_PROD_HOST}
- N8N_PORT=5678
- N8N_PROTOCOL=https
- WEBHOOK_URL=https://${N8N_PROD_HOST}/
- N8N_ENCRYPTION_KEY=${N8N_PROD_ENCRYPTION_KEY}
- GENERIC_TIMEZONE=${TIMEZONE}
# Performance Settings
- NODE_OPTIONS=--max-old-space-size=2048
- N8N_DEFAULT_BINARY_DATA_MODE=filesystem
- EXECUTIONS_DATA_PRUNE=true
- EXECUTIONS_DATA_MAX_AGE=336
- EXECUTIONS_DATA_PRUNE_MAX_COUNT=50000
# Monitoring
- N8N_METRICS=true
volumes:
- n8n_prod_data:/home/node/.n8n
networks:
- frontend
- backend-prod
depends_on:
postgres-prod:
condition: service_healthy
healthcheck:
test: ["CMD", "wget", "--spider", "-q", "http://localhost:5678/healthz"]
interval: 30s
timeout: 10s
retries: 3
start_period: 60s
# ============================================
# DEVELOPMENT INSTANCE
# ============================================
postgres-dev:
image: postgres:16-alpine
container_name: n8n-postgres-dev
restart: unless-stopped
environment:
- POSTGRES_USER=${POSTGRES_DEV_USER}
- POSTGRES_PASSWORD=${POSTGRES_DEV_PASSWORD}
- POSTGRES_DB=${POSTGRES_DEV_DB}
volumes:
- postgres_dev_data:/var/lib/postgresql/data
networks:
- backend-dev
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_DEV_USER} -d ${POSTGRES_DEV_DB}"]
interval: 10s
timeout: 5s
retries: 5
start_period: 30s
n8n-dev:
image: docker.n8n.io/n8nio/n8n:latest
container_name: n8n-dev
restart: unless-stopped
environment:
# Database Configuration
- DB_TYPE=postgresdb
- DB_POSTGRESDB_HOST=postgres-dev
- DB_POSTGRESDB_PORT=5432
- DB_POSTGRESDB_DATABASE=${POSTGRES_DEV_DB}
- DB_POSTGRESDB_USER=${POSTGRES_DEV_USER}
- DB_POSTGRESDB_PASSWORD=${POSTGRES_DEV_PASSWORD}
# n8n Configuration
- N8N_HOST=${N8N_DEV_HOST}
- N8N_PORT=5678
- N8N_PROTOCOL=https
- WEBHOOK_URL=https://${N8N_DEV_HOST}/
- N8N_ENCRYPTION_KEY=${N8N_DEV_ENCRYPTION_KEY}
- GENERIC_TIMEZONE=${TIMEZONE}
# Performance Settings (lower limits for dev)
- NODE_OPTIONS=--max-old-space-size=1024
- N8N_DEFAULT_BINARY_DATA_MODE=filesystem
- EXECUTIONS_DATA_PRUNE=true
- EXECUTIONS_DATA_MAX_AGE=72
- EXECUTIONS_DATA_PRUNE_MAX_COUNT=5000
# Monitoring
- N8N_METRICS=true
volumes:
- n8n_dev_data:/home/node/.n8n
networks:
- frontend
- backend-dev
depends_on:
postgres-dev:
condition: service_healthy
healthcheck:
test: ["CMD", "wget", "--spider", "-q", "http://localhost:5678/healthz"]
interval: 30s
timeout: 10s
retries: 3
start_period: 60s
# ============================================
# CLOUDFLARE TUNNEL (for n8n access)
# ============================================
cloudflared:
image: cloudflare/cloudflared:latest
container_name: n8n-cloudflared
restart: unless-stopped
command: tunnel --no-autoupdate run --token ${TUNNEL_TOKEN}
networks:
- frontend
depends_on:
- n8n-prod
- n8n-dev
# ============================================
# NETWORKS
# ============================================
networks:
frontend:
driver: bridge
backend-prod:
driver: bridge
internal: true
backend-dev:
driver: bridge
internal: true
# ============================================
# VOLUMES
# ============================================
volumes:
postgres_prod_data:
postgres_dev_data:
n8n_prod_data:
n8n_dev_data:
Save and exit (Ctrl+O, Enter, Ctrl+X).
7.1 Configuration Differences: Prod vs Dev
| Setting | Production | Development | Reason |
|---|---|---|---|
max-old-space-size |
2048 MB | 1024 MB | Dev needs less memory |
EXECUTIONS_DATA_MAX_AGE |
336 hrs (14 days) | 72 hrs (3 days) | Less retention for dev |
EXECUTIONS_DATA_PRUNE_MAX_COUNT |
50,000 | 5,000 | Lower limits for dev |
8. Configure Public Hostnames
Do this in your web browser (on CLIENT)
After the containers are running, configure the tunnel routing:
- Go to Cloudflare Zero Trust Dashboard
- Navigate to Networks → Tunnels
- Click on your n8n-stack tunnel
- Go to Public Hostname tab
- Click Add a public hostname
Production Hostname
| Field | Value |
|---|---|
| Subdomain | n8n-prod |
| Domain | Select your domain |
| Path | (leave empty) |
| Type | HTTP |
| URL | n8n-prod:5678 |
Click Save hostname.
Development Hostname
Click Add a public hostname again:
| Field | Value |
|---|---|
| Subdomain | n8n-dev |
| Domain | Select your domain |
| Path | (leave empty) |
| Type | HTTP |
| URL | n8n-dev:5678 |
Click Save hostname.
Understanding Service URLs
| URL | Correct? | Explanation |
|---|---|---|
n8n-prod:5678 |
✅ | Docker DNS resolves container names |
localhost:5678 |
❌ | Points to cloudflared container itself |
192.168.x.x:5678 |
⚠️ | Works but IP may change |
9. Securing the Development Instance
Do this in your web browser (on CLIENT)
Protect the dev instance with Cloudflare Access so only you can access it.
9.1 Create Access Application
- Go to Access → Applications
- Click Add an application
- Select Self-hosted
- Configure:
| Field | Value |
|---|---|
| Application name | n8n Development |
| Session duration | 24 hours |
| Application domain | n8n-dev.yourdomain.com |
| Path | (leave empty) |
- Click Next
9.2 Create Access Policy
| Field | Value |
|---|---|
| Policy name | Dev Access |
| Action | Allow |
Include rule:
- Selector:
Emails - Value:
your.email@example.com
Click Next, then Add application.
9.3 Configure Webhook Bypass
Webhooks need to receive external requests without authentication. Since Cloudflare Access doesn't support path-based rules within a policy, you must create a separate application for webhooks.
Create a Separate Webhook Application
- Go to Access → Applications
- Click Add an application
- Select Self-hosted
- Configure:
| Field | Value |
|---|---|
| Application name | n8n Dev Webhooks |
| Session duration | 24 hours |
| Application domain | n8n-dev.yourdomain.com |
| Path | webhook/* |
Note: The Path field appears below the domain field when creating the application.
- Click Next
Add Bypass Policy
| Field | Value |
|---|---|
| Policy name | Bypass All |
| Action | Bypass |
Include rule:
- Selector: Everyone
- Value:
Everyone
- Click Next → Add application
How It Works
You now have two applications for dev:
| Application | Domain/Path | Policy |
|---|---|---|
| n8n Development | n8n-dev.yourdomain.com |
Allow (your email) |
| n8n Dev Webhooks | n8n-dev.yourdomain.com/webhook/* |
Bypass (Everyone) |
Cloudflare evaluates the most specific path first, so:
- Requests to
/webhook/*→ Bypass (no auth required) - All other requests → Require authentication
Note for Production: If your production instance (
n8n-prod.yourdomain.com) is public without Access protection, webhooks work automatically — no bypass application needed.
10. Deployment
Run these commands on SERVER (via SSH)
ssh myserver
cd ~/n8n-stack
10.1 Validate Configuration
# Check .env file permissions
ls -la .env
# Validate docker-compose syntax
docker compose config --quiet && echo "✓ Configuration valid"
10.2 Start Services
docker compose up -d
10.3 Monitor Startup
# Watch all logs (Ctrl+C to exit)
docker compose logs -f
# Or watch specific service
docker compose logs -f n8n-prod
10.4 Verify All Containers Running
docker compose ps
Expected output:
NAME STATUS PORTS
n8n-cloudflared Up X minutes
n8n-dev Up X minutes (healthy)
n8n-postgres-dev Up X minutes (healthy)
n8n-postgres-prod Up X minutes (healthy)
n8n-prod Up X minutes (healthy)
Wait until all show (healthy) status (1-2 minutes).
11. First-Time n8n Setup
Do this in your web browser (on CLIENT)
11.1 Production Instance
- Open
https://n8n-prod.yourdomain.com - Create your owner account:
- First name, Last name
- Strong password
- Complete the setup wizard
11.2 Development Instance
- Open
https://n8n-dev.yourdomain.com - Authenticate with Cloudflare Access (if configured)
- Create a separate owner account
⚠️ Security: The first user to access each instance becomes the owner. Complete this immediately after deployment.
12. Verification and Testing
Run on SERVER (via SSH) unless noted
12.1 Test Container Health
# Test n8n health endpoints
docker exec n8n-prod wget -qO- http://localhost:5678/healthz
docker exec n8n-dev wget -qO- http://localhost:5678/healthz
12.2 Test Tunnel Connectivity
# Check cloudflared can reach n8n containers
docker exec n8n-cloudflared wget -qO- --timeout=5 http://n8n-prod:5678/healthz
docker exec n8n-cloudflared wget -qO- --timeout=5 http://n8n-dev:5678/healthz
12.3 Test Database Connectivity
# Check PostgreSQL
docker exec n8n-postgres-prod pg_isready -U n8n_prod -d n8n_production
docker exec n8n-postgres-dev pg_isready -U n8n_dev -d n8n_development
12.4 Test Webhooks
Do this from CLIENT
- In n8n-prod, create a workflow with a Webhook node
- Set method to POST
- Copy the Production URL
- Activate the workflow
- Test from your client terminal:
curl -X POST https://n8n-prod.yourdomain.com/webhook-test/your-webhook-path \
-H "Content-Type: application/json" \
-d '{"test": "Hello from webhook!"}'
13. Backup Procedures
Run on SERVER (via SSH)
13.1 Manual Backup
cd ~/n8n-stack
# Production database
docker exec n8n-postgres-prod pg_dump -U n8n_prod -d n8n_production | gzip > backup_prod_$(date +%Y%m%d).sql.gz
# Development database
docker exec n8n-postgres-dev pg_dump -U n8n_dev -d n8n_development | gzip > backup_dev_$(date +%Y%m%d).sql.gz
# Environment file (CRITICAL - contains encryption keys)
cp .env .env.backup.$(date +%Y%m%d)
13.2 Automated Backup Script
cat > backup.sh << 'EOF'
#!/bin/bash
set -e
BACKUP_DIR="$HOME/n8n-backups"
DATE=$(date +%Y%m%d_%H%M%S)
RETENTION_DAYS=14
mkdir -p "$BACKUP_DIR"
echo "[$(date)] Starting backup..."
# Production
docker exec n8n-postgres-prod pg_dump -U n8n_prod -F c -d n8n_production > "$BACKUP_DIR/prod_$DATE.dump"
# Development
docker exec n8n-postgres-dev pg_dump -U n8n_dev -F c -d n8n_development > "$BACKUP_DIR/dev_$DATE.dump"
# Environment file
cp ~/n8n-stack/.env "$BACKUP_DIR/env_$DATE.backup"
# Cleanup old backups
find "$BACKUP_DIR" -name "*.dump" -mtime +$RETENTION_DAYS -delete
find "$BACKUP_DIR" -name "*.backup" -mtime +$RETENTION_DAYS -delete
echo "[$(date)] Backup complete!"
ls -lh "$BACKUP_DIR" | tail -5
EOF
chmod +x backup.sh
13.3 Schedule Daily Backups
crontab -e
Add:
0 2 * * * /home/$USER/n8n-stack/backup.sh >> /home/$USER/n8n-stack/backup.log 2>&1
13.4 Restore from Backup
# Stop n8n first
docker compose stop n8n-prod
# Restore production database
gunzip -c backup_prod_20260122.sql.gz | docker exec -i n8n-postgres-prod psql -U n8n_prod -d n8n_production
# Start n8n
docker compose start n8n-prod
14. Troubleshooting
Common Issues
| Issue | Cause | Solution |
|---|---|---|
| 502 Bad Gateway | Container not healthy |
docker compose logs n8n-prod - wait for healthy |
| Webhooks show localhost | WEBHOOK_URL wrong | Check .env has correct public URL |
| "Connection refused" | Database not ready | Wait for postgres to be healthy |
| Can't access dev instance | Not in Access policy | Add your email to Cloudflare Access |
| Tunnel not connecting | Wrong token | Verify TUNNEL_TOKEN in .env
|
Useful Commands
# Check all container status
docker compose ps
# View logs
docker compose logs -f cloudflared
docker compose logs -f n8n-prod
# Restart a service
docker compose restart n8n-prod
# Rebuild everything
docker compose down
docker compose up -d
# Check resource usage
docker stats
# Access container shell
docker exec -it n8n-prod /bin/sh
Network Debugging
# Test DNS resolution inside cloudflared
docker exec n8n-cloudflared nslookup n8n-prod
docker exec n8n-cloudflared nslookup n8n-dev
# Check network configuration
docker network ls
docker network inspect n8n-stack_frontend
15. Quick Reference
Your Endpoints
| Service | URL |
|---|---|
| SSH Access | ssh myserver |
| n8n Production | https://n8n-prod.yourdomain.com |
| n8n Development | https://n8n-dev.yourdomain.com |
Daily Operations (from CLIENT)
# Connect to server
ssh myserver
# Check status
cd ~/n8n-stack && docker compose ps
# View logs
docker compose logs -f
# Restart services
docker compose restart
# Update n8n
docker compose pull
docker compose up -d
File Locations (on SERVER)
| File | Path |
|---|---|
| Docker Compose | ~/n8n-stack/docker-compose.yml |
| Environment | ~/n8n-stack/.env |
| Backups | ~/n8n-backups/ |
| SSH Tunnel | ~/cloudflare-tunnel/ |
Important Reminders
- ✅ Backup encryption keys - Store
.envsecurely offline - ✅ Don't change encryption keys after first run
- ✅ Create owner accounts immediately after deployment
- ✅ Test webhooks before using in production
- ✅ Keep SSH tunnel separate from n8n tunnel
Appendix A: Docker Installation
If Docker is not installed on your server:
# Remove old versions
for pkg in docker.io docker-compose docker-compose-v2 podman-docker containerd runc; do
sudo apt-get remove -y $pkg 2>/dev/null
done
# Add Docker repository
sudo apt-get update
sudo apt-get 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
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo "${UBUNTU_CODENAME:-$VERSION_CODENAME}") stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
sudo apt-get update
# Install Docker
sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
# Add user to docker group
sudo usermod -aG docker $USER
newgrp docker
# Enable on boot
sudo systemctl enable docker.service
sudo systemctl enable containerd.service
# Verify
docker run hello-world

Top comments (0)