Skip to main content

How to Self-Host Windmill — Open Source n8n Alternative for Developer Workflows 2026

·OSSAlt Team
windmilln8n-alternativeself-hostingworkflow-automationdocker2026

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?

ScenarioChoose
Non-engineers building automationsn8n
Developers building internal toolingWindmill
Replacing Zapier/Maken8n
Replacing Retool/AirplaneWindmill
Data pipelines with PythonWindmill
Visual workflow designern8n
API endpoints from scriptsWindmill

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

FeatureWindmill (self-hosted)n8n (self-hosted)Zapier
Target audienceDevelopersMixedNon-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.

Comments