Self-Host Windmill and Monitor Data Breaches

Self-host Windmill CE with Docker and automate a daily data breach monitor.

Introduction

30 Second Summary

Data breaches happen constantly, but most people only find out weeks later when the affected company sends an email. What if you had your own automated watchdog quietly monitoring the services you use?

In this project, you will self-host Windmill Community Edition using Docker Compose and build a breach monitor that checks the Have I Been Pwned API daily for new breaches affecting services you care about.

What You'll Build

You will run the breach checker, walk away, and come back to find a log of every new breach that surfaced overnight for the services you track.

By the end of this project, you'll have:

  • A fully operational Windmill instance running locally where you can create, test, and schedule automations from a browser dashboard.
  • A Python breach checker that queries the public Have I Been Pwned API, filters for domains you specify, and surfaces any breaches added since your last check.
  • A daily scheduled automation that runs your breach checker hands-free, with every result logged in Windmill's run history.
  • Secret Mission: Wire up a Discord webhook so Windmill pings you directly when a new breach is found.

Are there any prerequisites?

None. This project walks you through installing Docker Desktop on Windows 11 and setting up everything from scratch. No prior experience with Docker or Windmill is required.

Before We Start

Before we dive in, let's lock in what you're about to build and why it matters.

You're going to self-host a workflow automation engine called Windmill on your own machine using Docker. Then you'll build a practical automation inside it: a breach monitor that checks the public Have I Been Pwned API every day for new data breaches affecting services you care about.

Install Docker Desktop and Spin Up Windmill

Every piece of the Windmill stack runs inside a Docker container. The server, the workers, the database, and the reverse proxy are all defined in a single Docker Compose file that spins them up together.

In this step, you will install Docker Desktop on your Windows 11 machine, download the Windmill configuration files, and launch the full Windmill CE stack with one command.

In this step, get ready to:
  • Install Docker Desktop with the WSL 2 backend and confirm it is running.
  • Download the Windmill Docker Compose files and start the stack.
  • Log in to Windmill and complete the initial setup.
Install Docker Desktop on Windows 11

Docker Desktop provides the container runtime that Windmill needs. It bundles the Docker engine, the docker compose CLI, and a graphical dashboard for managing containers.

  • Open the Docker Desktop download page by visiting this link in your browser:

https://docs.docker.com/desktop/setup/install/windows-install/

  • Click the download button at the top of the page to get Docker Desktop Installer.exe.
  • Double-click Docker Desktop Installer.exe to run the installer.
  • When asked which installation mode to use, choose per-user installation (recommended, no admin rights needed).
  • On the Configuration page, ensure Use WSL 2 is selected. This is the default.
  • Click Ok and wait for the installation to complete.

What happens during installation?

The installer places Docker Desktop in your user profile folder and enables the WSL 2 backend. No system reboot is required for per-user installation on most machines. If prompted to restart, do so and continue from where you left off.

Docker Desktop does not start automatically after installation. You need to launch it manually.

  • Press the Windows key to open your search bar.
  • Type Docker Desktop and press Enter to open it.
  • Docker will display the Subscription Service Agreement. Click Accept to continue.

Wait for Docker Desktop to finish starting. You will see the Docker Dashboard with a green status indicator in the bottom-left corner.

  • Open a terminal by pressing the Windows key, typing PowerShell, and pressing Enter.
  • Confirm Docker is running by running this command:
docker --version

✔️ I see a version number

Docker is installed and running. You are ready to move on.

ⓧ Command not found or not recognized

This means Docker Desktop is not running or the installation did not complete.

  • Check that the Docker Desktop window is open and shows a green status. If it shows "Starting", wait for it to finish.
  • Close your PowerShell window and open a new one. The PATH may not have updated in your existing session.
  • If Docker Desktop is not open, search for it in the Start menu and launch it again.
Download the Windmill compose files and start the stack

Windmill provides a ready-made Docker Compose setup. You only need three files to run the full stack: docker-compose.yml, Caddyfile, and .env.

  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir windmill-project
cd windmill-project
  • Download the three Windmill configuration files by running these commands:
curl.exe https://raw.githubusercontent.com/windmill-labs/windmill/main/docker-compose.yml -o docker-compose.yml
curl.exe https://raw.githubusercontent.com/windmill-labs/windmill/main/Caddyfile -o Caddyfile
curl.exe https://raw.githubusercontent.com/windmill-labs/windmill/main/.env -o .env

Why curl.exe instead of curl?

In Windows PowerShell 5.1, typing curl calls a different command (Invoke-WebRequest) that uses different syntax. Adding .exe ensures you call the real curl tool that ships with Windows 11.

  • Confirm all three files downloaded by running:
ls

You should see docker-compose.yml and Caddyfile listed in the output.

Finding the .env file

Files starting with a dot are hidden by default in PowerShell. If you do not see .env in the ls output, run ls -Force to reveal hidden files. It is still there.

Not seeing all three files?

  • If you see an error like "Could not resolve host", check your internet connection and try again.
  • If you get a red error about Invoke-WebRequest, you typed curl instead of curl.exe. Re-run with the .exe extension.

Help me troubleshoot my Windmill file downloads

  • Start the full Windmill stack by running:
docker compose up -d

What does this command do?

The docker compose up -d command reads docker-compose.yml and starts all defined services as background containers. The -d flag means "detached" so your terminal stays free.

The services include the Windmill server, workers, PostgreSQL, LSP, and Caddy reverse proxy.

The first run takes a minute or two as Docker downloads the container images. You will see progress bars for each image being pulled.

Seeing a docker compose error?

  • If you see "docker: command not found" or "is not recognized", Docker Desktop is not running. Go back and start it from the Windows search bar.
  • If you see a port conflict error, another service is using port 80. Stop that service or close the application using that port, then re-run the command.

Help me fix my Docker Compose startup error

Log in to Windmill and complete initial setup

Windmill is now running as a set of containers. Caddy (the reverse proxy) listens on port 80, which means you can access Windmill at http://localhost in your browser.

  • Before you open your browser, what do you think you will see at http://localhost? Take a guess.
  • Open your browser and navigate to http://localhost.

You should see the Windmill login page. This confirms the entire stack is running correctly.

  • In the Email field, enter admin@windmill.dev.
  • In the Password field, enter changeme.
  • Click Login.
  • When prompted, change the default password to something you will remember.

After changing your password, you land on the Windmill home page. You now have a fully operational Windmill CE instance with the server, workers, PostgreSQL, LSP, and Caddy reverse proxy all running locally.

Not seeing the Windmill login page?

  • Make sure your URL is http://localhost (not https). The local setup does not use HTTPS.
  • Switch back to your PowerShell terminal and run docker compose ps to check that all containers show a "running" state.
  • If containers are restarting, wait 30 seconds and refresh the page. The database may still be initializing.

Help me troubleshoot my Windmill login page

Your local Windmill instance is live. Next up, you will write and run your first Python script inside Windmill to confirm the platform can execute code.

Create and Run Your First Script

Your Windmill instance is live and you're logged in. Now you need to confirm it can actually execute code and show you the results.

In this step, you'll write a Python script directly in Windmill's built-in editor and run it. This gives you immediate proof that the platform works end-to-end before you build anything more complex.

In this step, get ready to:
  • Create and run a Hello World Python script in Windmill.
  • Explore the run history to see how Windmill logs every execution.
Create a Hello World Python script

Every script in Windmill needs a main function as its entry point. Windmill calls this function when the script runs and displays whatever it returns in the output panel.

  • From the Windmill home page, click New in the top-right corner.
  • Select Script from the dropdown menu.
  • Set the language to Python.
  • Set the script path to u/admin/hello_world.

What is a script path?

In Windmill, every script has a path that acts as its unique address. The u/admin/ prefix means it belongs to the admin user's personal space.

  • Replace the default code in the editor with the following script:
import datetime


def main():
    # Get the current time as an ISO-formatted string
    now = datetime.datetime.now().isoformat()
    return f"Hello from Windmill! The current time is {now}"

What does this code do?

The main() function is the entry point Windmill calls when you run the script. It grabs the current timestamp and returns a greeting string.

Whatever main() returns is what Windmill displays in the output panel on the right side of the editor.

Before you run this, what do you think the output will look like? Will it be plain text, a dictionary, or something else?

  • Click the Run button on the right panel to execute the script.

You should see a string that reads Hello from Windmill! The current time is followed by the current ISO timestamp.

Not seeing any output?

  • Make sure your script has a main() function. Windmill only runs code inside this function.
  • Check that main() uses return to send back a value. Using print() alone will write to logs but not display in the result panel.
  • If you see a dependency error, wait a moment and try again. Windmill installs Python packages automatically on first run.

help me debug my Windmill Python script that isn't producing output

✔️ Awesome, I've got everything!

Great. Your script ran successfully and you can see the output in the right panel.

ⓧ I'd like to double check the full code

Here is the complete script. Make sure yours matches exactly:

import datetime


def main():
    now = datetime.datetime.now().isoformat()
    return f"Hello from Windmill! The current time is {now}"
Explore the run history

Every time a script executes in Windmill, the run is logged with its output, duration, and status. This is where you'll track your breach checker once it runs on a schedule.

  • Click Runs in the left sidebar menu.
  • Find your Hello World script run at the top of the list.

You should see your run listed with a green success status, the duration it took, and the output string you saw earlier.

Don't see your run listed?

  • Make sure you clicked Runs in the left sidebar, not Schedules or another menu item.
  • If the list is empty, return to your script and click Run again. The run should appear immediately after execution completes.

help me find my script run in the Windmill Runs menu

You've confirmed that Windmill can execute Python code and log the results. Next up, you'll build a real automation that queries the Have I Been Pwned API for data breaches.

Build the Breach Checker Script

Checking whether a service you use has been breached means opening a website, searching manually, and remembering to do it again tomorrow. You will forget. Everyone does.

In this step, you will write a Python script inside Windmill that calls the Have I Been Pwned public API, filters breaches for the services you care about, and returns structured results. By the end, one click of "Run" gives you a full breach report.

In this step, get ready to:
  • Create a Python script that queries the HIBP breaches API.
  • Filter results to only the services you track.
  • Add date filtering so repeated runs show only new breaches.
Create the breach checker script

The Have I Been Pwned API exposes a public endpoint that returns every known data breach. Your script will call that endpoint, loop through the results, and keep only the breaches that match domains you specify.

  • In Windmill, click New in the top-right corner and select Script.
  • Set the language to Python.
  • Set the path to u/admin/breach_checker.
  • Clear any boilerplate code in the editor, then paste the following code that sets up the API call:
import requests
import datetime


def main(services: list):
    # Call the HIBP public API for all known breaches
    response = requests.get(
        "https://haveibeenpwned.com/api/v3/breaches",
        headers={"User-Agent": "Windmill-Breach-Monitor"},
    )
    response.raise_for_status()

    all_breaches = response.json()

What does this code do?

The main function accepts a services list of domain strings (like adobe.com). Windmill automatically turns function parameters into a form UI on the right panel.

The API requires a User-Agent header on every request. Without one, the server returns a 403 Forbidden error. The raise_for_status() call ensures your script stops cleanly if anything goes wrong with the request.

  • Directly below the code you just pasted, add the filtering and return logic:
    # Filter for breaches matching our tracked services
    new_breaches = []
    for breach in all_breaches:
        domain = breach.get("Domain", "").lower()

        if domain in [s.lower() for s in services]:
            new_breaches.append(
                {
                    "name": breach["Name"],
                    "title": breach["Title"],
                    "domain": breach["Domain"],
                    "breach_date": breach["BreachDate"],
                    "added_date": breach["AddedDate"],
                    "pwn_count": breach["PwnCount"],
                    "data_classes": breach["DataClasses"],
                    "description": breach["Description"][:200],
                }
            )

    # Return results based on what was found
    if new_breaches:
        return {
            "status": "NEW_BREACHES_FOUND",
            "count": len(new_breaches),
            "breaches": new_breaches,
        }
    else:
        return {
            "status": "ALL_CLEAR",
            "message": f"No new breaches found for {len(services)} monitored services.",
        }

What does the filtering logic do?

The loop checks each breach's Domain field against your list of tracked services. Both sides are lowercased so Adobe.com still matches adobe.com.

Matching breaches get appended to new_breaches with just the fields you care about: name, domain, dates, affected account count, and what data was exposed (truncated to 200 characters).

Test with real data

The HIBP breaches endpoint is fully public. No API key is needed for this call.

  • In the input panel on the right side of the editor, find the services field.
  • Enter the following list of domains to monitor:
["adobe.com", "linkedin.com", "dropbox.com"]
  • Before you click Run, think about what you expect to see. These are well-known companies that have had major breaches. Do you think the script will find matches?
  • Click Run (or press Ctrl+Enter).

You should see a result with "status": "NEW_BREACHES_FOUND" and a list of matching breaches, each showing the breach name, domain, breach date, and exposed data classes.

Why does it return so many results?

Right now, the script returns every breach ever recorded for those domains, regardless of when it was added. Adobe alone had a breach affecting 153 million accounts. In the next substep, you will add date filtering so only recently discovered breaches appear.

Seeing an empty result or an error?

  • If you see 403 Forbidden, check that the User-Agent header is present in your requests.get() call. The HIBP API rejects requests without one.
  • If you see a module import error, Windmill installs dependencies automatically on first run. Wait a moment and run again.
  • Make sure your services input is a valid JSON array with quoted strings and no trailing commas.

Still stuck? Help me debug my breach checker script in Windmill

Add date filtering

The script works, but it dumps every historical breach for your tracked services. For a daily monitor, you only want breaches discovered since your last check.

  • In the editor, find the function signature at the top of your script:
def main(services: list):
  • Replace it with this version that adds a last_checked parameter:
def main(services: list, last_checked: str = "2026-01-01T00:00:00Z"):
  • Next, find this line in your script:
    all_breaches = response.json()

    # Filter for breaches matching our tracked services
    new_breaches = []
    for breach in all_breaches:
        domain = breach.get("Domain", "").lower()

        if domain in [s.lower() for s in services]:
  • Replace that section with the following, which adds the date cutoff and checks AddedDate against it:
    all_breaches = response.json()

    # Parse the cutoff date for filtering
    cutoff = datetime.datetime.fromisoformat(last_checked.replace("Z", "+00:00"))

    # Filter for breaches matching our tracked services
    new_breaches = []
    for breach in all_breaches:
        domain = breach.get("Domain", "").lower()
        added_date = datetime.datetime.fromisoformat(
            breach["AddedDate"].replace("Z", "+00:00")
        )

        if domain in [s.lower() for s in services] and added_date > cutoff:
  • Finally, find the ALL_CLEAR message near the bottom:
            "message": f"No new breaches found for {len(services)} monitored services.",
  • Replace it with this version that includes the date:
            "message": f"No new breaches found for {len(services)} monitored services since {last_checked}.",

Why filter by AddedDate instead of BreachDate?

The BreachDate is when the breach actually occurred (sometimes years ago). The AddedDate is when HIBP learned about it and added it to their database. You want to detect when new breaches surface, so AddedDate is the correct filter.

  • In the input panel on the right, set last_checked to 2024-01-01T00:00:00Z to test with a recent cutoff date.
  • Keep services set to ["adobe.com", "linkedin.com", "dropbox.com"].
  • Before you run it, predict: will you see fewer results than last time? Why?
  • Click Run.

You should see either a shorter list of breaches (only those added after January 2024) or an ALL_CLEAR result confirming no new breaches since that date. The date filter is working.

Getting a date parsing error?

  • Make sure your last_checked value is a full ISO string like 2024-01-01T00:00:00Z. Partial dates like 2024-01-01 will fail to parse.
  • Double check that the cutoff line sits between all_breaches = response.json() and the for loop.

Still stuck? Help me fix the date filtering in my Windmill breach checker

✔️ Awesome, I've got everything!

Great. Double check you have saved your script in Windmill by clicking Save (or Ctrl+S) if you have not already.

ⓧ I'd like to double check the full code

Your u/admin/breach_checker script should contain three sections in order:

  • The imports (requests and datetime) followed by the def main(services: list, last_checked: str = "2026-01-01T00:00:00Z"): function signature.
  • The API call block: requests.get with the User-Agent header, raise_for_status(), and all_breaches = response.json().
  • The cutoff parsing line, then the for loop that checks both domain and added_date > cutoff, building the new_breaches list.
  • The return block: NEW_BREACHES_FOUND dict if matches exist, ALL_CLEAR dict otherwise.

Scroll up through your code chunks and confirm each section is present in order. If anything looks off, compare line by line.

Your breach checker works. One click in Windmill gives you a filtered breach report for any services you track. The catch? You still have to remember to click Run every day. Next up, you will turn this into a hands-free daily automation with Windmill's built-in scheduler.

Schedule the Breach Checker to Run Daily

Your breach checker works. It queries the HIBP API, filters for your tracked services, and hands you the results. There is just one problem: you have to click Run every single time.

A monitoring tool that depends on you remembering to run it is not really monitoring anything. Windmill has a built-in scheduler that attaches a cron expression to your script. After this step, your breach checker runs daily whether you open Windmill or not.

In this step, get ready to:
  • Enable the built-in schedule on your breach checker script.
  • Deploy the script with its schedule active.
  • Confirm the schedule appears in the Schedules menu and a run is logged.
Enable the schedule on your script

Every Windmill script can have a primary schedule attached directly in its settings. This is the simplest path: you configure the schedule once, deploy, and Windmill fires the script on the cadence you set.

  • Return to your u/admin/breach_checker script in Windmill.
  • Open the script's Settings (the gear icon or Settings tab in the editor toolbar).
  • Find the Schedule section.
  • Toggle Schedule enabled to on.
  • In the cron expression field, enter 0 8 * * * to run every day at 8:00 AM.
  • Fill in the arguments below the cron expression.
  • For services, enter ["adobe.com", "linkedin.com", "dropbox.com"].
  • For last_checked, enter 2026-01-01T00:00:00Z.

What is a cron expression?

A cron expression is a compact string that defines a recurring schedule. It has five fields: minute, hour, day-of-month, month, and day-of-week.

The expression 0 8 * * * means: at minute 0, hour 8, every day of the month, every month, every day of the week. Windmill also offers a visual cron builder if you prefer clicking over typing.

Deploy and confirm the schedule runs

Toggling the schedule on is not enough by itself. You need to deploy the script for Windmill to activate the schedule and start tracking runs.

  • Click Deploy to save the script with its schedule enabled.
  • Navigate to the Schedules menu on the left sidebar.

Before you look at the list, what do you expect to see for your breach checker? Think about what status and cron expression should appear.

  • Confirm your breach_checker schedule appears in the list with status Enabled and cron expression 0 8 * * *.

What should I see?

The Schedules menu shows every active and inactive schedule in your workspace. Your entry should display the script path u/admin/breach_checker, the cron expression 0 8 * * *, and a green or enabled status indicator.

  • Trigger a manual run to confirm everything works end-to-end: return to your script page and click Run (or wait for the next scheduled execution).
  • Navigate to the Runs menu on the left sidebar.
  • Confirm you see a completed run for breach_checker with its output, duration, and success status.

Schedule not appearing?

Make sure you clicked Deploy after enabling the schedule. Toggling the schedule on without deploying does not persist the change.

If the schedule shows as disabled, open the script settings again and verify Schedule enabled is toggled on, then redeploy.

Help me troubleshoot why my Windmill schedule is not appearing after I deployed my breach_checker script.

Your breach checker now runs every morning without you lifting a finger. Windmill fires it on schedule, logs every result, and you can check back any time to see what it found. Next up: the Secret Mission. You will wire up a Discord webhook so Windmill pushes alerts directly to you instead of waiting for you to check the dashboard.

Secret mission

Add a Discord Webhook Notification

Your breach checker logs results to the Windmill dashboard, but that means you have to open Windmill to know if something happened. In this secret mission, you'll wire up a Discord webhook so Windmill pushes breach alerts directly to a channel you control. No more polling the dashboard.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely on your local machine using Docker Desktop. There are no cloud costs or paid services to worry about. Docker Desktop does consume system RAM and CPU while running, so decide whether to keep it active, pause it, or remove everything.

Resources you used:

  • Docker containers running the Windmill stack (server, workers, PostgreSQL, LSP, Caddy reverse proxy).
  • Local project folder windmill-project on your Desktop.
  • Docker Desktop application installed on Windows 11.
  • Discord incoming webhook (Secret Mission only).

Keep everything running

No action needed. Choose this if you are still actively building or want Windmill to keep running your scheduled breach checks in the background.

  • Docker Desktop stays running and uses system RAM and CPU.
  • Your daily breach checker schedule continues to fire automatically.
  • All scripts, variables, and run history remain accessible at http://localhost.

Pause - I'll come back to this later

Stop the Windmill containers to free up system resources while keeping your project files and data intact. Resume anytime with a single command.

  • Open a terminal and navigate to your project folder by running:
cd ~/Desktop/windmill-project
  • Stop all Windmill containers by running:
docker compose stop
  • Your containers are now stopped. No system resources are consumed.
  • When you are ready to resume, navigate back to the folder and run:
docker compose start
  • Your scripts, schedules, and run history will all be preserved.

Delete - I don't want to use this again

Remove all project resources and start fresh. This permanently deletes your Windmill data including scripts, schedules, variables, and run history.

  • Open a terminal and navigate to your project folder by running:
cd ~/Desktop/windmill-project
  • Stop all containers and remove volumes (this deletes all Windmill data) by running:
docker compose down -v
  • Navigate back to your Desktop and delete the project folder by running:
cd ~/Desktop
Remove-Item -Recurse -Force windmill-project
  • Confirm the folder is gone by running:
ls
  • You should no longer see windmill-project in the output.

To uninstall Docker Desktop (optional):

  • Open the Windows Start menu and go to Settings.
  • Select Apps, then Apps & features.
  • Find Docker Desktop in the list and select Uninstall.
  • Select Uninstall again to confirm.

If you completed the Secret Mission (optional):

  • Open your Discord server and navigate to the channel where you created the webhook.
  • Click the gear icon to open Channel Settings.
  • Select Integrations, then Webhooks.
  • Find your webhook and click Delete Webhook.

Nice Work!

Nice Work!

Nice work! You just self-hosted a workflow automation platform and built a hands-free breach monitor that watches the services you care about.

You've learned how to:

  • Deploy Windmill Community Edition locally using Docker Compose, spinning up a full workflow engine with server, workers, and database in a single command.
  • Build a Python script inside Windmill that queries the Have I Been Pwned API and filters breaches for services you track.
  • Schedule your breach checker to run daily using Windmill's built-in cron scheduler, turning a manual task into a fully automated monitor.
  • Secret Mission: Wired up a Discord webhook so Windmill actively notifies you when new breaches are discovered.

Ready to quiz yourself?