Skip to Content

Windmill Self-Host Setup: Run Scripts and Automate Workflows on Your Own Infrastructure

A complete developer guide to deploying Windmill on your own server with Docker Compose, writing your first scripts and workflows, wiring up triggers, and running production-grade automation without handing your data to a third-party cloud.

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)
  • curl and git on 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:

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 API
  • windmill_worker — executes jobs pulled from the PostgreSQL queue
  • windmill_worker_native — dedicated worker for native jobs (lighter workloads)
  • db — PostgreSQL 15, holding all state: scripts, flows, job history, secrets
  • lsp — language server for Monaco editor intellisense
  • caddy — 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/DELETE events 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:


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.

Talk to us about your setup →

Own Your Code: How to Self-Host Gitea on Your Own Server with Docker and PostgreSQL
Set up a fully self-hosted Git server with Gitea, back it with PostgreSQL, serve it over HTTPS, wire up SSH access, and run your first CI/CD workflow — all on infrastructure you control.