Three months into running n8n in my HomeLab, I hit a wall. Not a small one — the kind where you’re sitting at 2 AM staring at a Function node, trying to figure out why your 120-line JavaScript blob isn’t persisting data between executions the way you expect.
n8n works brilliantly for simple API connections. But once your workflows get complex — multiple Python dependencies, shared utility functions, real error handling — the GUI becomes a liability. You end up writing code in a textarea with no linting, no git history, and no way to test a single step without firing the entire flow.
That’s when I found Windmill.
What Windmill Actually Is
Windmill is a self-hostable workflow automation platform, but it approaches the problem from the opposite direction of n8n. Instead of “build workflows visually, optionally add code,” Windmill treats your scripts as first-class citizens.
Every script in Windmill is an actual file — Python, TypeScript, Go, or Bash — stored in a git-backed workspace. You write real code in a web IDE with LSP (Language Server Protocol) support. Autocomplete works. Type checking works. You can test scripts in isolation without touching the rest of the flow.
Key Concepts Before You Deploy
- Scripts: Individual typed functions (Python
def, TypeScriptexport default function) that take inputs and return typed outputs - Flows: Visual chains of scripts — each step is a real script file, not an inline code blob
- Schedules: Cron-style triggers attached to any script or flow
- Webhooks: Auto-generated HTTP endpoints for every script, ready immediately
- Variables & Secrets: Centralized secret management with type-safe references in code
- Workers: Separate containers that execute scripts — scale them independently as load grows
The mental model shift: in n8n, you build a workflow and drop code inside it. In Windmill, you write scripts and wire them into flows. If you already think in functions and typed return values, you’ll feel at home within the first twenty minutes.
Deploying Windmill with Docker Compose
You need Docker and Docker Compose installed. If you’re already running other HomeLab services, you have these. Plan for at least 2 GB of RAM — the server, worker, and LSP containers each carry real overhead, and the database needs headroom to breathe.
Create a working directory:
mkdir -p ~/homelab/windmill && cd ~/homelab/windmill
Create docker-compose.yml:
version: "3.7"
services:
db:
image: postgres:16
shm_size: 128mb
volumes:
- db_data:/var/lib/postgresql/data
environment:
POSTGRES_PASSWORD: changeme
POSTGRES_USER: windmill
POSTGRES_DB: windmill
healthcheck:
test: ["CMD-SHELL", "pg_isready -U windmill"]
interval: 10s
timeout: 5s
retries: 5
restart: unless-stopped
windmill_server:
image: ghcr.io/windmill-labs/windmill:main
restart: unless-stopped
expose:
- 8000
environment:
- DATABASE_URL=postgres://windmill:changeme@db/windmill
- MODE=server
depends_on:
db:
condition: service_healthy
volumes:
- windmill_cache:/tmp/windmill/cache
windmill_worker:
image: ghcr.io/windmill-labs/windmill:main
restart: unless-stopped
environment:
- DATABASE_URL=postgres://windmill:changeme@db/windmill
- MODE=worker
- WORKER_GROUP=default
depends_on:
db:
condition: service_healthy
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- windmill_cache:/tmp/windmill/cache
windmill_worker_native:
image: ghcr.io/windmill-labs/windmill:main
restart: unless-stopped
environment:
- DATABASE_URL=postgres://windmill:changeme@db/windmill
- MODE=worker
- WORKER_GROUP=native
depends_on:
db:
condition: service_healthy
lsp:
image: ghcr.io/windmill-labs/windmill-lsp:latest
restart: unless-stopped
expose:
- 3001
caddy:
image: caddy:2.7.6-alpine
restart: unless-stopped
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile
ports:
- "3000:80"
environment:
- BASE_URL=":80"
volumes:
db_data: {}
windmill_cache: {}
Create a Caddyfile for the reverse proxy:
:80 {
bind 0.0.0.0
reverse_proxy /ws/* windmill_server:8000
reverse_proxy lsp:3001 {
header_up Host lsp
}
reverse_proxy windmill_server:8000
}
Start everything:
docker compose up -d
Wait about 30 seconds for the database to initialize, then open http://localhost:3000. The first login creates an admin account — use a real password. Windmill auto-generates a webhook URL for every script you create, which means those endpoints are potentially reachable from outside your local network. Weak credentials on an internet-facing HomeLab box is a genuine exposure.
Writing Real Scripts
Your First Python Script
Navigate to Scripts, click New Script, choose Python. The editor gives you a typed function template to start from:
import wmill
def main(name: str = "world"):
return f"Hello, {name}!"
Windmill reads those type annotations and renders a live form — text fields for strings, number spinners for integers, checkboxes for booleans. Hit the Run button and test this script directly in the editor, no flow wiring needed.
Here’s something more practical — a disk usage checker that returns structured data your other scripts can act on:
import subprocess
import wmill
def main(threshold_percent: int = 80) -> dict:
result = subprocess.run(
["df", "-h", "/"],
capture_output=True,
text=True
)
lines = result.stdout.strip().split("\n")
usage_line = lines[1].split()
use_percent = int(usage_line[4].replace("%", ""))
return {
"disk_usage_percent": use_percent,
"alert": use_percent > threshold_percent,
"message": f"Disk at {use_percent}% — {'ALERT' if use_percent > threshold_percent else 'OK'}"
}
Save and run it. You get typed JSON back immediately. A downstream alert script can reference result.alert directly — no parsing, no brittle string matching, just a typed field from the previous step’s output.
Chaining Scripts into a Flow
Create a new Flow, add the disk-check script as Step 1. For Step 2, add a TypeScript script that sends a Telegram alert when the flag is true:
import * as wmill from "windmill-client";
export async function main(
alert: boolean,
message: string,
bot_token: string = "$var:telegram_bot_token",
chat_id: string = "$var:telegram_chat_id"
) {
if (!alert) {
return { sent: false, reason: "No alert triggered" };
}
const url = `https://api.telegram.org/bot${bot_token}/sendMessage`;
const response = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ chat_id, text: `HomeLab Alert: ${message}` }),
});
const data = await response.json();
return { sent: true, telegram_response: data };
}
The $var:telegram_bot_token syntax is a reference to a secret stored in Windmill’s variable manager — credentials stay out of source code entirely. Wire Step 1’s output to Step 2’s inputs in the flow editor, then attach a cron schedule:
# Runs every hour on the hour
0 * * * *
Troubleshooting When Things Break at 2 AM
Workers not picking up jobs? Start with the logs:
docker compose logs windmill_worker --tail=50
Most common cause: a DATABASE_URL mismatch or the worker container can’t resolve the db hostname. When the hostname doesn’t match the service name exactly, the worker silently fails to connect — jobs queue up but never execute. Verify that every service name in your compose file matches what’s referenced in the environment variables character for character.
Python packages not found? Add a requirements comment block at the top of your script — Windmill workers install them automatically on first run, no custom Docker image needed:
# requirements:
# requests==2.31.0
# psutil==5.9.5
import requests
import psutil
That comment block is all you write. No rebuilding images, no managing virtual environments — the worker handles the lockfile and isolates dependencies per script.
LSP autocomplete not working in the editor? Check that the lsp container is running and that your Caddyfile correctly routes /ws/* to the windmill_server container. Restart lsp if it started before the server was ready:
docker compose restart lsp
Where This Fits Your HomeLab Stack
Treating automation scripts as real versioned code changes how the library grows. The first few scripts feel slower than dragging nodes in a GUI — maybe 20 minutes versus 5. By week three, that investment pays off: the disk monitor is a shared module used by five different flows, and the Telegram notifier handles alerts from monitoring jobs, backup pipelines, and deploy scripts alike. Reuse compounds fast once the patterns are established.
The git history makes the 2 AM difference. Every script has a full change log. When something breaks — and something always breaks at 2 AM — you can see exactly what changed between the last working run and now, then roll back with one click. n8n’s JSON export gets you a snapshot, not a diff.
Windmill isn’t trying to replace every automation tool. It fills the gap between “cron job with a Bash script” and “full CI/CD pipeline.” If you already write code for your HomeLab, this is the layer that connects everything without duct-taping tools together. Keep n8n for simple integrations that are better done visually — the two tools solve different problems and run happily side by side.
