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
| Project | Windmill (Community Edition) |
| License | AGPLv3 |
| Recommended Plan | RamNode Cloud VPS 4 vCPU / 8 GB (sweet spot for small teams) |
| Architecture | Server + workers + Postgres queue |
| Stack | Docker Compose, Caddy (auto-TLS), Postgres 16 |
| Estimated Setup Time | 45–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
Provision and Harden the VPS
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_keysPermitRootLogin no
PasswordAuthentication no
PubkeyAuthentication yesapt 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=10Install Docker Engine and Compose
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 deployLog out and back in for the group change. Verify with docker compose version and docker run --rm hello-world.
Pull the Windmill Compose Stack
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 .envThe 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.
Configure Environment Variables and Secrets
PG_PASSWORD=$(openssl rand -base64 32 | tr -d '/+=' | cut -c1-32)
echo "Generated password: $PG_PASSWORD"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=10Replace POSTGRES_PASSWORD: changeme in the db service of docker-compose.yml with the same password, then lock the env file:
chmod 600 .envConfigure Caddy and Your Domain
Point an A record (e.g. windmill.example.com) at the VPS, then verify DNS:
dig +short windmill.example.comcaddy:
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.comThe 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.
First Boot
docker compose pull
docker compose up -d
docker compose logs -f --tail=100First 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.
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:
windmill_worker:
deploy:
replicas: 2
resources:
limits:
memory: 1536M
windmill_worker_native:
deploy:
replicas: 1
resources:
limits:
memory: 1024MFor 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.
shared_buffers = 256MB
effective_cache_size = 768MB
work_mem = 8MB
maintenance_work_mem = 64MBBackups
Windmill stores its entire state in Postgres. A nightly logical dump is enough to restore everything.
#!/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 -deletechmod +x /opt/windmill/backup.sh
sudo crontab -e
# Add:
30 3 * * * /opt/windmill/backup.sh >> /var/log/windmill-backup.log 2>&1docker 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_serverUpdates
cd /opt/windmill
/opt/windmill/backup.sh
docker compose pull
docker compose up -dMigrations 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_URLpassword or Postgres still initializing — checkdocker 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_GROUPvalues - "No worker available": at least one worker must advertise the tag the job requires
