Beyond the Basics: What This Guide Covers
Getting Windmill running with Docker Compose is the easy part. The real productivity gains come from the features most guides skip: Git-backed version control for your scripts, worker groups that route jobs to the right compute, the drag-and-drop App Builder for internal tooling, and the workflow patterns that keep large teams from stepping on each other.
This guide assumes you already have a working Windmill self-host setup. If you're starting from scratch, read Windmill Self-Host Setup: Run Scripts and Automate Workflows on Your Own Infrastructure first, then come back here.
What you'll walk away with:
- A Git sync pipeline so every script lives in version control
- Worker groups that separate production jobs from dev noise
- Internal apps built on top of your scripts without writing frontend code
- Workflow patterns — approval gates, error branches, parallel fan-out — that production teams actually use
Related reading in this series:
• AI-Powered Workflows, Native Integrations, Audit Logging, and Scaling to Production
• High Availability, Multi-Workspace Governance, Custom Runtimes, and Disaster Recovery
Prerequisites
Before working through this guide, confirm you have the following in place.
A running Windmill instance
- Windmill deployed via Docker Compose or Kubernetes Helm chart
BASE_URLset to your real domain in.env— Git sync and webhooks both depend on it- Admin access to at least one workspace
CLI installed
The wmill CLI is required for Git sync and several admin operations:
# Install the Windmill CLI globally
npm install -g windmill-cli
# Confirm it's installed
wmill --version
# Authenticate against your instance
wmill workspace add production https://windmill.yourdomain.com YOUR_TOKEN
wmill workspace switch production
A GitHub or GitLab repository
Git sync requires a repository your Windmill instance can reach. A private repo works fine — you'll provide a deploy key or personal access token during setup.
Section 1: Git Sync — Scripts and Flows Under Version Control
Windmill's Git sync feature mirrors your workspace — scripts, flows, resource types, variables — to a Git repository. Every change made in the UI creates a commit. Every push to the repo syncs back into Windmill. This means code review workflows, PR-based deployment gates, and full audit history without any custom tooling.
How Git sync works
Windmill represents each object in the filesystem as a pair of files:
u/username/script_name.py(or.ts,.go,.sh, etc.) — the script sourceu/username/script_name.script.yaml— metadata: description, schema, tags, worker group
Flows serialize as f/folder/flow_name.flow.yaml. Apps serialize as JSON. The file tree mirrors the workspace path structure exactly.
Configure Git sync in the UI
Go to Workspace Settings → Git Sync. You'll need:
- Repository URL (SSH or HTTPS)
- Branch name (e.g.,
main) - A deploy key or personal access token with write access
Once saved, Windmill immediately pushes the current workspace state to the repo. From this point forward, every save in the UI triggers a commit.
Pull changes from Git into Windmill
To sync edits made in the repo back into Windmill, use the CLI or trigger a pull from the UI:
# Push local workspace state to Git
wmill sync push
# Pull Git state into the Windmill workspace
wmill sync pull
# Diff what would change before pulling
wmill sync pull --dry-run
CI/CD deployment pattern
A clean deployment pipeline: developers write scripts locally, open a PR, get code review, merge to main, and a GitHub Actions workflow pushes the change to Windmill automatically.
# .github/workflows/deploy-to-windmill.yml
name: Deploy to Windmill
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install wmill CLI
run: npm install -g windmill-cli
- name: Authenticate
run: |
wmill workspace add production ${{ secrets.WINDMILL_URL }} \
${{ secrets.WINDMILL_TOKEN }}
wmill workspace switch production
- name: Push to Windmill
run: wmill sync push --yes
Add WINDMILL_URL and WINDMILL_TOKEN as repository secrets. The --yes flag skips the interactive confirmation prompt in CI.
Lock scripts to a Git revision
For strict deployment pipelines, set a script's draft mode to require deployment from Git only — preventing ad-hoc UI edits on production scripts. Configure this per-script in the script metadata YAML:
# u/admin/critical_job.script.yaml
summary: "Critical production job"
description: "Deploy via Git only — do not edit in UI"
tag: production
worker_group: production
concurrency_limit: 1
concurrency_time_window_s: 60
Section 2: Worker Groups — Routing Jobs to the Right Compute
By default, all workers pick up all jobs. That works fine for small setups. It breaks down the moment you have a mix of fast lightweight jobs and slow heavy ones queuing behind each other, or when different scripts need different system dependencies.
Worker groups solve this by letting you tag scripts and flows with a group name, and then run dedicated workers that only pick up jobs from that tag.
Common worker group patterns
- production — dedicated workers for scheduled and webhook-triggered jobs; never shares queue with dev work
- heavy — large Python ML jobs, data processing, anything that burns CPU for minutes
- native — fast, lightweight jobs that don't need dependency installation (Bash one-liners, simple SQL)
- gpu — workers on GPU-equipped nodes for inference workloads
Add a worker group in Docker Compose
# docker-compose.yml — add dedicated worker services
windmill_worker_production:
image: ${WM_IMAGE}
pull_policy: always
restart: unless-stopped
environment:
DATABASE_URL: ${DATABASE_URL}
MODE: worker
WORKER_GROUP: production
METRICS_ADDR: "false"
depends_on:
db:
condition: service_healthy
volumes:
- worker_production_cache:/tmp/windmill/cache
windmill_worker_heavy:
image: ${WM_IMAGE}
pull_policy: always
restart: unless-stopped
environment:
DATABASE_URL: ${DATABASE_URL}
MODE: worker
WORKER_GROUP: heavy
depends_on:
db:
condition: service_healthy
volumes:
- worker_heavy_cache:/tmp/windmill/cache
deploy:
resources:
limits:
cpus: '4'
memory: 8G
volumes:
worker_production_cache: {}
worker_heavy_cache: {}
Tag a script to a worker group
In the script editor, open Settings → Worker Group Tag and type the group name. Or set it in YAML for Git-synced scripts:
# Script metadata YAML
tag: heavy
Scripts without a tag go to the default worker group. Scripts tagged heavy will only run on workers where WORKER_GROUP=heavy.
Monitor worker queue depth
# Check how many jobs are queued per worker group
curl -s \
-H "Authorization: Bearer YOUR_TOKEN" \
https://windmill.yourdomain.com/api/w/your-workspace/jobs/queue/count
# Scale up the heavy worker group on demand
docker compose up -d --scale windmill_worker_heavy=3
Section 3: The App Builder — Internal Tools Without a Frontend Team
Windmill's App Builder is a WYSIWYG interface for building internal tools on top of your scripts and flows. Think admin panels, data entry forms, ops dashboards, approval UIs — built by connecting drag-and-drop components to your existing backend scripts.
No React. No Vue. No separate deployment. The app runs inside Windmill and inherits all its auth, permissions, and logging.
Core App Builder concepts
- Components — buttons, tables, forms, text inputs, charts, file uploads, select dropdowns, date pickers, and more
- Runnables — scripts or flows connected to components. A button's click triggers a runnable. A table's data source is a runnable's output.
- Context — components share a reactive context. A table selection can feed into a form. A form submission can refresh a table.
- Permissions — apps can be shared with specific users, groups, or made public (with or without auth)
Build a simple data dashboard app
Create a new app from Apps → New App. The pattern for a read-only dashboard:
- Add a Table component to the canvas
- Set its data source to a SQL script:
SELECT id, name, status, created_at FROM orders ORDER BY created_at DESC LIMIT 100 - Add a Text component connected to
table.selectedRow.statusto show details on row click - Add a Button that triggers a flow to update the selected row's status
- Set the button's success callback to refresh the table component
Share an app with a team
# Share an app with a group via API
curl -X POST \
https://windmill.yourdomain.com/api/w/your-workspace/apps/p/u/admin/order_dashboard \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"policy": {
"execution_mode": "viewer",
"triggerables": {}
}
}'
Execution modes:
viewer— app runs scripts under the app owner's credentials, not the viewer's. Viewers only need the app URL.publisher— scripts run under the viewer's credentials. Viewer needs permissions on the underlying scripts.
Full-code apps for advanced UIs
For cases where the drag-and-drop builder isn't flexible enough, Windmill supports full-code React and Svelte apps. Your frontend calls backend runnables as API functions — Windmill handles execution, auth, and logging transparently. This is the escape hatch when you need custom layouts or complex interactivity that components can't express.
Section 4: Enterprise Workflow Patterns
Beyond simple linear scripts, Windmill flows support the patterns that real production workflows need. Here are the four patterns used most in larger teams.
Pattern 1: Approval gate (human-in-the-loop)
Pause a flow mid-execution and wait for a human to approve or reject before continuing. Classic use case: a deployment flow that needs a senior engineer sign-off before hitting production.
Add a Suspend/Approval step to your flow. Windmill sends an email (or Slack message via a prior step) with an approve/reject link. The flow resumes only when someone clicks approve — or times out after your configured deadline.
// TypeScript step — send Slack approval request before suspend step
export async function main(
flow_job_id: string,
approve_url: string,
reject_url: string
) {
const message = [
`*Production deployment awaiting approval*`,
`Job: \`${flow_job_id}\``,
`<${approve_url}|✅ Approve> | <${reject_url}|❌ Reject>`,
].join("\n");
await fetch(Deno.env.get("SLACK_WEBHOOK")!, {
method: "POST",
body: JSON.stringify({ text: message }),
});
return { notified: true };
}
Pattern 2: Error branch (conditional failure handling)
Add a Branch step after any step that might fail. The branch checks results.stepN.error — if it's set, route to an error-handling path (alert on-call, write to an error log, trigger a rollback). If it's null, continue normally.
This is cleaner than wrapping everything in try/catch inside a single script because each branch is independently logged, retried, and visible in the flow's execution graph.
Pattern 3: Fan-out with parallel loops
Process a list of items in parallel — each item runs as an independent job. A list of 100 API calls becomes 100 concurrent jobs instead of a sequential 100-iteration loop.
// Step 1 — return a list of items to process
export async function main(): Promise<string[]> {
// Returns ["user-1", "user-2", ..., "user-100"]
const rows = await db.query("SELECT id FROM users WHERE needs_sync = true");
return rows.map(r => r.id);
}
// Step 2 — Loop step in Windmill set to "parallel"
// Windmill fans this out: one job per item, all running concurrently
// Each iteration receives `flow_input.iter.value` as the current item
export async function main(user_id: string) {
await syncUser(user_id);
return { synced: user_id };
}
Pattern 4: Idempotent scheduled jobs
Scheduled flows should be safe to re-run without side effects — in case a run fails halfway and needs to be retried. The pattern: at the start of the flow, check whether the work for this time window is already done. If yes, exit early with a success status.
// Python — idempotency check at flow start
import psycopg2
from datetime import datetime, date
def main(run_date: str = None) -> dict:
"""Check if this date's batch has already been processed."""
target_date = run_date or date.today().isoformat()
conn = psycopg2.connect(wmill.get_variable("u/admin/pg_url"))
cur = conn.cursor()
cur.execute(
"SELECT completed_at FROM batch_runs WHERE run_date = %s",
(target_date,)
)
row = cur.fetchone()
conn.close()
if row:
return {"already_done": True, "completed_at": str(row[0])}
return {"already_done": False, "target_date": target_date}
The next step in the flow checks results.step1.already_done — if true, the branch exits early. If false, the processing branch runs.
Section 5: Multi-Workspace Governance for Teams
A single Windmill instance can host multiple isolated workspaces. Each workspace has its own scripts, flows, apps, variables, resources, users, and permissions. Workers can be scoped to a specific workspace or shared across all.
When to use multiple workspaces
- Dev / staging / production — separate environments on the same Windmill instance. Git sync deploys between them.
- Team isolation — Data Engineering and Backend Platform each have their own workspace. No accidental overwrites.
- Tenant isolation — Enterprise: each customer gets their own workspace with their own resource credentials and zero visibility into each other's data.
Create a workspace via CLI
# Create a new workspace from the CLI
wmill workspace add staging https://windmill.yourdomain.com ADMIN_TOKEN
# Switch to it
wmill workspace switch staging
# Create a user in the workspace
wmill user create --email [email protected] --role developer
Promote scripts between workspaces
The canonical pattern for environment promotion: script lives in dev workspace under Git. PR merged to main. CI pipeline runs wmill sync push targeting the production workspace. No copy-paste, no manual re-deployment.
# GitHub Actions — promote to production workspace on merge to main
- name: Deploy to production workspace
run: |
wmill workspace add production ${{ secrets.WINDMILL_URL }} \
${{ secrets.WINDMILL_PROD_TOKEN }}
wmill workspace switch production
wmill sync push --yes --include-schedules
Section 6: Tips, Troubleshooting, and Common Gotchas
Git sync push fails with "permission denied"
The most common cause is a deploy key without write access, or an SSH key that wasn't added to the known_hosts of the Windmill server container. Check the server logs:
docker compose logs windmill_server | grep -i "git\|ssh\|sync"
# Test SSH connectivity from inside the container:
docker exec -it windmill_server ssh -T [email protected]
Worker group jobs never start — check the tag spelling
If jobs tagged heavy sit in the queue indefinitely, the worker group name in the script metadata doesn't match WORKER_GROUP in the container's environment. Both are case-sensitive.
# Confirm the worker is registered and picking up the right tag:
docker compose logs windmill_worker_heavy | head -20
# Look for a line like:
# [INFO] Worker registered: group=heavy capacity=1
Flow hangs at approval step indefinitely
Approval steps have a configurable timeout. If the flow hangs and no one received the approval link, check two things:
- The step before the approval step (the notification script) completed successfully — check its output in the flow execution view
BASE_URLis set correctly — the approve/reject URLs in the notification embed yourBASE_URL. If it's set tolocalhost, the links won't work for remote approvers.
App Builder component shows no data
If a table or chart component shows empty results, open the browser console. Windmill makes the API call in the browser — CORS errors, auth failures, or script errors all surface there. The most common issue: the script connected to the component throws an exception, which Windmill silently converts to an empty result. Click the component, open Run History, and read the actual error from the script run.
Parallel loop creates too many concurrent jobs
Fanning out 10,000 items in a parallel loop will create 10,000 concurrent jobs. That hammers the database and saturates workers. Add a concurrency limit to the loop step in the flow editor, or chunk the input list in Step 1 before the loop:
// Chunk a large list before passing to a parallel loop
// Step 1 output: chunks of 100 items each
export async function main(ids: string[]): Promise<string[][]> {
const chunkSize = 100;
const chunks: string[][] = [];
for (let i = 0; i < ids.length; i += chunkSize) {
chunks.push(ids.slice(i, i + chunkSize));
}
return chunks;
// Step 2: loop over chunks (not individual items)
// Step 3: inner loop processes each chunk sequentially
}
wmill sync pull overwrites local changes
Always run wmill sync pull --dry-run before a real pull to see what would change. If you have uncommitted local edits in the repo and pull overwrites them, they're gone. The safe workflow:
git stashany local changeswmill sync pullto get the latest Windmill stategit stash popand resolve any conflictswmill sync pushto push the merged result back
Where to Go From Here
You now have the advanced Windmill self-host setup layer working: scripts under Git, jobs routed to the right workers, internal apps built on top of your backend logic, and the workflow patterns that handle real-world complexity — approvals, error branches, parallel fan-out, and idempotent scheduling.
The next layer up:
- AI workflows and native integrations — connecting LLMs, vector databases, and Windmill's audit logging system: AI-Powered Workflows, Native Integrations, Audit Logging, and Scaling to Production
- High availability and disaster recovery — Kubernetes deployment, multi-region workers, multi-workspace governance, and backup/restore procedures: High Availability, Multi-Workspace Governance, Custom Runtimes, and Disaster Recovery
- Start from scratch — if you haven't deployed Windmill yet: Run Scripts and Automate Workflows on Your Own Infrastructure
Need help scaling Windmill for your engineering team?
Sysbrix helps teams design and operate production Windmill environments — multi-workspace governance, custom worker configurations, Git-backed deployment pipelines, and enterprise support SLAs. Whether you're migrating from Airflow, Temporal, or a home-grown cron system, we've done it before.