Workflow Automation
    Open Source (AGPLv3)

    Deploy Windmill on a VPS

    Turn TypeScript, Python, Go, Bash, and SQL scripts into webhooks, scheduled jobs, multi-step flows, and internal apps — all from one self-hosted platform that replaces n8n, cron hosts, and Retool in a single stack.

    At a Glance

    ProjectWindmill (Community Edition)
    LicenseAGPLv3
    Recommended PlanRamNode Cloud VPS 4 vCPU / 8 GB (sweet spot for small teams)
    ArchitectureServer + workers + Postgres queue
    StackDocker Compose, Caddy (auto-TLS), Postgres 16
    Estimated Setup Time45–60 minutes

    Prerequisites

    • A RamNode KVM VPS with Ubuntu 24.04 LTS (4 vCPU / 8 GB recommended)
    • Root or sudo access via SSH key
    • A DNS A record (e.g. windmill.example.com) pointing at the VPS
    • Ports 80, 443 open at any external firewall
    1

    Provision and Harden the VPS

    Create deploy user
    adduser deploy
    usermod -aG sudo deploy
    mkdir -p /home/deploy/.ssh
    cp ~/.ssh/authorized_keys /home/deploy/.ssh/authorized_keys
    chown -R deploy:deploy /home/deploy/.ssh
    chmod 700 /home/deploy/.ssh && chmod 600 /home/deploy/.ssh/authorized_keys
    Lock down SSH (/etc/ssh/sshd_config)
    PermitRootLogin no
    PasswordAuthentication no
    PubkeyAuthentication yes
    Firewall, fail2ban, swap
    apt update && apt upgrade -y
    apt install -y ufw fail2ban curl ca-certificates gnupg lsb-release htop
    ufw default deny incoming && ufw default allow outgoing
    ufw allow OpenSSH && ufw allow 80/tcp && ufw allow 443/tcp && ufw enable
    systemctl enable --now fail2ban
    
    fallocate -l 2G /swapfile && chmod 600 /swapfile
    mkswap /swapfile && swapon /swapfile
    echo '/swapfile none swap sw 0 0' >> /etc/fstab
    sysctl vm.swappiness=10
    2

    Install Docker Engine and Compose

    Add Docker's official repo
    install -m 0755 -d /etc/apt/keyrings
    curl -fsSL https://download.docker.com/linux/ubuntu/gpg \
      | gpg --dearmor -o /etc/apt/keyrings/docker.gpg
    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 $(lsb_release -cs) stable" \
      > /etc/apt/sources.list.d/docker.list
    
    apt update
    apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
    usermod -aG docker deploy

    Log out and back in for the group change. Verify with docker compose version and docker run --rm hello-world.

    3

    Pull the Windmill Compose Stack

    Create directory and pull official files
    sudo mkdir -p /opt/windmill && sudo chown deploy:deploy /opt/windmill
    cd /opt/windmill
    
    curl -sSL https://raw.githubusercontent.com/windmill-labs/windmill/main/docker-compose.yml -o docker-compose.yml
    curl -sSL https://raw.githubusercontent.com/windmill-labs/windmill/main/Caddyfile -o Caddyfile
    curl -sSL https://raw.githubusercontent.com/windmill-labs/windmill/main/.env -o .env

    The default stack ships Postgres 16, the Windmill server, three default workers, one native worker, the Monaco LSP, Caddy (with the L4 module for SMTP), and a Docker-in-Docker sidecar that the workers use as their daemon — keep DinD enabled for the security boundary.

    4

    Configure Environment Variables and Secrets

    Generate a strong Postgres password
    PG_PASSWORD=$(openssl rand -base64 32 | tr -d '/+=' | cut -c1-32)
    echo "Generated password: $PG_PASSWORD"
    Edit .env
    DATABASE_URL=postgres://postgres:YOUR_GENERATED_PASSWORD@db/windmill?sslmode=disable
    WM_IMAGE=ghcr.io/windmill-labs/windmill:main
    LOG_MAX_SIZE=20m
    LOG_MAX_FILE=10

    Replace POSTGRES_PASSWORD: changeme in the db service of docker-compose.yml with the same password, then lock the env file:

    Lock down secrets
    chmod 600 .env
    5

    Configure Caddy and Your Domain

    Point an A record (e.g. windmill.example.com) at the VPS, then verify DNS:

    Verify DNS
    dig +short windmill.example.com
    Caddy service in docker-compose.yml
    caddy:
      image: ghcr.io/windmill-labs/caddy-l4:latest
      restart: unless-stopped
      volumes:
        - ./Caddyfile:/etc/caddy/Caddyfile
        - caddy_data:/data
      ports:
        - 80:80
        - 25:25
        - 443:443
      environment:
        - BASE_URL=windmill.example.com

    The shipped Caddyfile honors BASE_URL and provisions a Let's Encrypt cert via HTTP-01. The caddy_data volume persists certs across restarts — essential to avoid rate limits during testing.

    6

    First Boot

    Bring the stack up
    docker compose pull
    docker compose up -d
    docker compose logs -f --tail=100

    First boot pulls 2–3 GB of images. Wait for the Postgres healthcheck to pass and the server to log listening on 0.0.0.0:8000. Migrations run automatically on first start (30–60 s). Browse to https://windmill.example.com and create the superadmin account — store the password in your password manager.

    7

    Resource Tuning for a RamNode VPS

    The defaults are sized for a generous instance and will overcommit RAM on a 4 vCPU / 8 GB plan. Tune workers down:

    Worker tuning in docker-compose.yml
    windmill_worker:
      deploy:
        replicas: 2
        resources:
          limits:
            memory: 1536M
    
    windmill_worker_native:
      deploy:
        replicas: 1
        resources:
          limits:
            memory: 1024M

    For 2 vCPU / 4 GB: drop to one default worker at 1 GB and one native worker at 768 MB; consider disabling the LSP via ENABLE_LSP=false.

    Postgres tuning (postgresql.conf)
    shared_buffers = 256MB
    effective_cache_size = 768MB
    work_mem = 8MB
    maintenance_work_mem = 64MB
    8

    Backups

    Windmill stores its entire state in Postgres. A nightly logical dump is enough to restore everything.

    /opt/windmill/backup.sh
    #!/usr/bin/env bash
    set -euo pipefail
    
    BACKUP_DIR="/opt/windmill/backups"
    TIMESTAMP=$(date +%Y%m%d-%H%M%S)
    RETENTION_DAYS=14
    
    mkdir -p "$BACKUP_DIR"
    
    docker compose -f /opt/windmill/docker-compose.yml exec -T db \
      pg_dump -U postgres -F c -d windmill \
      > "$BACKUP_DIR/windmill-$TIMESTAMP.dump"
    
    gzip "$BACKUP_DIR/windmill-$TIMESTAMP.dump"
    find "$BACKUP_DIR" -name 'windmill-*.dump.gz' -mtime +$RETENTION_DAYS -delete
    Schedule it
    chmod +x /opt/windmill/backup.sh
    sudo crontab -e
    # Add:
    30 3 * * * /opt/windmill/backup.sh >> /var/log/windmill-backup.log 2>&1
    Restore
    docker compose exec -T db psql -U postgres -c "DROP DATABASE windmill;"
    docker compose exec -T db psql -U postgres -c "CREATE DATABASE windmill;"
    gunzip -c windmill-YYYYMMDD-HHMMSS.dump.gz | \
      docker compose exec -T db pg_restore -U postgres -d windmill
    docker compose restart windmill_server
    9

    Updates

    Three-command update
    cd /opt/windmill
    /opt/windmill/backup.sh
    docker compose pull
    docker compose up -d

    Migrations run automatically on boot. For production, pin WM_IMAGE to a specific version (e.g. ghcr.io/windmill-labs/windmill:v1.500.0) instead of riding the :main tag.

    Operational Touches

    • SSO: configure Google/Azure/GitHub from Instance Settings once you pass two users
    • BASE_URL on the server: required for correct webhook URL generation
    • Worker queue monitor: if jobs pile up, add replicas (docker compose up -d) or scale horizontally
    • FAVOR_UNSHARE_PID: keep enabled — prevents user scripts from inspecting the worker process tree
    • Disk on db_data and worker_dependency_cache: review retention to keep history bounded

    Troubleshooting

    • Server fails to start: wrong DATABASE_URL password or Postgres still initializing — check docker compose logs db
    • Caddy can't get a cert: verify ports 80/443 open in UFW, DNS resolves, nothing else binds 80
    • Workers don't register: check DB connectivity and conflicting WORKER_GROUP values
    • "No worker available": at least one worker must advertise the tag the job requires