Windmill is an open-source platform for turning scripts written in Python, TypeScript, Go, Bash or SQL into internal tools, scheduled jobs, webhooks and multi-step flows. Each script's parameters automatically become a form in the UI and an API endpoint, and jobs run on workers that you control. In this tutorial you will self-host Windmill on Ubuntu 24.04 using the official Docker Compose stack, secure it with HTTPS, and create a script, a flow, a schedule and a webhook.

Prerequisites

To follow this tutorial you need:

  • A server running Ubuntu 24.04 LTS with at least 2 GB of RAM (4 GB if you plan to run several workers), for example a CubePath VPS.
  • A non-root user with sudo privileges.
  • Docker Engine and the Docker Compose plugin installed from Docker's official repository.
  • A domain name, referred to as windmill.your_domain, with a DNS A record pointing to your server's public IP. Caddy needs it to obtain a Let's Encrypt certificate.
  • Ports 80 and 443 reachable from the internet.

Step 1 - Downloading the Windmill Compose files

Windmill publishes a ready-to-use Compose stack with the server, workers, a PostgreSQL database, a language server for the editor and a Caddy reverse proxy. Create a directory for it and download the three files it needs:

sudo mkdir -p /opt/windmill
sudo chown "$USER": /opt/windmill
cd /opt/windmill
curl -fsSL -o docker-compose.yml https://raw.githubusercontent.com/windmill-labs/windmill/main/docker-compose.yml
curl -fsSL -o Caddyfile https://raw.githubusercontent.com/windmill-labs/windmill/main/Caddyfile
curl -fsSL -o .env https://raw.githubusercontent.com/windmill-labs/windmill/main/.env

Confirm the files are there:

ls -la /opt/windmill
-rw-rw-r-- 1 your_user your_user  ... .env
-rw-rw-r-- 1 your_user your_user  ... Caddyfile
-rw-rw-r-- 1 your_user your_user  ... docker-compose.yml

Open docker-compose.yml and skim it before continuing. You will see the services db, windmill_server, windmill_worker, windmill_worker_native, lsp and caddy.

Step 2 - Setting a database password

The default files use the password changeme for PostgreSQL. Find every occurrence:

grep -n changeme /opt/windmill/.env /opt/windmill/docker-compose.yml

The password appears in DATABASE_URL inside .env and in the POSTGRES_PASSWORD variable of the db service in docker-compose.yml. Generate a random password:

openssl rand -hex 24

Replace changeme with that value in both places, then restrict the permissions of .env:

nano /opt/windmill/.env
nano /opt/windmill/docker-compose.yml
chmod 600 /opt/windmill/.env

Step 3 - Enabling HTTPS with Caddy

The caddy service reads a BASE_URL environment variable that tells Caddy which address to serve. By default it is set to :80 (plain HTTP on any hostname), and the file contains commented alternatives for HTTPS. Edit the caddy service:

nano /opt/windmill/docker-compose.yml

Set BASE_URL to your domain and publish port 443 next to port 80. The relevant part of the service should look like this:

  caddy:
    ports:
      - 80:80
      - 443:443
    environment:
      - BASE_URL=windmill.your_domain

Leave the other keys of the service (image, volumes, restart policy) as they are. With a hostname in BASE_URL, Caddy obtains and renews a Let's Encrypt certificate automatically and redirects HTTP to HTTPS.

The stack also publishes port 25, used by Windmill's email triggers. If you don't need them, remove the 25:25 line from the caddy ports.

Open the web ports in UFW:

sudo ufw allow OpenSSH
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw enable

Step 4 - Starting Windmill

Pull the images and start the stack:

cd /opt/windmill
docker compose up -d

Check that all containers are running:

docker compose ps

All services should show a running or Up state, with db reported as healthy. Follow the server log until it reports that it is listening:

docker compose logs -f windmill_server

Press Ctrl+C to stop following. If Caddy fails to get a certificate, docker compose logs caddy shows the reason, usually a DNS record that does not point to the server yet or port 80 being blocked.

Open https://windmill.your_domain in your browser. Log in with the default superadmin account, email [email protected] and password changeme. Windmill immediately asks you to set up the instance.

Step 5 - Securing the instance and creating a workspace

Complete the setup wizard in this order:

  1. Change the superadmin email and password to your own. Use a strong password; this account can manage the whole instance.
  2. In Instance settings, set the Base URL to https://windmill.your_domain. Windmill uses it to build webhook URLs, approval links and OAuth redirects.
  3. Create a workspace, for example with the ID ops. Scripts, flows, variables and resources belong to a workspace, and the workspace ID appears in every API URL.

Log out and log back in with the new credentials to confirm they work.

Step 6 - Writing a Python script

Scripts are versioned functions. Windmill reads the main function's parameters and their type hints to build the input form, and it installs third-party packages by resolving the script's imports, so no requirements.txt is needed.

In the workspace, click + Script, set the path to f/monitoring/check_url (the f/ prefix places it in a folder called monitoring, which you can share with groups), choose Python and paste:

import requests


def main(url: str, expected_status: int = 200, timeout_seconds: int = 10) -> dict:
    """Check that a URL answers with the expected HTTP status."""
    response = requests.get(url, timeout=timeout_seconds)
    return {
        "url": url,
        "status_code": response.status_code,
        "ok": response.status_code == expected_status,
        "response_ms": int(response.elapsed.total_seconds() * 1000),
    }

The right panel shows the generated form with url, expected_status and timeout_seconds. Enter https://example.com and click Test. The first run takes longer because the worker installs requests; the result looks like this:

{
  "url": "https://example.com",
  "status_code": 200,
  "ok": true,
  "response_ms": 184
}

Click Deploy to save a version of the script.

Step 7 - Writing a Bash script

Bash scripts receive their arguments positionally. Windmill infers the form fields from the variables assigned from $1, $2 and so on. Create a script at f/monitoring/disk_usage, choose Bash and paste:

path="$1"
threshold="${2:-80}"

usage=$(df --output=pcent "$path" | tail -n 1 | tr -dc '0-9')
echo "Disk usage on ${path} (worker container): ${usage}%"

if [ "$usage" -ge "$threshold" ]; then
  echo "Above threshold of ${threshold}%" >&2
  exit 1
fi

Test it with path set to /. A non-zero exit code marks the job as failed, which is what schedules and flows use to trigger error handlers. Deploy the script.

Step 8 - Storing secrets as variables

Never hardcode tokens in scripts. Go to Variables, click + Variable, set the path to f/monitoring/slack_webhook_url, paste the value and mark it as Secret. Secret values are encrypted in the database and hidden in the UI.

Read the variable from Python with the wmill client, which Windmill also installs automatically:

import wmill

webhook_url = wmill.get_variable("f/monitoring/slack_webhook_url")

For structured credentials, such as a PostgreSQL connection, use Resources instead. A resource has a type (for example postgresql) and can be selected directly from a script's input form.

Step 9 - Building a flow

A flow chains scripts into steps, passing results from one to the next. Click + Flow, set the path to f/monitoring/check_sites and build it:

  1. Open Settings > Inputs of the flow and add an input urls of type array of strings.
  2. Add a For loop step. Set its iterator expression to flow_input.urls.
  3. Inside the loop, add a step that uses the workspace script f/monitoring/check_url. Set its url input to the expression flow_input.iter.value, which is the current item of the loop.
  4. After the loop, add an inline Python step that summarizes the results. Set its results input to results.a, where a is the ID Windmill gave the loop step (shown on the step card):
def main(results: list) -> dict:
    failed = [r["url"] for r in results if not r["ok"]]
    if failed:
        raise Exception(f"Unhealthy URLs: {', '.join(failed)}")
    return {"checked": len(results), "failed": 0}

Test the flow with urls set to ["https://example.com", "https://your_domain"]. The run view shows each loop iteration and the output of every step. Deploy the flow.

Step 10 - Scheduling the flow

Open the flow, go to Schedules (or use the Schedules menu and create a new one) and add a schedule with the cron expression 0 */5 * * * * and timezone UTC. Windmill cron expressions include a leading seconds field, so this one runs every five minutes. Fill in the urls argument and enable the schedule.

After five minutes, open Runs. You should see jobs triggered by the schedule, each with its status and duration. In the schedule settings you can also set an error handler, for example a script that posts to Slack using the variable from Step 8, so failures notify you instead of waiting to be noticed.

Step 11 - Triggering scripts through webhooks

Every deployed script and flow has webhook endpoints. First create an API token: open your user menu, go to Account settings > Tokens and create a token. Store it in a shell variable on the machine you will call from:

export WM_TOKEN='your_token'

Run the check_url script and wait for its result in a single synchronous request:

curl -s -X POST \
  "https://windmill.your_domain/api/w/ops/jobs/run_wait_result/p/f/monitoring/check_url" \
  -H "Authorization: Bearer $WM_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"url": "https://example.com"}'
{"url":"https://example.com","status_code":200,"ok":true,"response_ms":161}

For long-running jobs, use the asynchronous endpoint instead. It returns a job UUID immediately, and the job appears in Runs:

curl -s -X POST \
  "https://windmill.your_domain/api/w/ops/jobs/run/p/f/monitoring/check_url" \
  -H "Authorization: Bearer $WM_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"url": "https://example.com"}'

Flows use the same URLs with /f/ in place of /p/, for example .../jobs/run/f/f/monitoring/check_sites. The script's Triggers > Webhooks panel shows the exact URLs for each script. For external services that call the webhook, create a dedicated token with a limited scope instead of using your personal one.

Step 12 - Scaling workers

Each windmill_worker container runs one job at a time. The Compose file starts several replicas through deploy.replicas. To handle more concurrent jobs, raise the number of replicas:

cd /opt/windmill
docker compose up -d --scale windmill_worker=5

Check the result in Workers in the Windmill UI, which lists every connected worker and its group. Budget roughly 1 GB of RAM per standard worker for Python jobs with dependencies.

Updating Windmill

Windmill releases frequently. To update, pull the new images and recreate the containers; database migrations run automatically on startup:

cd /opt/windmill
docker compose pull
docker compose up -d

Back up the database before major upgrades:

docker compose exec -T db pg_dump -U postgres windmill | gzip > ~/windmill-$(date +%F).sql.gz

Troubleshooting

The browser shows a certificate error or Caddy keeps restarting. Check docker compose logs caddy. Let's Encrypt must reach the server on port 80 using the name in BASE_URL; verify the DNS record with dig +short windmill.your_domain.

Jobs stay in the queue and never start. No worker is available for the job's tag. Open Workers and check that workers are connected, then look at docker compose logs windmill_worker for errors.

A Python script fails with ModuleNotFoundError. Windmill maps imports to packages by name, which fails when they differ. Pin the package explicitly with a comment at the top of the script, for example # requirements: followed by the package name on the next comment line, as described in Windmill's dependency documentation, or import it under its standard name.

windmill_server restarts with a database authentication error. The password in DATABASE_URL does not match the one PostgreSQL was initialized with. Make both values match the password that was set when the db volume was first created.

Conclusion

You deployed Windmill on Ubuntu 24.04 with Docker Compose and automatic HTTPS, replaced the default credentials, and created Python and Bash scripts, a looping flow, a schedule and webhook endpoints. As next steps, invite teammates to the workspace and organize permissions with folders and groups, connect your databases as resources to build internal apps on top of your scripts, and sync the workspace to a Git repository so scripts are reviewed like the rest of your code.