Why Self-Host Windmill?
Windmill is an open-source developer platform that turns scripts into webhooks, scheduled jobs, internal UIs, and multi-step workflows. It supports Python, TypeScript, Go, Bash, SQL, Rust, and more — all running on a stack you own.
The managed cloud is fine for prototyping. But once you're automating real infrastructure, running jobs against private databases, or dealing with compliance requirements, self-hosting is the move. You control the data, the secrets, the runtime, and the scale.
This guide walks you through a complete Windmill self-host setup: from a working Docker Compose deployment to writing scripts, building workflows, wiring triggers, and debugging when things go wrong.
Looking to go further after this guide? Check out our deeper dives:
• Git Sync, Worker Groups, App Builder, and Enterprise Workflow Patterns
• AI-Powered Workflows, Native Integrations, Audit Logging, and Scaling to Production
• High Availability, Multi-Workspace Governance, Custom Runtimes, and Disaster Recovery
Prerequisites
Before you start, make sure you have the following in place.
System requirements
- OS: Linux (Ubuntu 22.04+ recommended), macOS, or Windows with WSL2
- CPU: 2 vCPU minimum; 4+ vCPU for production
- RAM: 4 GB minimum; 8 GB recommended for multiple worker replicas
- Disk: 20 GB free for containers, logs, and PostgreSQL data
Software dependencies
- Docker Engine 24+ and Docker Compose v2
- A domain name or hostname if you want TLS (optional for local dev)
curlandgiton the host
Confirm Docker is running before proceeding:
docker --version
docker compose version
Section 1: Deploy Windmill with Docker Compose
The fastest path to a working Windmill self-host setup is Docker Compose. Three files spin up the entire stack: the Windmill server, workers, PostgreSQL, the LSP for in-browser editor intelligence, and a Caddy reverse proxy.
Download the official config files
curl https://raw.githubusercontent.com/windmill-labs/windmill/main/docker-compose.yml -o docker-compose.yml
curl https://raw.githubusercontent.com/windmill-labs/windmill/main/Caddyfile -o Caddyfile
curl https://raw.githubusercontent.com/windmill-labs/windmill/main/.env -o .env
Start the stack
docker compose up -d
That's it. Open http://localhost in your browser. The default admin credentials are:
- Email:
[email protected] - Password:
changeme
Change these immediately in Settings → Users once you're logged in.
What just started
Docker Compose brings up six services:
windmill_server— serves the frontend and REST APIwindmill_worker— executes jobs pulled from the PostgreSQL queuewindmill_worker_native— dedicated worker for native jobs (lighter workloads)db— PostgreSQL 15, holding all state: scripts, flows, job history, secretslsp— language server for Monaco editor intellisensecaddy— reverse proxy routing HTTP/HTTPS traffic
Key environment variables in .env
Open .env and review these before going to production:
# Your public-facing domain (critical for webhook URL generation)
BASE_URL=https://windmill.yourdomain.com
# PostgreSQL connection string (change the default password!)
DATABASE_URL=postgres://windmill:windmill@db/windmill
# Pin to a specific version in production
WM_IMAGE=ghcr.io/windmill-labs/windmill:v1.500.0
# Number of worker replicas
NUM_WORKERS=3
Always set BASE_URL to your real domain before exposing Windmill to the internet. Webhook URLs, OAuth redirects, and email triggers all derive from it.
Using an external PostgreSQL database
For production, swap the bundled db service for a managed database (RDS, Cloud SQL, Neon, etc.). Set the connection string and disable the local db service:
# .env
DATABASE_URL=postgres://windmill_user:yourpassword@your-rds-host:5432/windmill
# docker-compose.yml — scale db to 0 replicas
# (or remove the db service entirely and delete the db volume reference)
If your managed Postgres user is not a superuser (common on Scaleway, Azure, GCP Cloud SQL, and AWS RDS), you need to pre-create the Windmill roles before first boot:
curl https://raw.githubusercontent.com/windmill-labs/windmill/main/init-db-as-superuser.sql \
-o init-db-as-superuser.sql
psql YOUR_PRIVILEGED_DATABASE_URL -f init-db-as-superuser.sql
Skip this step and you'll hit role "windmill_admin" does not exist on startup.
Section 2: Writing Your First Scripts
A Windmill script is a function with typed parameters. Windmill reads the signature, auto-generates a UI for it, logs every run, and can trigger it from any channel. No boilerplate, no framework glue.
TypeScript script example
Navigate to Scripts → New Script. Pick TypeScript. Paste this:
// Send a Slack notification
export async function main(
webhook_url: string,
message: string,
channel: string = "#alerts"
) {
const response = await fetch(webhook_url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text: message, channel }),
});
if (!response.ok) {
throw new Error(`Slack returned ${response.status}`);
}
return { sent: true, channel };
}
Hit Run. Windmill renders input fields for webhook_url, message, and channel automatically. The result and logs appear inline.
Python script example
# Fetch and parse a JSON API endpoint
import httpx
from typing import Any
def main(url: str, timeout: int = 10) -> dict[str, Any]:
"""Fetch a JSON endpoint and return parsed data."""
with httpx.Client(timeout=timeout) as client:
response = client.get(url)
response.raise_for_status()
return {"status": response.status_code, "data": response.json()}
Windmill detects the import (httpx) and installs it automatically in an isolated environment. No requirements.txt needed.
Storing secrets as Windmill variables
Never hardcode credentials in scripts. Store them as Variables (Settings → Variables) and reference them by path:
// TypeScript — read a secret variable
import * as wmill from "windmill-client";
export async function main() {
// Fetches the value of the variable at path u/admin/slack_webhook
const webhookUrl = await wmill.getVariable("u/admin/slack_webhook");
// Use the variable...
return { loaded: !!webhookUrl };
}
Section 3: Building Multi-Step Workflows (Flows)
Flows are where Windmill gets powerful. Chain scripts into a DAG — with branching, loops, retries, approvals, and parallel execution — all from a drag-and-drop editor.
Anatomy of a flow
Each step in a flow is one of:
- Script step — runs an existing script or inline code
- Loop step — iterates over a list, running a step for each item (in parallel or sequentially)
- Branch step — conditional routing: run step A if condition X, step B otherwise
- Approval step — pauses the flow until a human approves or rejects via a link
- Inner flow — calls another flow as a sub-workflow
Passing data between steps
Each step receives the outputs of previous steps. Reference them with the result picker or directly in expressions:
// Step 2 — uses output from Step 1
// In the input binding for "user_id", reference:
results.step1.user_id
// Access the flow's top-level input:
flow_input.environment
Example: nightly data pipeline flow
A real-world pattern: fetch rows from a database, transform each one, then post a summary to Slack.
- Step 1 — SQL:
SELECT * FROM events WHERE created_at > NOW() - INTERVAL '1 day' - Step 2 — Loop: For each row, run a Python enrichment script
- Step 3 — TypeScript: Aggregate enriched results and POST to Slack webhook
The entire flow definition is stored as YAML under version control. You can export it, commit it, and re-import it across workspaces.
Section 4: Triggering Scripts and Flows
One of Windmill's biggest strengths: the same script or flow can be triggered from multiple channels simultaneously — no code changes required.
Schedules (cron)
Navigate to the script or flow → Schedule. Set a cron expression:
# Run every day at 2:30 AM UTC
30 2 * * *
# Run every 15 minutes
*/15 * * * *
# Run at 9 AM on weekdays
0 9 * * 1-5
Schedules support timezone overrides, argument pinning per schedule, and alerting on failure.
Webhooks
Every script and flow gets a webhook URL out of the box. Find it under the script → Triggers → Webhooks. Call it with a POST:
curl -X POST \
https://windmill.yourdomain.com/api/w/your-workspace/jobs/run/p/u/admin/your_script \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{"url": "https://api.example.com/data", "timeout": 30}'
Windmill returns a job_id immediately. Poll the job or set up a result callback to retrieve output.
CLI trigger
Install the wmill CLI and run scripts directly from your terminal or CI pipeline:
# Install the CLI
npm install -g windmill-cli
# Authenticate
wmill workspace add my-instance https://windmill.yourdomain.com YOUR_TOKEN
# Run a script by path
wmill script run u/admin/notify_slack \
--data '{"message": "Deploy complete", "channel": "#deploys"}'
Other trigger sources
Windmill supports a wide range of event-driven triggers natively:
- Postgres triggers — react to
INSERT/UPDATE/DELETEevents via logical replication - Kafka topics — consume messages and run a script per message
- SQS / MQTT / NATS — queue and pub/sub integrations
- WebSocket — maintain a live connection and fire scripts on messages
- Email triggers — send an email to a script-specific address to trigger it
- Custom HTTP routes — map any HTTP path and method to a script
Section 5: Scaling Workers and Managing Worker Groups
Workers are stateless. They connect to PostgreSQL, pull jobs off the queue, and execute them. Scaling is just adding replicas.
Adding more workers
# Scale to 5 worker replicas on the fly
docker compose up -d --scale windmill_worker=5
The rule of thumb: 1 worker per 1 vCPU, 1–2 GB RAM. Each worker handles one job at a time by default.
Worker groups for job routing
Worker groups let you route specific jobs to specific workers — useful for isolating GPU workloads, high-memory jobs, or jobs that need particular system dependencies.
# docker-compose.yml — add a dedicated worker group for heavy jobs
windmill_worker_heavy:
image: ${WM_IMAGE}
restart: unless-stopped
environment:
DATABASE_URL: ${DATABASE_URL}
MODE: worker
WORKER_GROUP: heavy
depends_on:
db:
condition: service_healthy
volumes:
- worker_heavy_tmp:/tmp/windmill/cache
volumes:
worker_heavy_tmp: {}
Then tag scripts or flows with the heavy worker group tag in the UI. Those jobs will only run on workers in that group.
For more advanced worker group configurations including Kubernetes-based scaling, see our guide on Git Sync, Worker Groups, App Builder, and Enterprise Workflow Patterns.
Section 6: Securing Your Windmill Instance
A self-hosted Windmill instance is only as secure as you make it. Here's the minimum hardening checklist.
Change default credentials immediately
The default [email protected] / changeme pair is public knowledge. Change the password in Settings → Users within the first minute.
Set a strong database password
Edit .env and replace the placeholder:
# .env
DATABASE_URL=postgres://windmill:REPLACE_WITH_STRONG_PASSWORD@db/windmill
Enable TLS with Caddy
Edit the Caddyfile to replace the local reverse proxy config with your domain:
windmill.yourdomain.com {
reverse_proxy windmill_server:8000
}
Caddy fetches a Let's Encrypt certificate automatically. Make sure port 80 and 443 are open on your firewall and that your DNS A record points to the host.
Configure SSO
For teams, wire up SSO via Google Workspace, GitHub, Azure AD, or any OIDC provider from Instance Settings → OAuth. Disabling password login entirely after SSO is set up is a clean security win.
Whitelist environment variables for workers
By default, workers don't inherit host environment variables. Explicitly whitelist what workers can see:
# .env — whitelist specific env vars for worker execution environments
WHITELIST_ENVS="DATABASE_READ_URL,INTERNAL_API_KEY,S3_BUCKET_NAME"
Section 7: Tips, Troubleshooting, and Common Gotchas
Jobs aren't running — check the worker queue
If jobs pile up in the queue and don't execute, your workers are either down or overloaded. Check worker status:
# Check which containers are running
docker compose ps
# Tail worker logs
docker compose logs -f windmill_worker
# Scale up workers immediately
docker compose up -d --scale windmill_worker=4
Webhook URLs are wrong or broken
Root cause 99% of the time: BASE_URL in .env is not set or is set to localhost. Fix it and restart:
# .env
BASE_URL=https://windmill.yourdomain.com
# Apply the change
docker compose restart windmill_server
Database migration fails on startup
If you're using a managed PostgreSQL without superuser access and see role "windmill_admin" does not exist, run the init script referenced in Section 1 before starting Windmill. This is a one-time operation.
Script imports fail (Python packages not found)
Windmill auto-installs packages, but the worker needs internet access to reach PyPI or NPM. If your environment is air-gapped, configure a private registry or cache layer. Check worker logs for DNS/network errors first.
Pin your Windmill version in production
The :main image tag tracks the latest commit. Stable production installs should pin to a specific version:
# .env
WM_IMAGE=ghcr.io/windmill-labs/windmill:v1.500.0
Back up PostgreSQL regularly
Everything — scripts, flows, job history, secrets, users — lives in Postgres. A simple backup script:
#!/bin/bash
TIMESTAMP=$(date +%Y%m%d-%H%M%S)
docker compose exec -T db \
pg_dump -U windmill -Fc windmill \
| gzip > /backups/windmill-${TIMESTAMP}.dump.gz
echo "Backup complete: windmill-${TIMESTAMP}.dump.gz"
Schedule this with a cron job on the host. For disaster recovery procedures including point-in-time restore, see our guide on High Availability, Multi-Workspace Governance, Custom Runtimes, and Disaster Recovery.
Slow job startup (cold start)
Python and TypeScript jobs cache their dependency environments after the first run. Cold starts on a fresh worker can take 10–30 seconds while packages install. Subsequent runs on the same worker are fast. If cold starts are hurting you, use dedicated worker groups with pre-warmed environments.
Logs not showing up
Windmill streams real-time logs for running jobs in the UI. If logs are missing for completed jobs, confirm your windmill_server container is healthy and connected to Postgres — logs are stored in the database, not on disk.
Where to Go From Here
You now have a working Windmill self-host setup: Docker Compose deployed, scripts running, workflows chained, triggers wired, and a production hardening baseline in place.
The natural next steps from here:
- Git Sync — version-control all your scripts and flows in a GitHub/GitLab repo and sync changes automatically. Covered in our Git Sync, Worker Groups, and Enterprise Workflow Patterns guide.
- AI-Powered Workflows — build flows that call LLMs, use Windmill's built-in AI code assistant, and connect to vector databases. Covered in our AI-Powered Workflows, Native Integrations, and Scaling to Production guide.
- High Availability — move to Kubernetes with the official Helm chart, set up multi-region workers, and implement disaster recovery. Covered in our High Availability and Disaster Recovery guide.
Need help deploying Windmill at scale?
Sysbrix works with engineering teams to design, deploy, and operate self-hosted Windmill environments — from single-VPS setups to multi-region Kubernetes clusters with enterprise governance, custom runtimes, and SLA-backed support.