DEV Community

Cover image for Deploying Open Source Workflow Systems On Hetzner
Raizan
Raizan

Posted on Originally published at chasebot.online

Deploying Open Source Workflow Systems On Hetzner

What You'll Need


Table of Contents


Provisioning the Hetzner Server Architecture

When setting up self-hosted workflow automation, infrastructure costs and reliability are the main considerations. Proprietary SaaS platforms become expensive as execution volumes scale into tens of thousands of tasks per month. Running an open-source workflow execution engine on a cloud server gives you uncapped execution capacity at a fixed monthly price.

I prefer using a cloud instance on Hetzner VPS for this work. A CX22 or CX32 instance provides dedicated vCPU resources, NVMe storage, and high-bandwidth networking at a fraction of the cost of legacy providers. If you need alternative infrastructure, providers like DigitalOcean or Contabo VPS work similarly, but Hetzner offers unmatched price-to-performance metrics in European and North American datacenters.

To begin, launch an Ubuntu 22.04 LTS instance on Hetzner. Once the server boots, connect via SSH and execute the initial system updates and firewall setup.

sudo apt-get update && sudo apt-get upgrade -y
sudo apt-get install -y curl git ufw jq CA-certificates gnupg
Enter fullscreen mode Exit fullscreen mode

Next, configure the Uncomplicated Firewall (UFW) to enforce a strict security profile. Block all incoming ports except standard SSH, HTTP, and HTTPS traffic.

sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow 22/tcp
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw enable
Enter fullscreen mode Exit fullscreen mode

Now, install the latest official Docker Engine and the Docker Compose plugin directly from Docker's upstream repository.

sudo install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
sudo chmod a+r /etc/apt/keyrings/docker.gpg

echo \
  "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu \
  $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | \
  sudo tee /etc/apt/sources.list.d/docker.list > /dev/null

sudo apt-get update
sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
Enter fullscreen mode Exit fullscreen mode

Verify that the Docker daemon is active and configured to launch on boot.

sudo systemctl enable --now docker
sudo usermod -aG docker $USER
Enter fullscreen mode Exit fullscreen mode

Create a designated working directory on your Hetzner host where your deployment stacks and configuration files will reside.

mkdir -p /opt/automation-stack/caddy_data
mkdir -p /opt/automation-stack/caddy_config
mkdir -p /opt/automation-stack/postgres_data
mkdir -p /opt/automation-stack/n8n_data
cd /opt/automation-stack
Enter fullscreen mode Exit fullscreen mode

Deploying n8n with Docker Compose and PostgreSQL

While SaaS options like Make.com offer managed convenience, running self-hosted n8n Cloud alternative software gives you full control over data privacy, execution limits, and custom integrations.

For production workloads, running n8n with an embedded SQLite database is insufficient due to lock contention during parallel workflow executions. We will deploy n8n backed by a robust PostgreSQL container.

To ensure container stability and automatic recovery, we integrate health checks directly into the orchestration file. If you want to dive deeper into container readiness, review our guide on Configuring Docker Compose Container Health Checks.

Create the docker-compose.yml file in /opt/automation-stack/docker-compose.yml:

version: '3.8'

networks:
  automation_net:
    driver: bridge

volumes:
  postgres_storage:
  n8n_storage:
  caddy_data:
  caddy_config:

services:
  postgres:
    image: postgres:15-alpine
    container_name: postgres_db
    restart: always
    networks:
      - automation_net
    environment:
      POSTGRES_USER: n8n_db_user
      POSTGRES_PASSWORD: StrongProductionPassword987!
      POSTGRES_DB: n8n_database
    volumes:
      - postgres_storage:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U n8n_db_user -d n8n_database"]
      interval: 10s
      timeout: 5s
      retries: 5
      start_period: 10s

  n8n:
    image: docker.n8n.io/n8nio/n8n:latest
    container_name: n8n_engine
    restart: always
    networks:
      - automation_net
    ports:
      - "127.0.0.1:5678:5678"
    environment:
      - DB_TYPE=postgresdb
      - DB_POSTGRESDB_HOST=postgres
      - DB_POSTGRESDB_PORT=5432
      - DB_POSTGRESDB_DATABASE=n8n_database
      - DB_POSTGRESDB_USER=n8n_db_user
      - DB_POSTGRESDB_PASSWORD=StrongProductionPassword987!
      - N8N_HOST=automation.example.com
      - N8N_PORT=5678
      - N8N_PROTOCOL=https
      - NODE_ENV=production
      - WEBHOOK_URL=https://automation.example.com/
      - EXECUTIONS_DATA_PRUNE=true
      - EXECUTIONS_DATA_MAX_AGE=168
      - EXECUTIONS_DATA_PRUNE_MAX_COUNT=50000
      - GENERIC_TIMEZONE=UTC
    volumes:
      - n8n_storage:/home/node/.n8n
    depends_on:
      postgres:
        condition: service_healthy
    healthcheck:
      test: ["CMD", "wget", "--spider", "-q", "http://localhost:5678/healthz"]
      interval: 15s
      timeout: 5s
      retries: 3
      start_period: 30s

  caddy:
    image: caddy:2-alpine
    container_name: caddy_proxy
    restart: always
    networks:
      - automation_net
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile
      - caddy_data:/data
      - caddy_config:/config
    depends_on:
      - n8n
Enter fullscreen mode Exit fullscreen mode

Start the container ecosystem in detached mode:

docker compose up -d
Enter fullscreen mode Exit fullscreen mode

Check the startup status of the container stack:

docker compose ps
Enter fullscreen mode Exit fullscreen mode

💡 Fast-Track Your Project: Don't want to configure this yourself? I build custom n8n pipelines and bots. Message me with code SYS3-DEVTO.


Configuring Caddy Reverse Proxy and SSL

With the services defined, we must safely route public web traffic to our n8n instance using encrypted TLS tunnels. You can purchase your base domain through Namecheap and point an A Record containing your host's public IP address directly to your server (for example, automation.example.com).

We use Caddy as our reverse proxy because it handles Automatic Certificate Management Environment (ACME) challenges seamlessly, provision TLS certificates from Let's Encrypt without external cron jobs or Certbot scripts.

Create the configuration file at /opt/automation-stack/Caddyfile:

automation.example.com {
    encode gzip zstd

    header {
        Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
        X-Content-Type-Options "nosniff"
        X-Frame-Options "SAMEORIGIN"
        X-XSS-Protection "1; mode=block"
        Referrer-Policy "no-referrer-when-downgrade"
    }

    reverse_proxy n8n:5678 {
        header_up Host {host}
        header_up X-Real-IP {remote_host}
        header_up X-Forwarded-For {remote_host}
        header_up X-Forwarded-Proto {scheme}
    }
}
Enter fullscreen mode Exit fullscreen mode

Reload the running Caddy service to pick up the updated directives:

docker compose exec -T caddy caddy reload --config /etc/caddy/Caddyfile
Enter fullscreen mode Exit fullscreen mode

You can now test your domain in a Web browser. Caddy automatically provisions standard TLS certificates and transparently proxies traffic down to n8n over the internal Docker network (automation_net).


Monitoring, Log Aggregation, and Automated Backups

A production deployment requires log aggregation and back-up schedules for disaster recovery. If you are operating multiple services, review our full guide on Configuring Centralized Log Aggregation with Grafana Loki to ship your container outputs into centralized dashboards.

Creating an Automated PostgreSQL Backup Script

We will write a robust Python utility that connects to our database container, executes a database dump, compresses the output file, and enforces data retention by deleting files older than 14 days.

Create the backup script file at /opt/automation-stack/db_backup.py:

import os
import subprocess
import datetime
import glob

BACKUP_DIR = "/opt/automation-stack/backups"
CONTAINER_NAME = "postgres_db"
DB_USER = "n8n_db_user"
DB_NAME = "n8n_database"
RETENTION_DAYS = 14

def run_backup():
    if not os.path.exists(BACKUP_DIR):
        os.makedirs(BACKUP_DIR, mode=0o750)

    timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
    filename = f"n8n_backup_{timestamp}.sql.gz"
    filepath = os.path.join(BACKUP_DIR, filename)

    dump_cmd = (
        f"docker exec {CONTAINER_NAME} pg_dump -U {DB_USER} {DB_NAME} | gzip > {filepath}"
    )

    print(f"Starting backup: {filepath}")
    result = subprocess.run(dump_cmd, shell=True, capture_output=True, text=True)

    if result.returncode == 0:
        print("Database dump successfully created.")
    else:
        print(f"Error executing database dump: {result.stderr}")
        return

    cleanup_old_backups()

def cleanup_old_backups():
    now = datetime.datetime.now()
    pattern = os.path.join(BACKUP_DIR, "n8n_backup_*.sql.gz")
    backup_files = glob.glob(pattern)

    for file_path in backup_files:
        file_time = datetime.datetime.fromtimestamp(os.path.getmtime(file_path))
        age_days = (now - file_time).days
        if age_days > RETENTION_DAYS:
            try:
                os.remove(file_path)
                print(f"Removed expired backup: {file_path}")
            except OSError as e:
                print(f"Failed to remove file {file_path}: {e}")

if __name__ == "__main__":
    run_backup()
Enter fullscreen mode Exit fullscreen mode

Make the script executable:

chmod +x /opt/automation-stack/db_backup.py
Enter fullscreen mode Exit fullscreen mode

Automation via Systemd Service and Timer

Instead of relying on legacy cron daemons, we schedule our Python script using Systemd units for improved monitoring and process isolation. To learn more about native Linux scheduling, read our guide on How to Schedule Python Scripts With Systemd.

Create the systemd service unit file at /etc/systemd/system/n8n-backup.service:

[Unit]
Description=Automated PostgreSQL Backup for n8n Engine
After=docker.service
Requires=docker.service

[Service]
Type=oneshot
User=root
WorkingDirectory=/opt/automation-stack
ExecStart=/usr/bin/python3 /opt/automation-stack/db_backup.py
StandardOutput=journal
StandardError=journal

[Install]
WantedBy=multi-user.target
Enter fullscreen mode Exit fullscreen mode

Create the corresponding systemd timer unit file at /etc/systemd/system/n8n-backup.timer:

[Unit]
Description=Run n8n PostgreSQL Backup Daily at 02:00 UTC

[Timer]
OnCalendar=*-*-* 02:00:00
Persistent=true

[Install]
WantedBy=timers.target
Enter fullscreen mode Exit fullscreen mode

Reload the systemd manager configuration, enable the timer, and verify its target schedule execution:

sudo systemctl daemon-reload
sudo systemctl enable --now n8n-backup.timer
sudo systemctl list-timers --all | grep n8n-backup
Enter fullscreen mode Exit fullscreen mode

You can run an immediate dry run execution of the backup unit to confirm system functionality:

sudo systemctl start n8n-backup.service
sudo journalctl -u n8n-backup.service -n 20 --no-pager
Enter fullscreen mode Exit fullscreen mode

This stack gives you high-performance, self-hosted workflow execution on Hetzner VPS. It includes database isolation, HTTPS proxying via Caddy, automated database health management, and systemd backup tasks.


Getting Started

To implement this workflow infrastructure, acquire your base dependencies:

Outsource Your Automation

Don't have time? I build production n8n workflows, WhatsApp bots, and fully automated YouTube Shorts pipelines. Hire me on Fiverr, mention SYS3-DEVTO for priority. Or DM at chasebot.online.


Originally published on Automation Insider.

Top comments (0)