Windmill: Turn Python, TypeScript, Bash, and SQL Scripts into Workflows, APIs, and UIs

# Windmill: Ubah Script Python, TypeScript, Bash, dan SQL Jadi Workflow, API, dan UI Halo temen-temen, di tutorial kali ini aku mau ngajak kalian kenalan sama satu tool yang menurutku sangat underrat...

By Ruby Abdullah · · tutorial
windmillautomationworkflowdevtoolsself-hosting

Windmill: Turn Python, TypeScript, Bash, and SQL Scripts into Workflows, APIs, and UIs

Hey folks, in this tutorial I want to introduce you to a tool that I think is seriously underrated but has a huge impact on developer team productivity, and it is called Windmill. If you often write small scripts for automation, like sending emails, syncing data between databases, calling third-party APIs, or generating daily reports, those scripts usually end up sitting on your own laptop, or at best on a server behind a cron job that is painful to monitor. Well, Windmill is here to solve exactly that problem.

Windmill is an open-source developer platform whose job is to turn ordinary scripts into three things at once, namely a workflow, an API endpoint, and an auto-generated UI. So you just write a main() function in Python, TypeScript, Bash, or SQL, and Windmill automatically builds an input form based on your function parameters, plus an endpoint you can call over HTTP. Pretty cool, right? In this tutorial we will go from zero, starting with the concept, how to install it using Docker, writing a Python script with a typed main(), composing several scripts into a flow, scheduling, managing secrets and resources, all the way to triggering via webhook. Let's get started.

Introduction

Before we jump into installation, I want to explain first why Windmill is different from other automation tools you might already know like Zapier, n8n, or Airflow. Windmill is code-first but still low-friction. What do I mean by that? In Zapier or n8n, you click nodes on a canvas, and when you need complex logic you hit a wall. In Airflow, you write DAGs with quite a bit of boilerplate and it is hard to run with a single click. Windmill takes a middle path that I think fits perfectly, where you write real code (so you are free to do whatever you want), but Windmill gives you a layer on top of it that makes that code instantly have a UI, an endpoint, scheduling, permission management, and observability without you having to set up anything.

There are three core concepts you absolutely must understand in Windmill, namely Script, Flow, and App.

The first one is Script. This is the smallest unit in Windmill. One script contains one main() function in your language of choice. As soon as you save it, Windmill reads your function signature and automatically generates two things, an input form from the function parameters, and an API endpoint to call that script. So for example if you have a function def main(name: str, age: int), Windmill immediately shows a form with a text input for name and a number input for age. It is almost magical, you do not have to touch a single line of HTML.

The second one is Flow. A Flow is how you connect several scripts into one pipeline. Imagine you have script A that fetches data from an API, script B that transforms that data, and script C that saves it to a database. In a Flow, you simply arrange A then B then C, and the output of a previous step can be used as the input of the next step. Flows support advanced features like for-loops (to process data repeatedly), branches (logic branching), error handlers, automatic retries, and approval steps (for human-in-the-loop). All of this can be configured through a visual editor or through a YAML definition.

The third one is App. Apps are for those of you who want to build internal tools with a more polished look. Windmill has a drag-and-drop app builder, where you can drop tables, buttons, forms, and charts, then connect those components to backend scripts. It is perfect for building admin dashboards or operational tools for non-technical teams.

Besides those three concepts, there are also three kinds of state storage that matter, namely Variable, Secret, and Resource. A Variable is a value that can be reused across many scripts. A Secret is a variable that is encrypted, perfect for storing passwords or API keys. A Resource is a structured collection of configuration, for example database credentials that contain host, port, user, password, and database name in a single object. We will cover each of these in detail.

One thing that makes me love Windmill is that it is self-hostable. So all your data and code stay on your own infrastructure, nothing leaks to a third-party cloud. For those of you working at companies with strict data privacy requirements, this is a big plus. And even better, self-hosting is super easy because you just use Docker Compose. Let's jump straight into practice.

Installation

The easiest way to try Windmill on your own machine is using Docker Compose. All you need is Docker and Docker Compose already installed. If not, install them first from the official Docker site. Once you are ready, follow these steps.

First, we create a folder for our Windmill project.

mkdir windmill-demo && cd windmill-demo

Second, we download the official docker-compose.yml file from Windmill along with its .env template.

curl -o docker-compose.yml https://raw.githubusercontent.com/windmill-labs/windmill/main/docker-compose.yml

curl -o .env https://raw.githubusercontent.com/windmill-labs/windmill/main/.env

This docker-compose.yml file already configures all the services Windmill needs, namely the PostgreSQL database (to store all data), the Windmill server, the workers (which execute your scripts), and Caddy as a reverse proxy. If you are curious, open the file, its contents look roughly like the snippet below that I simplified to make it easier to read.

cat docker-compose.yml

The core structure looks like this, folks. I rewrote a minimal version so you understand the components.

version: "3.7"

services:

db:

image: postgres:16

restart: unless-stopped

volumes:

  • dbdata:/var/lib/postgresql/data
environment:

POSTGRESPASSWORD: changeme

POSTGRESDB: windmill

healthcheck:

test: ["CMD-SHELL", "pgisready -U postgres"]

interval: 10s

timeout: 5s

retries: 5

windmillserver:

image: ghcr.io/windmill-labs/windmill:main

restart: unless-stopped

dependson:

db:

condition: servicehealthy

environment:

DATABASEURL: postgres://postgres:changeme@db/windmill

MODE: server

ports:

  • "8000:8000"

windmillworker:

image: ghcr.io/windmill-labs/windmill:main

restart: unless-stopped

dependson:

db:

condition: servicehealthy

environment:

DATABASEURL: postgres://postgres:changeme@db/windmill

MODE: worker

WORKERGROUP: default

volumes:

dbdata:

Third, we run all services with a single command.

docker compose up -d

This command downloads the required images (a bit slow at first because they are fairly large), then runs all containers in the background. You can check the container status with this command.

docker compose ps

If all containers show a status of running or healthy, then your Windmill is ready. Now open your browser and go to http://localhost:8000. You will be greeted by the initial setup page. For the first login, the default is usually email admin@windmill.dev with password changeme. Remember, this is only for development. If you want to deploy to production, you absolutely MUST change that default password and set the proper environment variables, especially POSTGRESPASSWORD and BASEURL.

After logging in, you will see the Workspace concept. A Workspace is like a work area that separates projects from one another. To get started, create one workspace first, for example name it demo. Once the workspace is created, you are inside Windmill's main dashboard where you can create scripts, flows, and apps.

For those of you who do not want the hassle of self-hosting yet and just want to play around, Windmill also has a cloud version at app.windmill.dev that has a free tier. But for this tutorial I recommend you use self-host so you feel the full control. Alright, installation is done, let's move to the most exciting part, writing scripts.

Basic Usage

In this section we will write our first Python script in Windmill. To start, in the dashboard click the Scripts menu, then click the button to create a new script and choose Python as the language. Windmill will show a code editor with an initial template.

The most important concept you have to remember is that every Windmill script MUST have a function named main. This function is the entry point. The parameters of this main function are what will be turned into an automatic input form by Windmill. That is why giving clear type hints to each parameter is really important, because from those type hints Windmill determines what kind of form input to render.

Let's write a simple script that computes and displays a personalized greeting.

def main(name: str, age: int, likescoffee: bool = True):

if likescoffee:

coffeenote = "sounds like you need a coffee before you start coding"

else:

coffeenote = "ah, you are on team tea"

return {

"greeting": f"Hello {name}, you are {age} years old",

"note": coffeenote,

"status": "ok",

}

Take a look, folks. The main function above has three parameters, namely name of type str, age of type int, and likescoffee of type bool with a default value of True. As soon as you save this script, Windmill immediately generates a form on the right side with a text input for name, a number input for age, and a toggle switch for likescoffee. Parameters that have a default value (like likescoffee) become optional in the form, while those without a default become required. This is what I meant by auto-generated UI earlier. You did not write a single line of HTML, yet you already got a functional form.

Once the script is saved, you can immediately click the Run button to try it. Fill in the form, click run, and you will see the result in the output section. The output is the object you return from the main function. Windmill displays this output in a clean JSON format.

Now what if your script needs an external library, for example requests to call an API? Well this is one of Windmill's clever features. You just import the library, and Windmill automatically detects that dependency and installs it for you. So you do not need to manage a requirements.txt or run pip install manually. Look at the following example that fetches weather data from a public API.

import requests

def main(city: str = "Jakarta"):

url = "https://wttr.in/" + city

params = {"format": "j1"}

resp = requests.get(url, params=params, timeout=10)

resp.raiseforstatus()

data = resp.json()

condition = data["currentcondition"][0]

return {

"city": city,

"tempcelsius": condition["tempC"],

"humidity": condition["humidity"],

"description": condition["weatherDesc"][0]["value"],

}

Windmill reads the import requests line above, then automatically prepares an environment with that library installed. The first run might be a bit slow because of the install process, but after that it is cached so it is fast. This is a totally different experience from managing a virtualenv manually that gives you headaches.

Besides Python, you can also write scripts in Bash. This is really useful for tasks that are more natural to do in a shell, for example calling command line tools or processing files. The format is slightly different, where arguments are taken through positional variables. Here is an example.

# the first argument is the folder name we want to check

folder="$1"

if [ -z "$folder" ]; then

echo "folder must not be empty"

exit 1

fi

echo "Checking folder contents: $folder"

filecount=$(ls -1 "$folder" 2>/dev/null | wc -l)

echo "Number of files: $filecount"

In a Bash script, Windmill still generates a form based on special comments and the order of arguments. Arguments $1, $2, and so on are mapped to form inputs. So even though you use Bash, you still get the same convenient auto-generated UI.

Now, one more cool thing. Every script you save automatically has its own API endpoint. You can see the details of this endpoint in the tab that shows how to call the script over HTTP. So your script can be called from other applications, from an external cron, or from a webhook, just with a plain HTTP request. We will dig deeper into this in the webhook trigger section later.

Advanced Usage

Alright folks, now we level up. In this section we will cover Flows, scheduling, and management of secrets, variables, and resources. This is the part where Windmill truly shows its power for serious automation.

Composing Scripts into a Flow

A Flow is like a cooking recipe, where you arrange step after step and each step can use the result of the previous step. For example we want to build a flow that first fetches a list of users from an API, then for each user we send a notification. This is a classic data pipeline example.

To create a flow, in the dashboard click the Flows menu then create a new flow. Windmill gives you a visual editor where you can add steps one by one. Each step can be an inline script (that you write right there) or a script you saved earlier in the workspace.

For the first step, we create a script that fetches data. For example like this.

import requests

def main(limit: int = 5):

resp = requests.get("https://jsonplaceholder.typicode.com/users", timeout=10)

resp.raiseforstatus()

users = resp.json()

return users[:limit]

This script returns a list of user objects. Now, in the next step, we want to process each user one by one. This is where the for-loop feature in Flows comes in. You add a new step of type for-loop, then set its iterator input to the output of the first step. Windmill automatically loops over each element of that list. Inside the loop, you access the element currently being processed through the flow context.

Inside the for-loop, we place a script that sends a notification (in this example we just print to keep it simple).

def main(user: dict):

name = user.get("name", "no name")

email = user.get("email", "no email")

print(f"Sending notification to {name} at {email}")

return {"sent": True, "email": email}

The way to connect a step's output to the next step's input in Windmill uses references. In the visual editor, you just click an input field then pick where the data comes from. Internally, Windmill stores this flow in a format you can view as JSON or YAML. References between steps are written using expressions like results.a which means the output of the step with id a. For data being looped, you use flowinput.iter.value to get the element being processed. This is very flexible because you can transform data between steps.

Flows also support other advanced features that are super useful in the real world. There are branches for conditional logic, for example if a certain condition is met then run step X, otherwise run step Y. There is an error handler that runs automatically if a step fails, perfect for sending alerts. There is a retry policy, where you can set a step to automatically retry a few times if it fails, with a certain delay. And there is an approval step, where the flow stops and waits for manual approval from a human before continuing. This approval step is great for processes that need sign-off, for example before sending a mass email or before deleting data.

Scheduling

One of the most common use cases of Windmill is running scripts or flows on a schedule, replacing traditional cron which is hard to monitor. Windmill has a built-in schedule feature that is far more pleasant to use.

To create a schedule, open the script or flow you want to schedule, then look for the Schedule option. You will be asked to enter a cron expression. The format is standard cron that you might already be familiar with. For example if you want a flow to run every day at 9 in the morning, you write the cron expression 0 9 . If every 15 minutes, /15 . Windmill also asks you to set a timezone, so you do not have to bother converting UTC time.

The advantage of scheduling in Windmill over regular cron is that you get a complete history of every execution. You can see whenever that script ran, how long it took, what its output was, and if it failed you can see the full error. You can also set a dedicated error handler for a schedule, so if a scheduled execution fails, you get a notification immediately. This is a total game changer compared to cron on a server whose errors only go into a log file that rarely gets checked.

Secret, Variable, and Resource

Now let's talk about credential and configuration management, because this is crucial for secure automation. Windmill has three concepts, namely Variable, Secret, and Resource.

A Variable is a reusable value. For example if you have a base API URL used across many scripts, it is better to store it as a variable so that if it changes you only update it in one place. To create a variable, go to the Variables menu, give it a path (for example u/ruby/baseapiurl), then fill in its value.

A Secret is essentially a variable too, but its value is encrypted in the database and will not be shown again after you save it. This is the right place to store API keys, tokens, or passwords. You just check the secret option when creating a variable.

The way to access variables and secrets inside a Python script uses Windmill's built-in library called wmill. Here is an example.

import wmill

def main(message: str):

# fetch the secret API key stored in Windmill

apikey = wmill.getvariable("u/ruby/telegramtoken")

baseurl = wmill.getvariable("u/ruby/baseapiurl")

print(f"Using base url: {baseurl}")

# apikey is used for authentication, never written in the code

result = sendmessage(apikey, message)

return {"status": "sent", "detail": result}

def sendmessage(token: str, message: str):

# illustration only, in the real world you would call a real API here

return {"messagelength": len(message)}

Notice, folks, in the code above we never write the API key literally inside the script. We fetch it from Windmill through wmill.getvariable. This is an important security best practice, do not let credentials get stuck in code that other people can see or that gets committed to git.

A Resource is a more structured concept. Imagine you want to connect to a PostgreSQL database. You need host, port, user, password, and database name. Instead of creating five separate variables, you create one Resource of type PostgreSQL whose content is all those fields in a single object. Windmill already has many built-in resource types for popular services like PostgreSQL, MySQL, MongoDB, AWS S3, SMTP, and many more. The way to access a resource in a script is also through wmill.

import wmill

def main():

# fetch the database resource stored as a whole object

db = wmill.getresource("u/ruby/postgresprod")

host = db["host"]

port = db["port"]

user = db["user"]

dbname = db["dbname"]

print(f"Connecting to {dbname} at {host}:{port} as {user}")

return {"connected": True, "database": dbname}

With resources, your configuration becomes clean, secure, and easy to reuse across many scripts without duplication. If the database password changes, you just update it in one resource, and all scripts using that resource update along with it.

Triggering via Webhook

This is the feature that in my opinion makes Windmill extremely powerful for integration. Every script and flow in Windmill automatically has a webhook URL. This means you can trigger a script execution just by sending an HTTP request to that URL. The use cases are countless, for example you want a script to run every time a new form is submitted, every time a payment comes in from a payment gateway, or every time there is an event from another service.

To see the webhook URL, open your script or flow, then look for the part that shows how to trigger via webhook. Windmill gives you two kinds of webhooks, namely sync (waits until it finishes and immediately returns the result) and async (immediately returns a job id, execution runs in the background). To call a webhook, you need an authentication token that you can generate in your account settings.

An example of calling a script webhook using curl looks like this.

# replace TOKEN, WORKSPACE, and the script path with your own

TOKEN="wmyoursecrettoken"

WORKSPACE="demo"

SCRIPTPATH="u/ruby/checkweather"

curl -X POST \

"http://localhost:8000/api/w/${WORKSPACE}/jobs/runwaitresult/p/${SCRIPTPATH}" \

-H "Authorization: Bearer ${TOKEN}" \

-H "Content-Type: application/json" \

-d '{"city": "Bandung"}'

The runwaitresult endpoint above is the sync type, so curl will wait until the script finishes and immediately show the result. If you want the async one so you do not wait, you use a different endpoint that immediately returns a job id.

TOKEN="wmyoursecrettoken"

WORKSPACE="demo"

SCRIPTPATH="u/ruby/checkweather"

async version, immediately get a job id without waiting for completion

curl -X POST \

"http://localhost:8000/api/w/${WORKSPACE}/jobs/run/p/${SCRIPTPATH}" \

-H "Authorization: Bearer ${TOKEN}" \

-H "Content-Type: application/json" \

-d '{"city": "Surabaya"}'

The JSON body you send in the request is directly mapped to your main function parameters. So {"city": "Bandung"} becomes the argument city="Bandung" in the main function. Super practical, right. With this webhook, Windmill can become the automation backend for almost anything. You can connect a website form to Windmill, connect Stripe to Windmill, or connect your internal system to Windmill, all through simple HTTP requests.

Best Practices

After touring Windmill's features, now I want to share a few best practices that I feel are important so that your Windmill usage stays clean and secure for the long term.

First, always store credentials as a Secret or Resource, never hardcode them in a script. I am repeating this because it is that important. Credentials stuck in code are a time bomb. If the script gets exported or someone else has read access, your credentials leak. With Secrets and Resources, sensitive values are encrypted and only injected at runtime.

Second, give clear and specific type hints in the main function. Because Windmill generates the UI from type hints, the more specific your type hints, the better the resulting form. Use int for numbers, bool for toggles, and you can even use enums or literals to create dropdown choices. You can also give sensible default values so users are not confused.

Third, break complex logic into several small scripts composed through a Flow. Do not build one giant script that does everything. Small scripts are easier to test, easier to reuse, and if something errors you can more easily tell which step is problematic because a Flow shows the status of each step separately.

Fourth, take advantage of retries and error handlers in Flows for critical processes. In the real world, APIs can time out, networks can drop, and services can go down temporarily. By setting a retry policy, your script automatically retries without you having to wake up in the middle of the night to re-run it manually. And with an error handler, if something truly fails, you get a notification immediately.

Fifth, use a consistent path and naming structure. Windmill uses a path system like u/username/scriptname for user-owned scripts and f/folder/scriptname for folder or team-owned scripts. Establish a clear naming convention from the start, for example a prefix based on function or team, so your workspace does not get messy once you have hundreds of scripts.

Sixth, make use of versioning and git sync. Windmill keeps a version history of each script, so if you edit something wrong, you can revert to a previous version. For serious teams, Windmill also supports syncing to a git repository, so all your scripts and flows can be reviewed through pull requests just like regular code. This makes your workflow auditable and collaborative.

Seventh, monitor your worker resources, especially if you self-host on a server with limited RAM. Each worker executes jobs in a separate process. If you run many heavy jobs simultaneously, RAM can run out quickly. You can configure the number of workers and worker groups to separate workloads, for example a worker group for light jobs and another for heavy jobs.

Eighth, add descriptions and documentation to each script. Windmill supports adding a summary and description to each script. Take the time to write a short explanation of what the script does and how to use it. Your teammates (and yourself six months from now) will thank you.

Conclusion

Well folks, that was our journey exploring Windmill from zero to its advanced features. We covered the core concepts of Script, Flow, and App. We installed Windmill using Docker Compose easily. We wrote a Python script with a typed main() function that automatically got a UI and an endpoint. We composed several scripts into a Flow with a for-loop, we scheduled executions using cron, we managed secrets, variables, and resources securely, and we triggered a script via webhook using curl.

In my opinion, Windmill's greatest strength lies in combining two worlds that are usually at odds, namely the full flexibility of writing real code, with the convenience of no-code tools that give you a UI, endpoint, scheduling, and monitoring automatically. For those of you who have had automation scripts scattered all over the place, Windmill can be a tidy home for all of them. And because it is open-source and self-hostable, you have full control over your data and infrastructure.

My advice, start small first. Take one automation script you have been running manually or via cron, move it to Windmill, give it a UI and a schedule, then feel the difference. Once you are comfortable, then explore Flows and Apps for more complex automation. I am sure once you try, you will get hooked on how pleasant it is to manage automation in Windmill.

Alright, that is all for my tutorial this time. I hope it is useful and makes you even more productive. Happy tinkering with Windmill, and see you in the next tutorial. Happy automating, folks.

Related Articles

AWS Step Functions for ML Tutorial: ML Workflow Orchestration

Tutorial Lengkap AWS Step Functions untuk ML: Orkestrasi ML Workflows AWS Step Functions menyediakan orkestrasi workflow...

Complete Dify Tutorial: Open-Source Platform for Building AI Applications

Tutorial Lengkap Dify: Platform Open-Source untuk Membangun Aplikasi AI Dify adalah platform open-source yang memungkink...

Temporal Tutorial: Durable Execution for Reliable Workflows

Temporal dengan Python: Durable Execution untuk Workflow yang Andal Temporal adalah platform untuk durable execution: ia...

ComfyUI Tutorial: Node-Based Workflows for Stable Diffusion

ComfyUI: Workflow Berbasis Node untuk Stable Diffusion ComfyUI adalah lingkungan grafis berbasis node untuk menjalankan ...