Self-Hosted Workflow Automation vs Cloud SaaS Platforms

Workflow Automation #tutorial#devops#self-hosting#cost-comparison
Self-Hosted Workflow Automation vs Cloud SaaS Platforms

What You’ll Need


Table of Contents


The Core Difference: Where Your Data Actually Lives

I’ve been automating workflows for three years now, and the first question I ask clients isn’t “What do you want to automate?” It’s “Where does your data need to live?”

That single question determines everything.

Self-hosted automation means your workflows run on your servers. Cloud SaaS means a vendor manages the infrastructure. It sounds simple, but this distinction ripples through cost, security, performance, and control in ways most people don’t discover until they’ve already committed.

I’m not going to pretend one is universally better. I’ve built production systems on both. What I will do is show you exactly what you’re trading off, with real numbers and working code, so you can make an informed decision based on your actual constraints—not marketing copy.


Self-Hosted Automation: Full Control, Full Responsibility

Self-hosting means running something like n8n on your own VPS. I’ve done this extensively. Here’s what you actually get.

What Self-Hosting Gives You

Complete data control. Your workflow data, logs, and execution history never leave your infrastructure. If you process customer PII, healthcare data, or anything compliance-sensitive, this matters enormously.

Zero usage-based costs. You pay for the server, not per workflow execution or API call. I wrote a detailed guide on how I run 3 automated systems on a single $7/month VPS , and that’s the real economics here.

Customization without limits. You can modify the source code, add custom nodes, integrate with internal systems, use private webhooks—whatever you need.

No vendor lock-in. Your workflows are portable. If your host changes pricing or shuts down, you migrate to another server.

The Honest Trade-offs

You handle infrastructure. Need SSL certificates? You set them up. Server crashes at 3 AM? You fix it. Database backups? Your responsibility. This isn’t theoretical—downtime directly impacts your automation.

Scaling isn’t automatic. Adding capacity means manually provisioning more resources or upgrading your plan.

Security is on you. Patches, updates, firewall rules, access controls—you’re the operator. One misconfigured rule can expose your entire setup.

Setting Up Self-Hosted n8n: A Working Example

Let me show you a practical setup using Docker on a Hetzner VPS . This assumes a clean Ubuntu 22.04 instance.

#!/bin/bash

# Update system packages
apt-get update
apt-get upgrade -y
apt-get install -y docker.io docker-compose curl git

# Create directories for persistent storage
mkdir -p /opt/n8n/data
mkdir -p /opt/n8n/logs

# Create docker-compose.yml
cat > /opt/n8n/docker-compose.yml << 'EOF'
version: '3.8'

services:
  postgres:
    image: postgres:15-alpine
    container_name: n8n-postgres
    environment:
      POSTGRES_DB: n8n
      POSTGRES_USER: n8n
      POSTGRES_PASSWORD: your_secure_password_here_change_this
    volumes:
      - postgres_data:/var/lib/postgresql/data
    networks:
      - n8n_network
    restart: unless-stopped
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U n8n"]
      interval: 10s
      timeout: 5s
      retries: 5

  n8n:
    image: n8nio/n8n:latest
    container_name: n8n
    ports:
      - "5678:5678"
    environment:
      DB_TYPE: postgresdb
      DB_POSTGRESDB_HOST: postgres
      DB_POSTGRESDB_PORT: 5432
      DB_POSTGRESDB_DATABASE: n8n
      DB_POSTGRESDB_USER: n8n
      DB_POSTGRESDB_PASSWORD: your_secure_password_here_change_this
      N8N_HOST: your-domain.com
      N8N_PORT: 443
      N8N_PROTOCOL: https
      NODE_ENV: production
      WEBHOOK_TUNNEL_URL: https://your-domain.com/
      GENERIC_TIMEZONE: UTC
    volumes:
      - /opt/n8n/data:/home/node/.n8n
      - /opt/n8n/logs:/home/node/.n8n/logs
    depends_on:
      postgres:
        condition: service_healthy
    networks:
      - n8n_network
    restart: unless-stopped

  nginx:
    image: nginx:alpine
    container_name: n8n-nginx
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - /opt/n8n/nginx.conf:/etc/nginx/nginx.conf:ro
      - /opt/n8n/ssl:/etc/nginx/ssl:ro
    depends_on:
      - n8n
    networks:
      - n8n_network
    restart: unless-stopped

volumes:
  postgres_data:

networks:
  n8n_network:
    driver: bridge
EOF

# Create NGINX configuration
cat > /opt/n8n/nginx.conf << 'EOF'
user nginx;
worker_processes auto;
error_log /var/log/nginx/error.log warn;
pid /var/run/nginx.pid;

events {
    worker_connections 1024;
}

http {
    include /etc/nginx/mime.types;
    default_type application/octet-stream;

    log_format main '$remote_addr - $remote_user [$time_local] "$request" '
                    '$status $body_bytes_sent "$http_referer" '
                    '"$http_user_agent" "$http_x_forwarded_for"';

    access_log /var/log/nginx/access.log main;

    sendfile on;
    tcp_nopush on;
    tcp_nodelay on;
    keepalive_timeout 65;
    types_hash_max_size 2048;

    gzip on;
    gzip_vary on;
    gzip_proxied any;
    gzip_comp_level 6;
    gzip_types text/plain text/css text/xml text/javascript application/json application/javascript application/xml+rss;

    upstream n8n_backend {
        server n8n:5678;
    }

    server {
        listen 80;
        server_name your-domain.com;
        return 301 https://$server_name$request_uri;
    }

    server {
        listen 443 ssl http2;
        server_name your-domain.com;

        ssl_certificate /etc/nginx/ssl/cert.pem;
        ssl_certificate_key /etc/nginx/ssl/key.pem;
        ssl_protocols TLSv1.2 TLSv1.3;
        ssl_ciphers HIGH:!aNULL:!MD5;
        ssl_prefer_server_ciphers on;

        client_max_body_size 50M;

        location / {
            proxy_pass http://n8n_backend;
            proxy_http_version 1.1;
            proxy_set_header Upgrade $http_upgrade;
            proxy_set_header Connection "upgrade";
            proxy_set_header Host $host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header X-Forwarded-Proto $scheme;
            proxy_redirect off;
            proxy_buffering off;
        }
    }
}
EOF

# Generate self-signed SSL certificate (replace with Let's Encrypt later)
mkdir -p /opt/n8n/ssl
openssl req -x509 -newkey rsa:4096 -nodes -out /opt/n8n/ssl/cert.pem -keyout /opt/n8n/ssl/key.pem -days 365 -subj "/C=US/ST=State/L=City/O=Organization/CN=your-domain.com"

# Start services
cd /opt/n8n
docker-compose up -d

# Wait for services to start
sleep 10

# Check status
docker-compose ps

echo "N8N is starting. Access at https://your-domain.com (accept self-signed cert for now)"
echo "Replace self-signed certificate with Let's Encrypt when ready"

After this setup, you have a fully functional n8n instance with PostgreSQL persistence, NGINX reverse proxy, and SSL. Replace the placeholder passwords and domain name before running.

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

Building a Self-Hosted Workflow: Complete Example

Let me show you a practical self-hosted workflow. This example pulls data from an API, processes it, and sends it to multiple destinations. Since this runs on your server, you control every execution step.

{
  "nodes": [
    {
      "parameters": {
        "url": "https://api.example.com/data",
        "authentication": "genericCredentialType",
        "genericCredentials": "api_key_credential",
        "requestMethod": "GET"
      },
      "name": "Fetch Data",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.1,
      "position": [250, 300]
    },
    {
      "parameters": {
        "operation": "filter",
        "filterType": "string",
        "conditions": {
          "conditions": [
            {
              "keyName": "status",
              "condition": "equals",
              "value": "active"
            }
          ]
        }
      },
      "name": "Filter Active Records",
      "type": "n8n-nodes-base.filter",
      "typeVersion": 1,
      "position": [450, 300]
    },
    {
      "parameters": {
        "fieldToSplitOut": "items",
        "include": "selectedFields",
        "selectedFields": {
          "item": {
            "id": {},
            "name": {},
            "value": {}
          }
        }
      },
      "name": "Split Data",
      "type": "n8n-nodes-base.splitInBatches",
      "typeVersion": 3,
      "position": [650, 300]
    },
    {
      "parameters": {
        "resource": "message",
        "operation": "create",
        "chatId": "your_channel_id",
        "text": "=Record: {{$node[\"Split Data\"].json.id}} - {{$node[\"Split Data\"].json.name}} - Value:

Want to automate this yourself?

Start with n8n Cloud (free tier available) or self-host on a Hetzner VPS for full control.

📬 Get Weekly Automation Tips

One email per week with tutorials, tools, and workflows. No spam, unsubscribe anytime.

Subscribe Free →