How to Self-Host Windmill — Open Source n8n Alternative for Developer Workflows 2026
What Is Windmill?
Windmill is an open source developer platform for building internal tools, workflows, and scripts. Unlike n8n (visual flowchart-first) or Zapier (no-code), Windmill is built for teams that want to write real code while getting the scheduling, monitoring, and UI generation for free.
The pitch: Write a Python or TypeScript function → Windmill gives you a REST API endpoint, a scheduled job, a shareable form UI, and a production monitoring dashboard. All in one platform.
n8n Cloud costs $20-50/month. Windmill is free when self-hosted.
Key features:
- Scripts in Python, TypeScript/Deno, Go, Bash, SQL
- Flows — visual orchestration of scripts (like n8n but code-first)
- Apps — low-code UI builder for internal tools (like Retool)
- Job scheduler (cron and webhook triggers)
- Resource management (secrets, API credentials)
- Audit logs and job history
- Worker groups for scaling
- GitHub sync for version control
Windmill vs n8n: Which Should You Use?
| Scenario | Choose |
|---|---|
| Non-engineers building automations | n8n |
| Developers building internal tooling | Windmill |
| Replacing Zapier/Make | n8n |
| Replacing Retool/Airplane | Windmill |
| Data pipelines with Python | Windmill |
| Visual workflow designer | n8n |
| API endpoints from scripts | Windmill |
Windmill isn't trying to replace n8n — it's a different tool for a different audience.
Prerequisites
- VPS with 2 vCPU, 2GB RAM (Hetzner CX32 ~€5.49/month)
- Docker + Docker Compose v2
- Domain name with DNS pointed to your server
- SMTP for email (optional)
Docker Compose Deployment
1. Clone Official Docker Setup
git clone https://github.com/windmill-labs/windmill
cd windmill
2. Configure Environment
cp .env.example .env
Edit .env:
# .env
# Domain
WM_BASE_URL=https://wm.yourdomain.com
# Database
DATABASE_URL=postgres://windmill:your-password@db/windmill
# Secrets
PASSWORD=your-windmill-admin-password
# Email (optional)
SMTP_FROM=noreply@yourdomain.com
SMTP_HOST=smtp.yourdomain.com
SMTP_PORT=587
SMTP_USERNAME=your-smtp-user
SMTP_PASSWORD=your-smtp-password
SMTP_TLS_IMPLICIT=true
# Enterprise features (leave blank for Community Edition)
# LICENSE_KEY=
3. The docker-compose.yaml
version: "3.7"
services:
db:
image: postgres:16
shm_size: 1g
restart: unless-stopped
volumes:
- db_data:/var/lib/postgresql/data
environment:
POSTGRES_DB: windmill
POSTGRES_USER: windmill
POSTGRES_PASSWORD: your-password
healthcheck:
test: ["CMD-SHELL", "pg_isready -U windmill"]
interval: 10s
timeout: 5s
retries: 5
windmill_server:
image: ghcr.io/windmill-labs/windmill:main
pull_policy: always
restart: unless-stopped
ports:
- "8000:8000"
depends_on:
db:
condition: service_healthy
environment:
- DATABASE_URL=${DATABASE_URL}
- WM_BASE_URL=${WM_BASE_URL}
- RUST_LOG=info
- MODE=server
volumes:
- worker_logs:/tmp/windmill/logs
windmill_worker:
image: ghcr.io/windmill-labs/windmill:main
pull_policy: always
restart: unless-stopped
depends_on:
db:
condition: service_healthy
environment:
- DATABASE_URL=${DATABASE_URL}
- WM_BASE_URL=${WM_BASE_URL}
- RUST_LOG=info
- MODE=worker
- WORKER_GROUP=default
volumes:
- worker_logs:/tmp/windmill/logs
- /var/run/docker.sock:/var/run/docker.sock # for Docker-in-Docker scripts
- worker_dependency_cache:/tmp/windmill/cache
windmill_worker_native:
image: ghcr.io/windmill-labs/windmill:main
pull_policy: always
restart: unless-stopped
depends_on:
db:
condition: service_healthy
environment:
- DATABASE_URL=${DATABASE_URL}
- WM_BASE_URL=${WM_BASE_URL}
- RUST_LOG=info
- MODE=worker
- WORKER_GROUP=native
- NUM_WORKERS=8
lsp:
image: ghcr.io/windmill-labs/windmill-lsp:latest
pull_policy: always
restart: unless-stopped
ports:
- "3001:3001"
caddy:
image: caddy:2.7-alpine
restart: unless-stopped
ports:
- "80:80"
- "443:443"
- "443:443/udp"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile
- caddy_data:/data
- caddy_config:/config
depends_on:
- windmill_server
volumes:
db_data:
worker_logs:
worker_dependency_cache:
caddy_data:
caddy_config:
4. Configure Built-In Caddy
Windmill ships a Caddyfile for automatic HTTPS:
# Caddyfile
{$WM_BASE_URL} {
reverse_proxy /ws/* windmill_server:8000
reverse_proxy /* windmill_server:8000
}
Update .env and run:
docker compose up -d
# Check all services
docker compose ps
docker compose logs -f windmill_server
First Login
Visit https://wm.yourdomain.com:
Default credentials:
Email: admin@windmill.dev
Password: changeme (or what you set in PASSWORD env)
Change the password immediately:
Top-right → Account → Change Password
Windmill Architecture: Scripts, Flows, Apps
Scripts — The Foundation
A script is a function with typed inputs that Windmill runs on demand or on a schedule:
# Python script: fetch_github_stats.py
import requests # auto-installed by Windmill
def main(owner: str, repo: str, github_token: str) -> dict:
"""
Fetch GitHub repository statistics.
owner: GitHub org or username
repo: Repository name
github_token: Personal access token (use Resource for secrets)
"""
headers = {"Authorization": f"token {github_token}"}
r = requests.get(
f"https://api.github.com/repos/{owner}/{repo}",
headers=headers
)
r.raise_for_status()
data = r.json()
return {
"stars": data["stargazers_count"],
"forks": data["forks_count"],
"open_issues": data["open_issues_count"],
"last_push": data["pushed_at"],
}
Save this script in Windmill → it immediately gets:
- A REST API endpoint (
POST /api/w/workspace/jobs/run/p/fetch_github_stats) - A form UI (auto-generated from type annotations)
- A scheduler (add cron expression)
- Job history and logs
TypeScript Scripts
// TypeScript/Deno script: send_slack_notification.ts
import * as wmill from "npm:windmill-client@1";
// Resources are stored in Windmill's credential manager
type SlackResource = {
token: string;
};
export async function main(
message: string,
channel: string,
slack: SlackResource, // auto-populated from stored resource
): Promise<{ ok: boolean; ts: string }> {
const response = await fetch("https://slack.com/api/chat.postMessage", {
method: "POST",
headers: {
"Authorization": `Bearer ${slack.token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ channel, text: message }),
});
const data = await response.json();
return { ok: data.ok, ts: data.ts };
}
SQL Scripts
-- SQL script: monthly_revenue_report.sql
-- Database resource: production_postgres
SELECT
date_trunc('month', created_at) AS month,
COUNT(*) AS orders,
SUM(amount) AS revenue,
AVG(amount) AS avg_order_value
FROM orders
WHERE
created_at >= NOW() - INTERVAL '12 months'
AND status = 'completed'
GROUP BY 1
ORDER BY 1 DESC;
Windmill connects to your PostgreSQL, MySQL, BigQuery, or Snowflake via Resources.
Flows — Orchestrate Scripts
A flow is a visual DAG of steps, each step being a script:
Flow: New User Onboarding
Step 1: get_user_details (Python)
↓
Step 2: create_stripe_customer (TypeScript)
↓
Step 3: add_to_crm (Python)
↓ (parallel)
Step 4a: send_welcome_email (Python)
Step 4b: notify_slack (TypeScript)
↓
Step 5: log_to_database (SQL)
Create a Flow
Flows → New Flow
1. Add step → Choose script from library or write inline
2. Connect inputs: previous step outputs → next step inputs
3. Add conditions: "If step 2 fails → go to error handler"
4. Add loops: "For each user in list → run step"
5. Save and test with sample inputs
Branching Logic
# In flow editor (visual), this represents:
# "If Stripe customer creation fails, send alert and stop"
Step 2: create_stripe_customer
onError:
- notify_slack: { message: "Stripe failure for {user_email}" }
- stop_flow
onSuccess:
- next_step
Apps — Internal Tool Builder
Apps let non-engineers interact with scripts via a UI you build in Windmill:
Apps → New App
Components available:
- Text, Number, Date inputs
- Dropdowns (static or from script)
- Tables (with inline editing)
- Charts (from script output)
- Buttons (run a script on click)
Example: Customer Support Lookup App
App: Customer Lookup
Input: customer_email (text field)
Button: "Lookup" → runs get_customer_details script
Table: displays orders, subscription status, recent activity
Button: "Issue Refund" → runs issue_refund script
(shows confirmation dialog first)
Button: "Reset Password" → runs trigger_password_reset
This replaces a Retool/Airplane internal tool — built in Windmill.
Resources — Secrets and Credentials
Resources are named credentials stored in Windmill:
Resources → New Resource
Type: PostgreSQL
Name: production_db
Fields:
host: db.yourdomain.com
port: 5432
dbname: app_production
user: readonly_user
password: ***
sslmode: require
Scripts receive resources as typed arguments — no hardcoded secrets in code:
import psycopg2
def main(db: dict): # db is the PostgreSQL resource
conn = psycopg2.connect(**db)
# ...
Schedule Scripts and Flows
Scripts → My Script → Schedule
→ Cron expression: "0 9 * * 1" (every Monday at 9am)
→ Or: "*/15 * * * *" (every 15 minutes)
→ Input defaults (filled in for scheduled runs)
→ Timezone: America/New_York
All scheduled job runs appear in the job queue with status, logs, and output.
GitHub Sync (GitOps)
Windmill can sync scripts and flows to/from a GitHub repository:
Settings → Git Sync
→ Repository: github.com/your-org/windmill-scripts
→ Branch: main
→ Token: GitHub PAT with repo access
Then:
# Your scripts in GitHub
scripts/
python/
fetch_github_stats.py
send_slack_notification.py
sql/
monthly_revenue_report.sql
flows/
new_user_onboarding.json
Changes pushed to GitHub automatically sync to Windmill — infrastructure-as-code for your automation.
Backup
#!/bin/bash
# backup-windmill.sh
DATE=$(date +%Y%m%d_%H%M%S)
BACKUP_DIR="/backups/windmill"
mkdir -p $BACKUP_DIR
docker compose exec -T db pg_dump \
-U windmill windmill | gzip > $BACKUP_DIR/db_$DATE.sql.gz
find $BACKUP_DIR -mtime +14 -delete
Windmill vs n8n vs Zapier
| Feature | Windmill (self-hosted) | n8n (self-hosted) | Zapier |
|---|---|---|---|
| Target audience | Developers | Mixed | Non-engineers |
| Scripting | ✅ Python/TS/Go/SQL | ⚠️ JS only | ❌ |
| Visual flow builder | ✅ | ✅ | ✅ |
| Internal app builder | ✅ | ❌ | ❌ |
| Scheduler | ✅ | ✅ | ✅ |
| REST API for scripts | ✅ Auto-generated | ❌ | ❌ |
| Secret management | ✅ Resources system | ✅ Credentials | ✅ |
| Git sync | ✅ | ⚠️ | ❌ |
| Self-hosted | ✅ | ✅ | ❌ |
| Price | ~$10/mo server | ~$10/mo server | $20-49/mo |
| Non-engineer friendly | ❌ | ✅ | ✅ |
Troubleshooting
Workers not executing jobs:
docker compose logs windmill_worker | tail -30
# Check DATABASE_URL — workers connect directly to PostgreSQL
Python packages not installing:
# Windmill installs packages via pip in isolated environments
# Check worker logs for pip install failures
# Usually network issues in air-gapped environments
Script times out:
# Default timeout is 180s
# Increase per-script: Script settings → Timeout
# Or set WM_JOB_DEFAULT_TIMEOUT_SECS env on worker
Windmill is a top n8n alternative on OSSAlt — explore all open source automation platforms.