Every team eventually hits the ceiling of SaaS automation tools. Pricing tiers, data residency rules, or the need to run scripts against internal resources that never touch the public internet. Windmill is an open-source developer platform that turns scripts into webhooks, workflows, and internal apps. It runs on your hardware, keeps your data in your database, and scales from a single VPS to a multi-node Kubernetes cluster without changing your mental model.
This guide walks through a complete Windmill self-host setup using Docker Compose. You will have a working instance with custom worker groups, domain configuration, and basic security hardening. For advanced patterns like Git sync, high availability, and enterprise governance, see our deep-dive guides on Git Sync and Enterprise Workflow Patterns, Running Scripts and Automating Workflows, and High Availability and Disaster Recovery.
What You Need Before Starting
Windmill is more resource-hungry than a simple API server because it executes arbitrary code in workers. Plan accordingly:
- A Linux server with at least 2 vCPUs and 4 GB RAM (4 vCPUs / 8 GB for production workloads)
- Docker Engine 24.x+ and Docker Compose v2 installed
- At least 20 GB free disk space (more if you run heavy dependency caches)
- A domain or subdomain with DNS pointing to your server
- A valid TLS certificate (Let's Encrypt is fine) or a reverse proxy handling termination
- Basic familiarity with Docker Compose, environment files, and Linux permissions
Windmill stores its entire state in PostgreSQL. Everything else is stateless, which makes backups and scaling straightforward.
Project Structure and Initial Files
Windmill publishes official Docker Compose, Caddyfile, and environment templates. Start by pulling them into a dedicated project directory:
sudo mkdir -p /opt/windmill
sudo chown $USER:$USER /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
These three files are the foundation. The Compose file defines the server, workers, database, LSP service, and Caddy reverse proxy. The Caddyfile routes traffic to the correct services. The .env file holds configuration variables.
Environment and Domain Configuration
Open the .env file and set the essentials. The most critical value is BASE_URL, which Windmill uses to generate webhook URLs, email links, and OAuth redirects.
# Windmill image tag
WM_IMAGE=ghcr.io/windmill-labs/windmill:main
# Public URL where Windmill is reachable
BASE_URL=https://windmill.example.com
# Database connection string
DATABASE_URL=postgres://postgres:changeme@db/windmill?sslmode=disable
# Number of worker replicas (adjust to your CPU count)
NUM_WORKERS=2
# Disable user signup after initial setup
SIGNUP_ENABLED=false
Change the default PostgreSQL password. If you skip this, anyone who can reach your database container owns your instance:
# Generate a strong password
DB_PASSWORD=$(openssl rand -hex 24)
echo "PostgreSQL password: $DB_PASSWORD"
# Update .env with the new password
sed -i "s/changeme/$DB_PASSWORD/g" .env
Store that password in your password manager. You will need it if you ever connect to the database directly for backups or debugging.
Starting the Stack and First Login
Launch everything in detached mode:
docker compose up -d
Wait about 30 seconds for the database to initialize and migrations to run. Then verify the services:
docker compose ps
docker compose logs --tail 50 windmill_server
On first boot, Windmill creates a default admin user. The credentials are printed in the server logs:
docker compose logs windmill_server | grep "Default admin user"
Log in at https://windmill.example.com with those credentials, then immediately change the password and enable multi-factor authentication from the account settings.
Configuring Worker Groups and Resource Limits
Workers are where your scripts actually run. By default, all jobs go to the generic worker pool. For production, you want specialized workers with different resource profiles. Edit docker-compose.yml to add a native worker group for lightweight in-process jobs:
windmill_worker_native:
image: ${WM_IMAGE}
pull_policy: always
deploy:
replicas: 2
resources:
limits:
cpus: '0.5'
memory: 512M
restart: unless-stopped
environment:
- DATABASE_URL=${DATABASE_URL}
- MODE=worker
- WORKER_GROUP=native
- NATIVE_MODE=true
networks:
- windmill
depends_on:
db:
condition: service_healthy
Native workers run scripts in-process without spawning containers, making them ideal for fast Python or TypeScript jobs that do not need system-level isolation. Heavy jobs like browser automation or Docker builds should stay on the default worker group.
Apply the changes with a rolling restart:
docker compose up -d --scale windmill_worker_native=2
Custom Domain and External Reverse Proxy
The bundled Caddy service works for quick tests, but production setups usually bring their own reverse proxy. If you terminate TLS at Cloudflare, NGINX, or Traefik, disable the Caddy container and expose the Windmill server directly:
# In docker-compose.yml, set caddy replicas to 0 or remove the service
# Expose windmill_server on a local port instead
windmill_server:
ports:
- "127.0.0.1:8000:8000"
Then configure your reverse proxy to forward to 127.0.0.1:8000. Here is a minimal NGINX snippet:
server {
listen 443 ssl http2;
server_name windmill.example.com;
ssl_certificate /path/to/fullchain.pem;
ssl_certificate_key /path/to/privkey.pem;
location / {
proxy_pass http://127.0.0.1:8000;
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;
}
location /ws/ {
proxy_pass http://127.0.0.1:3001;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
}
The /ws/ path serves the LSP (language server protocol) for Monaco editor intellisense. Without it, the code editor still works, but autocomplete and diagnostics disappear.
Backups and Updates
Database Backups
Since all state lives in PostgreSQL, backing up is simple. Run this daily via cron:
#!/bin/bash
BACKUP_DIR="/opt/backups/windmill"
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
mkdir -p $BACKUP_DIR
docker exec windmill-db-1 pg_dump -U postgres windmill \
| gzip > $BACKUP_DIR/windmill_$TIMESTAMP.sql.gz
# Retain last 14 days
find $BACKUP_DIR -name '*.sql.gz' -mtime +14 -delete
Updating Windmill
Updates are pull-and-recreate. Windmill handles graceful worker shutdown so in-flight jobs finish before containers restart:
docker compose pull
docker compose up -d
docker compose logs --tail 30 windmill_server
Check the release notes before major version bumps. Breaking changes are rare but documented.
Tips and Troubleshooting
Database migration fails on first start
If you see errors about missing roles, your PostgreSQL user likely lacks superuser privileges. This happens on managed databases like RDS, Cloud SQL, or Azure Database. Run the initialization script with an admin role first:
curl -sSL https://raw.githubusercontent.com/windmill-labs/windmill/main/init-db-as-superuser.sql -o init.sql
psql "$ADMIN_DATABASE_URL" -f init.sql
# Grant roles to your application user
psql "$DATABASE_URL" -c "GRANT windmill_admin TO app_user;"
psql "$DATABASE_URL" -c "GRANT windmill_user TO app_user;"
Workers are not picking up jobs
Check that workers can reach the database and that their WORKER_GROUP matches the tags on your jobs. Use the Windmill UI under Workers to see connected workers and their assigned groups.
High memory usage
Windmill caches language dependencies in worker volumes. If disk or memory balloons, prune the cache:
docker system prune -a --volumes
# Or target just Windmill builder cache
docker builder prune
Email triggers not working
Windmill listens on port 2525 inside the container for SMTP email triggers. Ensure your Caddyfile or reverse proxy forwards TCP port 25 to this internal port.
SSO login redirects fail
Confirm BASE_URL exactly matches your public domain, including the protocol. A mismatch of even a trailing slash breaks OAuth redirect validation.
Next Steps
You now have a self-hosted Windmill instance running on your own infrastructure. From here, you can build scheduled jobs, webhook endpoints, approval workflows, and internal tools without shipping data to a third party.
For deeper configurations, explore our advanced guides:
- Windmill Self-Host Setup: Git Sync, Worker Groups, App Builder, and Enterprise Workflow Patterns
- Windmill Self-Host Setup: Run Scripts and Automate Workflows on Your Own Infrastructure
- Windmill Self-Host Setup: High Availability, Multi-Workspace Governance, Custom Runtimes, and Disaster Recovery
Need help architecting multi-worker deployments, integrating with your identity provider, or scaling to high-availability Kubernetes? Contact our team for enterprise Windmill consulting and managed infrastructure services.