Windmill Script and Flow Automation Platform

Windmill is an open-source developer platform for building internal tools, workflows, and automation scripts using Python, TypeScript, Go, Bash, or SQL. Self-hosted on Linux, it provides an IDE-like editor, flow builder, scheduled jobs, webhook triggers, and team workspaces — making it a powerful alternative to Retool, Zapier, and custom automation scripts.

Prerequisites

  • Docker and Docker Compose installed
  • At least 2 GB RAM (4 GB recommended for multiple workers)
  • A domain name with SSL (for webhooks and team access)
  • PostgreSQL (included in Docker Compose)

Docker Installation

# Create project directory and download official compose file
mkdir -p /opt/windmill && cd /opt/windmill
curl -fsSL https://raw.githubusercontent.com/windmill-labs/windmill/main/docker-compose.yml -o docker-compose.yml
curl -fsSL https://raw.githubusercontent.com/windmill-labs/windmill/main/.env -o .env

Edit .env with your configuration:

# .env
WM_BASE_URL=https://windmill.yourdomain.com
DATABASE_URL=postgresql://windmill:windmill@db/windmill
SECRET=your-random-32-char-secret
# SMTP for email notifications
EMAIL_HOST=smtp.sendgrid.net
EMAIL_PORT=587
EMAIL_HOST_USER=apikey
EMAIL_HOST_PASSWORD=your-sendgrid-key
EMAIL_USE_TLS=true

The included docker-compose.yml:

version: '3'

services:
  db:
    image: postgres:15
    environment:
      POSTGRES_DB: windmill
      POSTGRES_USER: windmill
      POSTGRES_PASSWORD: windmill
    volumes:
      - db_data:/var/lib/postgresql/data

  windmill_server:
    image: ghcr.io/windmill-labs/windmill:main
    depends_on:
      - db
    ports:
      - "8000:8000"
    environment:
      DATABASE_URL: postgresql://windmill:windmill@db/windmill
      BASE_URL: https://windmill.yourdomain.com
      MODE: server
    volumes:
      - worker_logs:/tmp/windmill/logs

  windmill_worker:
    image: ghcr.io/windmill-labs/windmill:main
    depends_on:
      - windmill_server
    environment:
      DATABASE_URL: postgresql://windmill:windmill@db/windmill
      BASE_URL: https://windmill.yourdomain.com
      MODE: worker
      WORKER_GROUP: default
      NUM_WORKERS: 3
    volumes:
      - worker_logs:/tmp/windmill/logs
      - /var/run/docker.sock:/var/run/docker.sock  # For Docker jobs

volumes:
  db_data:
  worker_logs:
# Start Windmill
docker compose up -d

# View logs
docker compose logs windmill_server --tail 50 -f

# Access at http://your-server:8000
# Create your admin account on first visit

Script Creation

Windmill scripts are versioned, runnable functions with typed inputs:

Python Script

# In Windmill web editor: Scripts → New Script → Python3

import requests
import wmill  # Windmill Python client

# Input types are defined as function parameters
# They appear as a form in the Windmill UI
def main(
    target_url: str,
    expected_status: int = 200,
    timeout_seconds: int = 10,
    notify_on_failure: bool = True,
) -> dict:
    """Check if a URL returns the expected HTTP status code."""
    try:
        response = requests.get(target_url, timeout=timeout_seconds)
        success = response.status_code == expected_status

        result = {
            "url": target_url,
            "status_code": response.status_code,
            "success": success,
            "response_time_ms": int(response.elapsed.total_seconds() * 1000),
        }

        if not success and notify_on_failure:
            # Access Windmill resources (credentials stored in platform)
            slack_token = wmill.get_variable("f/monitoring/slack_token")
            requests.post(
                "https://slack.com/api/chat.postMessage",
                headers={"Authorization": f"Bearer {slack_token}"},
                json={
                    "channel": "#alerts",
                    "text": f"Health check failed: {target_url} returned {response.status_code}"
                }
            )

        return result

    except Exception as e:
        return {"url": target_url, "success": False, "error": str(e)}

TypeScript Script

// TypeScript scripts in Windmill (Deno runtime)
import * as wmill from "npm:windmill-client@1"

// Define resource types for typed connections
type PgDatabase = {
    host: string
    port: number
    database: string
    user: string
    password: string
}

export async function main(
    db: PgDatabase,
    table: string,
    limit: number = 100,
): Promise<object[]> {
    // Use Windmill's resource system for database connections
    const client = await wmill.getResource<PgDatabase>(`postgresql/${table}`)
    // ... or use the passed parameter directly
    return [{ table, limit, connected: true }]
}

Bash Script

#!/bin/bash
# Windmill Bash script - parameters come from environment variables
# Variables are injected from the form input

set -euo pipefail

# Windmill injects parameters as env vars
HOSTNAME="${HOSTNAME:-localhost}"
PORT="${PORT:-80}"
EXPECTED_STATUS="${EXPECTED_STATUS:-200}"

STATUS=$(curl -s -o /dev/null -w "%{http_code}" "http://$HOSTNAME:$PORT" || echo "000")

if [ "$STATUS" = "$EXPECTED_STATUS" ]; then
    echo "✓ $HOSTNAME:$PORT returned $STATUS"
    exit 0
else
    echo "✗ $HOSTNAME:$PORT returned $STATUS (expected $EXPECTED_STATUS)"
    exit 1
fi

Flow Design

Flows connect scripts and modules into multi-step workflows:

Creating a Flow

  1. Go to FlowsNew Flow
  2. Add steps by clicking +
  3. Each step can be a script, inline code, loop, or branch

Flow step types:

  • Script: Run a saved Windmill script
  • Inline Script: Write code directly in the flow
  • For Loop: Iterate over an array
  • Branch: If/else conditional routing
  • Input Transform: Transform data between steps

Flow with Multiple Steps

// Step 1: Fetch data (TypeScript inline)
// Input: { "limit": 50 }
export async function main(limit: number) {
    const response = await fetch(`https://api.example.com/orders?limit=${limit}`)
    return await response.json()
}

// Step 2: Filter orders (Python inline)
// Input: { "orders": "${previous_step.result}" }
def main(orders: list) -> list:
    return [o for o in orders if o["status"] == "pending"]

// Step 3: For Each - process each pending order
// Loops over the result of Step 2

// Step 3a: Process individual order (calls existing script)
// Script: scripts/process-order
// Input: { "order_id": "${for_loop_value.id}" }

Scheduling and Webhook Triggers

Scheduled Jobs

# In Windmill UI: Scripts/Flows → Schedule tab
# Or via API:

curl -X POST "https://windmill.yourdomain.com/api/w/myworkspace/schedules" \
  -H "Authorization: Bearer $WINDMILL_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "path": "f/monitoring/check-all-sites",
    "schedule": "*/5 * * * *",
    "timezone": "UTC",
    "is_flow": false,
    "args": {
      "notify": true
    },
    "enabled": true
  }'

# List schedules
curl "https://windmill.yourdomain.com/api/w/myworkspace/schedules/list" \
  -H "Authorization: Bearer $WINDMILL_TOKEN"

Webhook Triggers

Every script and flow gets a webhook URL automatically:

# Get webhook URL for a script
curl "https://windmill.yourdomain.com/api/w/myworkspace/scripts/by_path/f/myapp/process-webhook" \
  -H "Authorization: Bearer $WINDMILL_TOKEN"

# Trigger script via webhook (async - returns job ID)
curl -X POST \
  "https://windmill.yourdomain.com/api/w/myworkspace/jobs/run/p/f/myapp/process-webhook" \
  -H "Authorization: Bearer $WINDMILL_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"event_type": "user.signup", "user_id": 42}'

# Trigger and wait for result (sync)
curl -X POST \
  "https://windmill.yourdomain.com/api/w/myworkspace/jobs/run_wait_result/p/f/myapp/process-data" \
  -H "Authorization: Bearer $WINDMILL_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"input_data": [1, 2, 3]}'

Approval Flows

Approval steps pause a flow until a human approves or rejects:

# In a flow step, add an Approval type step
# Or in a Python script, use the wmill client:

import wmill

def main(order_id: str, amount: float):
    if amount > 1000:
        # Suspend and wait for approval
        # Sends an email with approve/deny links
        approvers = ["[email protected]"]
        approval_id = wmill.get_resume_urls(approvers=approvers)

        # This suspends execution - next steps run only after approval
        return {
            "status": "pending_approval",
            "order_id": order_id,
            "approval_id": approval_id,
        }

    # Small orders proceed automatically
    return {"status": "auto_approved", "order_id": order_id}

Configure approval notifications in the Windmill UI:

  • Flow → Approval Step → set approvers (email or Windmill usernames)
  • Set expiration time (auto-reject if not approved within X hours)

Team Workspaces and Permissions

# Windmill organizes resources in workspaces
# Each workspace has its own scripts, flows, and credentials

# Create a new workspace via API
curl -X POST "https://windmill.yourdomain.com/api/workspaces/create" \
  -H "Authorization: Bearer $ADMIN_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"id": "myteam", "name": "My Team", "username": "admin"}'

# Invite a user to a workspace
curl -X POST "https://windmill.yourdomain.com/api/w/myteam/workspaces/add_user" \
  -H "Authorization: Bearer $WINDMILL_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"email": "[email protected]", "is_admin": false, "operator": false}'

# Path-based permissions: f/ = folder prefix
# f/public/   - accessible by all workspace members
# f/private/  - accessible only by owner
# f/teamname/ - accessible by team members (groups)

Storing Credentials (Resources)

# Store a database credential as a Windmill resource
curl -X POST "https://windmill.yourdomain.com/api/w/myworkspace/resources/create" \
  -H "Authorization: Bearer $WINDMILL_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "path": "f/databases/production_pg",
    "resource_type": "postgresql",
    "value": {
      "host": "db.yourdomain.com",
      "port": 5432,
      "database": "myapp",
      "user": "readonly",
      "password": "secret"
    }
  }'

Worker Configuration and Scaling

# Scale workers for higher throughput
docker compose up -d --scale windmill_worker=5

# Create specialized worker groups for resource-intensive jobs
# docker-compose.yml additions:
#
# windmill_worker_heavy:
#   extends: windmill_worker
#   environment:
#     WORKER_GROUP: heavy
#     NUM_WORKERS: 1
#   deploy:
#     resources:
#       limits:
#         memory: 4G

# Tag scripts to run on specific worker groups:
# Script settings → Worker Group Tag: "heavy"

Troubleshooting

Script fails with module import error:

# Python packages are installed per-script using requirements block
# Add at top of script:
# import: requests, pandas, boto3

# Or use Windmill's requirements.txt approach:
# Check script settings → Extra Libraries

# View worker logs
docker compose logs windmill_worker --tail 50

Webhook not triggering:

# Verify Windmill is accessible from the internet
curl -I https://windmill.yourdomain.com

# Check the webhook URL format
# https://windmill.yourdomain.com/api/w/{workspace}/jobs/run/p/{script_path}

# Test webhook manually with verbose output
curl -v -X POST "https://windmill.yourdomain.com/api/w/myworkspace/jobs/run/p/f/myapp/script" \
  -H "Authorization: Bearer $WINDMILL_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{}'

Flow stuck on approval step:

# Check job status
curl "https://windmill.yourdomain.com/api/w/myworkspace/jobs/JOBID" \
  -H "Authorization: Bearer $WINDMILL_TOKEN"

# Manually approve/cancel suspended job in UI:
# Runs → select job → Resume or Cancel

Conclusion

Windmill combines the power of a full script execution environment with a visual flow builder and team collaboration features, making it a versatile platform for internal tooling and workflow automation. Its support for multiple languages, type-safe inputs that auto-generate UI forms, and built-in approval flows accelerate building production-grade automation without maintaining custom infrastructure for each use case.