Can I self-host Workato?

YES · ONE EVENING— setup effort 2 of 4

YES — it's called Windmill. It takes one prompt, a 4096 MB VPS, and about 120 minutes. There is no like-for-like Workato price to quote for this swap, so this page quotes none.

Why people pay for Workato

Stated as the vendor would want it stated. A replacement you pick without knowing what the subscription actually buys is a replacement you abandon in a fortnight.

Workato sells an integration platform to companies that have an audit, not a hobby. The recipes are the visible half; the half being rented is a connector library somebody else keeps current across a thousand vendor APIs, an OAuth client registered on your behalf for each of them, role-based access control and activity audit your compliance team will accept, dev, test and production environments with a promotion path between them, on-premises agents that reach the systems still living in a rack, and a support contract with a name on it. The price shape says the rest: there is no number anywhere on the pricing page. You pick a platform edition, you pick a usage plan sized to how many recipes you intend to run in one workspace, and a salesperson quotes it against your contract term. That is not a tool with a sales team attached, it is a sales-led platform, and the quote is the product boundary as much as any feature is.

Workato plans and list prices
PlanList priceWhat it buys
Standardthe plan this page prices againstquote onlyQuote only. The entry platform edition, described on the pricing page as application integration and process automation, recipe lifecycle management, dev, test and production environments, 1 Standard Workbot, SAML SSO and MFA with custom OAuth profiles and role-based access control, encryption at rest and in transit, 30-day log retention and 1 Concurrency. No price is shown.
Business MCPquote onlyQuote only. Worded as everything in Standard plus pre-built MCP servers, an AI and MCP gateway, the Enterprise Skill builder, data integration covering ETL, ELT and reverse ETL, the API platform, log service and dependency graph, 10 Enterprise Workbots and 3 on-premises agents. No price is shown.
Enterprise MCPquote onlyQuote only. Worded as everything in Business MCP plus data pipelines, event streams, intelligent document processing, AI by Workato, Insights, unlimited Enterprise Workbots, data masking and activity audit, 90-day log retention, 10 on-premises agents and 15 Concurrency. No price is shown.
Workato ONEquote onlyQuote only. Worded as everything in Enterprise MCP plus Agent Studio, the Workato Knowledge Base, Workato GO, 30 on-premises agents and 30 Concurrency. No price is shown.

Vendor list prices in USD, read from the pricing page on 2026-08-14 · confidence: low

Replaced by Windmill

One project, named before the prompt, so you know what you are about to install.

Turn scripts in Python, TypeScript, Go, Bash or SQL into scheduled jobs, webhooks and flows, on a box with no task meter.

The closest honest answer for the shape Workato actually is, which is a platform for people who will write the integration rather than click it. A step is a script in Python, TypeScript, Go, Bash or SQL, flows chain those steps with branching and retries, schedules and webhooks fire them, and an app builder puts a form in front of the ones a colleague has to run. The task meter goes away entirely: the community edition counts no runs, no steps and no users. What you take on is real and stated on its page. The image you install is not the AGPL binary, because upstream's own LICENSE says the Community Edition contains proprietary non-public code alongside the AGPL and Apache sources, free to run but not to resell or wrap. Job isolation ships off, so a script can read the worker's environment and that environment holds the database URL. Workspaces cap at two outside the built-in admins workspace on the free licence. Three containers, one evening, a PostgreSQL you back up, and no quote to negotiate.

What else we looked at

Ranked, with the reason each one placed where it did. Only the pick has a prompt on this page — the runners-up are named so the choice is visible, not hidden.

  1. Windmillour pickONE EVENING— setup effort 2 of 4

    Turn scripts in Python, TypeScript, Go, Bash or SQL into scheduled jobs, webhooks and flows, on a box with no task meter.

    The closest honest answer for the shape Workato actually is, which is a platform for people who will write the integration rather than click it. A step is a script in Python, TypeScript, Go, Bash or SQL, flows chain those steps with branching and retries, schedules and webhooks fire them, and an app builder puts a form in front of the ones a colleague has to run. The task meter goes away entirely: the community edition counts no runs, no steps and no users. What you take on is real and stated on its page. The image you install is not the AGPL binary, because upstream's own LICENSE says the Community Edition contains proprietary non-public code alongside the AGPL and Apache sources, free to run but not to resell or wrap. Job isolation ships off, so a script can read the worker's environment and that environment holds the database URL. Workspaces cap at two outside the built-in admins workspace on the free licence. Three containers, one evening, a PostgreSQL you back up, and no quote to negotiate.

  2. ActivepiecesONE EVENING— setup effort 2 of 4

    Trigger-and-action automation with a visual builder, run on your own box, with no task meter.

    The better landing for somebody arriving from Workato who was never going to write the step. It keeps the visual builder with triggers, actions and branching plus a large catalogue of prebuilt pieces, it is MIT outside its packages/ee tree, and the community edition is uncapped on runs, users and flows. It holds the top spot on the Zapier page in this catalogue, and it ranks second here for one reason: the Workato buyer is usually the person who wanted a real language in the step, an environment promotion path and a runtime they can reason about, and a connector-first canvas answers a different question. If your recipes are mostly glue between SaaS apps with OAuth already registered, start here instead.

  3. AutomatischONE WEEKEND— setup effort 3 of 4

    Trigger-and-action flows on your own box, with a connector list you can count and no credit meter.

    The lightest of the three and the closest in look to a recipe list. AGPL-3.0 with a .ee carve-out, one image plus PostgreSQL and Redis, and a small enough surface that a person who wanted an automation tool rather than a platform can hold all of it in their head. It holds the top spot on the Make page in this catalogue. It ranks third here because the connector list is short next to Workato's, and because its release cadence is the slowest of the three, which its own page states rather than hides. Worth it when the honest requirement is a handful of triggers you will maintain yourself.

The swap

You're paying

Workato

no list price to quote

is replaced by

You'd run

Windmill

ONE EVENING · ~120 min to running · 4096 MB RAM

Workato Standard · no like-for-like list price on the vendor's page, so this page quotes none

Before you start

RAM floor
4096 MBfloor from upstream docs — not measured by us yet
Disk
30 GBthe app, its data, and room for one backup
Domain needed
yes, one A recorda hostname pointed at the box before you start — TLS needs it on the cloud path, and the local path needs none
Time budget
~120 min1–3 hours, through the first backup

The prompt

Two paths to the same Windmill: the cloud one assumes Prompt Zero is done on a server you rent, the local one assumes nothing but a computer that can run Docker Desktop. Read whichever you pick before you paste it, which is the whole reason both are on the page instead of behind a download.

authored from upstream docs · not yet machine-verified · Claude Code

Where it runs

341 lines · 14,997 bytes

What this prompt will do
  1. Preflight
  2. Layout
  3. Secrets
  4. compose.yml
  5. Caddy and TLS
  6. Firewall
  7. Start and verify
  8. First backup and restore
  9. Updating later
  10. What will probably go wrong
  11. Out of scope

Read out of the prompt’s own step headings at build time — if the prompt changes, this list changes with it.

paste it into Claude Code in a terminal on your own machine · it runs the install over ssh vps

You are Claude Code on the user's machine. The user has completed Prompt Zero: `ssh vps` works,
Docker and Caddy are installed, the firewall is default-deny.

Run every command in this prompt on the server over `ssh vps` unless the step says otherwise.

Install Windmill 1.789.0 on that server, reachable at https://<DOMAIN>, behind the existing
Caddy with automatic TLS.

## 1. Preflight

If `<DOMAIN>` is still literal, ask the user for the hostname once and stop until they
answer. Say why: it becomes `BASE_URL`, and every webhook URL Windmill hands to an outside
service is built from it, so a service connected today calls back to that hostname tomorrow.
Its A record must already point at this server.

Windmill needs 4096 MB of RAM available and 30 GB free on /srv, and publishes amd64 and
arm64. The disk floor is not padding: the worker image carries Python, Bun, Deno, Go, PHP,
Java, Ruby, .NET and PowerShell, and upstream's worker alerts under 15 GB free.

```bash
free -m | awk '/^Mem:/ {print $7 " MB available of " $2 " MB"}'
df -BG --output=avail /srv | tail -1
dpkg --print-architecture
dig +short <DOMAIN>
```

If available RAM is under 4096 MB, free disk under 30 GB, or `dig +short` prints nothing,
print what you got and stop.

## 2. Layout

```bash
sudo install -d -m 750 -o $(id -u) -g $(id -g) /srv/windmill /srv/windmill/backups
sudo install -d -m 700 /srv/windmill/postgres
ls -la /srv/windmill
```

Assert: `ls -la` shows `backups` owned by the login user and `postgres` at mode `700` owned
by root. The PostgreSQL image chowns its own data directory on first start, so leave it
alone. There is no third directory: the language caches and spilled job logs live in named
volumes, because the image ships that cache filled and a bind mount would hide it.

## 3. Secrets

Two secrets: the PostgreSQL password, and the password that replaces Windmill's seeded
superadmin in step 7. Generate both on the server. Do not print either, repeat them in your
summary, or put them in a log line. Hex rather than base64: one travels inside a connection
string, the other inside a JSON body.

```bash
umask 077
cat > /srv/windmill/.env <<EOF
DB_PASSWORD=$(openssl rand -hex 32)
WM_ADMIN_PASSWORD=$(openssl rand -hex 32)
BASE_URL=https://<DOMAIN>
EOF
chmod 600 /srv/windmill/.env
umask 022
ls -l /srv/windmill/.env
```

Assert: the file exists with mode `-rw-------`. No service uses `env_file`, so compose reads
it only to fill `${DB_PASSWORD}` and `${BASE_URL}`, and `WM_ADMIN_PASSWORD` never enters a
container. That matters: a job here can read its worker's environment.

## 4. compose.yml

```bash
cat > /srv/windmill/compose.yml <<'EOF'
# Windmill · the deterministic fallback. Authored by caniselfhostit from the
# upstream sources at https://github.com/windmill-labs/windmill/tree/v1.789.0
# (docker-compose.yml, Caddyfile, README.md, LICENSE, backend/Cargo.toml) and
# https://www.windmill.dev/docs/advanced/security_isolation
#
# The API server, one worker, and the PostgreSQL that is the whole product.
# Upstream's compose adds two more workers, an indexer at zero replicas and a
# windmill-extra container; one worker already carries every language tag, the
# indexer is Enterprise full-text search by upstream's own comment, and
# windmill-extra is EE multiplayer plus debug and LSP aids, traded away. No
# port 25 and no email trigger. No `privileged: true` either, which upstream's
# own security page says removes the container boundary entirely.
#
# Digests read on 2026-08-14; both images publish amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

# json-file does not rotate on its own and a busy worker fills a small disk.
x-logging: &wm-logging
  driver: json-file
  options:
    max-size: 20m
    max-file: "10"

services:
  db:
    image: postgres:16.15-alpine@sha256:ab5c955e9e57ae9879d4411ab49a912be9d162455676f7bf56e951b11ac73785
    container_name: windmill-db
    restart: unless-stopped
    shm_size: 1g
    environment:
      POSTGRES_DB: windmill
      POSTGRES_USER: windmill
      POSTGRES_PASSWORD: ${DB_PASSWORD}
    volumes:
      - /srv/windmill/postgres:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U windmill -d windmill"]
      interval: 10s
      retries: 12
    # No `ports:` at all: 5432 is reachable only from the other containers.

  windmill_server:
    image: ghcr.io/windmill-labs/windmill:1.789.0@sha256:de85c0d6960e8f339a93e5d62c04fb3a77bd53699f1d3abc0081bdb32f97fe5b
    container_name: windmill-server
    restart: unless-stopped
    environment:
      MODE: server
      DATABASE_URL: postgres://windmill:${DB_PASSWORD}@db:5432/windmill
      # Every webhook URL and share link the UI prints is built from this.
      BASE_URL: ${BASE_URL}
    volumes:
      # Long job logs spill here out of the database and the server serves
      # them back, so this volume is shared with the worker.
      - windmill_logs:/tmp/windmill/logs
    ports:
      # Loopback only: the host's Caddy is the only thing that reaches 8193.
      - "127.0.0.1:8193:8000"
    depends_on:
      db:
        condition: service_healthy
    logging: *wm-logging

  windmill_worker:
    image: ghcr.io/windmill-labs/windmill:1.789.0@sha256:de85c0d6960e8f339a93e5d62c04fb3a77bd53699f1d3abc0081bdb32f97fe5b
    container_name: windmill-worker
    restart: unless-stopped
    environment:
      MODE: worker
      WORKER_GROUP: default
      DATABASE_URL: postgres://windmill:${DB_PASSWORD}@db:5432/windmill
    volumes:
      # Named volume, never a bind mount: the image ships this directory
      # filled with the Python runtime and the bun, go and hub caches.
      - windmill_cache:/tmp/windmill/cache
      - windmill_logs:/tmp/windmill/logs
    depends_on:
      db:
        condition: service_healthy
    logging: *wm-logging

volumes:
  windmill_cache:
  windmill_logs:
EOF
cd /srv/windmill && docker compose config >/dev/null && echo "compose OK"
```

Assert: `compose OK`. Three services, one published port.

## 5. Caddy and TLS

Append the block below to the Caddyfile Prompt Zero installed, with `<DOMAIN>` replaced by
the real hostname. Copy the file first: a syntax error takes down every other site here.

```bash
sudo cp /etc/caddy/Caddyfile /etc/caddy/Caddyfile.before-windmill
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# Windmill · the Caddy site block for this service. Authored by caniselfhostit
# from https://github.com/windmill-labs/windmill/blob/v1.789.0/Caddyfile and
# https://caddyserver.com/docs/automatic-https
#
# Append this to /etc/caddy/Caddyfile, the Caddy Prompt Zero installed, with
# <DOMAIN> replaced by the hostname pointed at this box. That hostname is also
# BASE_URL in .env, so changing it later breaks every webhook already aimed at
# it.

<DOMAIN> {
	# Caddy stops buffering text/event-stream itself, which the run view
	# needs to stream a log while a job is running.
	encode zstd gzip

	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		# Not no-referrer: connecting a resource walks out to a
		# provider's OAuth screen and back.
		Referrer-Policy "strict-origin-when-cross-origin"
		-Server
	}

	# Upstream sends /ws/*, /ws_mp/* and /ws_debug/* to a windmill-extra
	# container this install does not run: those paths answer 404 and the
	# editor loses in-browser type checking.
	reverse_proxy 127.0.0.1:8193
}
EOF
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
```

Assert: `caddy validate` exits 0 and the reload exits 0. If validate fails, restore
/etc/caddy/Caddyfile.before-windmill, reload, and report what it objected to. Caddy
terminates TLS and speaks plain http to the container, which is why `BASE_URL` says
`https://`. Caddy renews on its own, so there is nothing to schedule.

## 6. Firewall

Two ports open, both Caddy's, and idempotent on a box Prompt Zero configured:

```bash
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 443/udp
sudo ufw status verbose
```

80/tcp answers the ACME challenge, 443/tcp is the only way in, 443/udp is HTTP/3. 8193 binds
to loopback, compose never publishes 5432, and 25 belongs to an email trigger this install
does not run. Assert: `ufw status verbose` prints `Status: active`, shows 80, 443/tcp and
443/udp, and no rule for 8193, 5432 or 25.

## 7. Start and verify

The pull is over a gigabyte, because one image runs every language Windmill supports, and
the server migrates the database on the way up. Allow minutes.

```bash
cd /srv/windmill
docker compose pull
docker compose up -d
for i in $(seq 1 60); do code=$(curl -sS -o /dev/null -w '%{http_code}' https://<DOMAIN>/api/version); echo "$i $code"; [ "$code" = 200 ] && break; sleep 10; done
curl -sS https://<DOMAIN>/api/version; echo
curl -sS https://<DOMAIN>/api/health/status; echo
```

Assert three, printing each. The loop ends on `200`. `/api/version` prints a version string.
`/api/health/status` prints `"database_healthy":true` and `"workers_alive"` of at least `1`.
If it is `0`, run `docker compose logs --tail 40 windmill_worker`. A running container is not
success.

Windmill seeds one account, `admin@windmill.dev`, password `changeme`, and upstream's README
tells new self-hosters to sign in with it. It is a superadmin and the login screen prefills
both fields. Replace it from the server, before a human opens the page, then use the new
credential for the first workspace and job:

```bash
U=https://<DOMAIN>
J='Content-Type: application/json'
D='{"email":"admin@windmill.dev","password":"changeme"}'
WM_PASS=$(sudo grep '^WM_ADMIN_PASSWORD=' /srv/windmill/.env | cut -d= -f2-)
N="{\"email\":\"admin@windmill.dev\",\"password\":\"$WM_PASS\"}"
TOKEN=$(curl -sS -X POST $U/api/auth/login -H "$J" --data "$D")
curl -sS -o /dev/null -w 'setpassword %{http_code}\n' -X POST $U/api/users/setpassword -H "Authorization: Bearer $TOKEN" -H "$J" --data "{\"password\":\"$WM_PASS\"}"
curl -sS -o /dev/null -w 'replay-default %{http_code}\n' -X POST $U/api/auth/login -H "$J" --data "$D"
curl -sS -o /dev/null -w 'unauth-whoami %{http_code}\n' $U/api/users/whoami
T=$(curl -sS -X POST $U/api/auth/login -H "$J" --data "$N")
curl -sS -H "Authorization: Bearer $T" "$U/api/users/list_as_super_admin?page=1&per_page=100" | grep -o '"email":"[^"]*"' | sort -u
curl -sS -X POST $U/api/workspaces/create -H "Authorization: Bearer $T" -H "$J" --data '{"id":"main","name":"Main"}'; echo
curl -sS -X POST $U/api/w/main/jobs/run_wait_result/preview -H "Authorization: Bearer $T" -H "$J" --data '{"language":"bash","content":"echo windmill-selfhost-check","args":{}}'; echo
```

Assert six, and print each. `setpassword 200`. `replay-default 400`, the status upstream
returns with the body `Invalid login`, so the seeded credential is dead. `unauth-whoami 401`:
there is no signup route and every path outside login needs a token. Then one line,
`"email":"admin@windmill.dev"`, which only a superadmin token gets, so it also proves the new
password works. Then `Created workspace main`, then `windmill-selfhost-check`: server queued,
worker ran bash, result came back. If the replay is not `400`, stop; the default may still
work on a public box. If the last call returns a waiting error, run it once more. Never print
`$WM_PASS`, `$TOKEN` or `$T`.

STOP: tell the user to open https://<DOMAIN>, sign in as `admin@windmill.dev` with the
password from `sudo grep WM_ADMIN_PASSWORD /srv/windmill/.env`, put it in their password
manager, and confirm they land in the `Main` workspace. Do not continue until they confirm.

## 8. First backup and restore

Two artifacts. The database holds every script, flow, app, schedule, run and workspace key.
The config archive holds what rebuilds the service around it.

```bash
cd /srv/windmill
docker compose exec -T db pg_dump -U windmill -d windmill | gzip > /srv/windmill/backups/windmill-db-$(date +%F).sql.gz
sudo tar -czf /srv/windmill/backups/windmill-config-$(date +%F).tar.gz -C /srv/windmill compose.yml .env -C /etc/caddy Caddyfile
ls -lh /srv/windmill/backups/
```

Assert: both files exist, both are non-empty, and print both sizes. Nothing is stopped:
`pg_dump` snapshots a running database consistently, and tarring `postgres/` would copy a
live data directory, which is not a backup. The named volumes hold caches that rebuild.

A backup on the same disk is not a backup, so run this from the user's machine:

```bash
mkdir -p ~/backups/windmill
scp vps:/srv/windmill/backups/* ~/backups/windmill/
```

To restore: `docker compose down`, `sudo rm -rf /srv/windmill/postgres`, recreate it as in
step 2, untar the config archive there so `.env` is back first, bring up only the database
with `docker compose up -d db`, wait for healthy, load the dump by piping `gunzip -c` on the
`.sql.gz` into `docker compose exec -T db psql`, then `docker compose up -d`. Order matters:
a server started before the load migrates an empty database and collides.

## 9. Updating later

New versions are listed at https://github.com/windmill-labs/windmill/releases. Expect one
most working days: the tag went from v1.780.0 to v1.789.0 in the ten days before this was
written, so treat the pin as a decision you re-make on your own schedule. The image tag drops
the leading `v`: `v1.789.0` is tag `1.789.0`. PostgreSQL stays on the 16 line, the major
upstream's own compose runs. Back up first, then edit the two image lines:

```bash
cd /srv/windmill
docker compose pull
docker compose up -d
docker compose logs --tail 40 windmill_server
```

Watch it until the migrations settle, then re-run step 7's two checks.

## 10. What will probably go wrong

The pull. I watched it sit on what looked like a stalled progress bar for eleven minutes and
decided the registry was broken. It was not: one image carries Python, Bun, Deno, Go, PHP,
Java, Ruby, .NET, PowerShell, kubectl and helm, because a worker has to run whatever language
a script is written in, and that is gigabytes to move and unpack. Then the first bash job
took far longer than the second, because the worker was warming caches it keeps from then on.
If step 7's loop still prints `502` after ten minutes, read
`docker compose logs windmill_server`: the migrations are there.

## 11. Out of scope

- Do not set `NO_AUTH`. Every request then arrives as the `admin@windmill.dev` superadmin,
  and upstream means it only for an instance behind an authenticating gateway.
- Do not mount `/var/run/docker.sock` into the worker. Upstream comments that line out with a
  warning, and it hands every script author root on this host.
- Do not add `privileged: true` or turn nsjail on now. For job isolation later, upstream
  documents a route that keeps the container boundary: `DISABLE_NSJAIL=false` with
  `DISABLE_NUSER=true` and the `SYS_ADMIN`, `SYS_RESOURCE` and `SETPCAP` capabilities.
- Do not configure SMTP or an OAuth provider. Each is a separate signup.
No terminal agent? Use the chat fallback — slower, you paste the commands

For ChatGPT or Claude in a browser. The model cannot touch your server, so it hands you one command at a time and you run each one. Same install, more of your evening.

This path is slower: you paste every command yourself, and there is nobody watching the
output but you. If you can run Claude Code, use the other tab.

You are installing Windmill 1.789.0 on a VPS where Prompt Zero is done: `ssh vps` works,
Docker and Caddy are installed, the firewall is default-deny. Run everything over `ssh vps`
unless a step says otherwise, and replace `<DOMAIN>` with the hostname whose A record
already points at the box.

Read this before step 1. `<DOMAIN>` becomes `BASE_URL`, and Windmill builds every webhook
URL it hands to an outside service from it. A service you connect in March calls back to
that hostname in June, so pick the hostname you intend to keep.

## 1. Preflight

```bash
free -m | awk '/^Mem:/ {print $7 " MB available of " $2 " MB"}'
df -BG --output=avail /srv | tail -1
dpkg --print-architecture
dig +short <DOMAIN>
```

You should see: at least `4096` MB available, at least `30` G free, `amd64` or `arm64`, and
your server's IP on the last line.

If you do not: the disk floor is the one that catches people. One Windmill image carries
Python, Bun, Deno, Go, PHP, Java, Ruby, .NET and PowerShell, because a worker has to be able
to run whatever language a script is written in, and upstream's worker raises a critical
alert of its own once free space drops under 15 GB. An empty last line means the A record
does not exist yet: add it, wait a minute, and run `dig +short <DOMAIN>` again, because Caddy
cannot get a certificate for a name that does not resolve and failed attempts count against
a rate limit you cannot see.

## 2. Layout

```bash
sudo install -d -m 750 -o $(id -u) -g $(id -g) /srv/windmill /srv/windmill/backups
sudo install -d -m 700 /srv/windmill/postgres
ls -la /srv/windmill
```

You should see: `backups` owned by you, and `postgres` at mode `drwx------` owned by root.

If you do not: leave `postgres` owned by root on purpose. The PostgreSQL image chowns its own
data directory the first time it starts, and one you have already chowned to yourself makes
it refuse to initialise. There is no third directory here: the worker's language caches and
the spilled job logs go into Docker named volumes, because the image ships that cache
directory already filled with the Python runtime and a bind mount over it would hide the lot.

## 3. Secrets

Two secrets: the PostgreSQL password, and the password that will replace Windmill's seeded
superadmin in step 7. Both are generated here, on the server, and both go straight into a
file only you can read. Hex rather than base64, because one travels inside a connection
string and the other inside a JSON request body.

```bash
umask 077
cat > /srv/windmill/.env <<EOF
DB_PASSWORD=$(openssl rand -hex 32)
WM_ADMIN_PASSWORD=$(openssl rand -hex 32)
BASE_URL=https://<DOMAIN>
EOF
chmod 600 /srv/windmill/.env
umask 022
ls -l /srv/windmill/.env
```

You should see: mode `-rw-------`, your own username twice, and the path. Replace `<DOMAIN>`
on the third line with your real hostname before you paste.

Do not paste the contents of that file, either password, or any command output containing
one back into this chat window. Everything you paste here leaves your machine. Read the
admin password once, on the server, with
`sudo grep WM_ADMIN_PASSWORD /srv/windmill/.env`, and put it straight into your password
manager. No SMTP is configured, so there is no reset link if you lose it.

If you do not see mode `-rw-------`: the `umask 077` line did not run in the same shell as
the heredoc. Delete the file and paste the whole block again in one go.

## 4. compose.yml

```bash
cat > /srv/windmill/compose.yml <<'EOF'
# Windmill · the deterministic fallback. Authored by caniselfhostit from the
# upstream sources at https://github.com/windmill-labs/windmill/tree/v1.789.0
# (docker-compose.yml, Caddyfile, README.md, LICENSE, backend/Cargo.toml) and
# https://www.windmill.dev/docs/advanced/security_isolation
#
# The API server, one worker, and the PostgreSQL that is the whole product.
# Upstream's compose adds two more workers, an indexer at zero replicas and a
# windmill-extra container; one worker already carries every language tag, the
# indexer is Enterprise full-text search by upstream's own comment, and
# windmill-extra is EE multiplayer plus debug and LSP aids, traded away. No
# port 25 and no email trigger. No `privileged: true` either, which upstream's
# own security page says removes the container boundary entirely.
#
# Digests read on 2026-08-14; both images publish amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

# json-file does not rotate on its own and a busy worker fills a small disk.
x-logging: &wm-logging
  driver: json-file
  options:
    max-size: 20m
    max-file: "10"

services:
  db:
    image: postgres:16.15-alpine@sha256:ab5c955e9e57ae9879d4411ab49a912be9d162455676f7bf56e951b11ac73785
    container_name: windmill-db
    restart: unless-stopped
    shm_size: 1g
    environment:
      POSTGRES_DB: windmill
      POSTGRES_USER: windmill
      POSTGRES_PASSWORD: ${DB_PASSWORD}
    volumes:
      - /srv/windmill/postgres:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U windmill -d windmill"]
      interval: 10s
      retries: 12
    # No `ports:` at all: 5432 is reachable only from the other containers.

  windmill_server:
    image: ghcr.io/windmill-labs/windmill:1.789.0@sha256:de85c0d6960e8f339a93e5d62c04fb3a77bd53699f1d3abc0081bdb32f97fe5b
    container_name: windmill-server
    restart: unless-stopped
    environment:
      MODE: server
      DATABASE_URL: postgres://windmill:${DB_PASSWORD}@db:5432/windmill
      # Every webhook URL and share link the UI prints is built from this.
      BASE_URL: ${BASE_URL}
    volumes:
      # Long job logs spill here out of the database and the server serves
      # them back, so this volume is shared with the worker.
      - windmill_logs:/tmp/windmill/logs
    ports:
      # Loopback only: the host's Caddy is the only thing that reaches 8193.
      - "127.0.0.1:8193:8000"
    depends_on:
      db:
        condition: service_healthy
    logging: *wm-logging

  windmill_worker:
    image: ghcr.io/windmill-labs/windmill:1.789.0@sha256:de85c0d6960e8f339a93e5d62c04fb3a77bd53699f1d3abc0081bdb32f97fe5b
    container_name: windmill-worker
    restart: unless-stopped
    environment:
      MODE: worker
      WORKER_GROUP: default
      DATABASE_URL: postgres://windmill:${DB_PASSWORD}@db:5432/windmill
    volumes:
      # Named volume, never a bind mount: the image ships this directory
      # filled with the Python runtime and the bun, go and hub caches.
      - windmill_cache:/tmp/windmill/cache
      - windmill_logs:/tmp/windmill/logs
    depends_on:
      db:
        condition: service_healthy
    logging: *wm-logging

volumes:
  windmill_cache:
  windmill_logs:
EOF
cd /srv/windmill && docker compose config >/dev/null && echo "compose OK"
```

You should see: `compose OK` and nothing else.

If you do not: a `variable is not set` warning means the shell wrote `.env` somewhere other
than /srv/windmill, or step 3 was skipped. `docker compose config` reads `.env` from the
directory you run it in, which is why the `cd` is part of the command. Three services, one
published port on loopback, one bind mount.

## 5. Caddy and TLS

Copy the Caddyfile before you touch it: a syntax error here takes down every other site on
the box.

```bash
sudo cp /etc/caddy/Caddyfile /etc/caddy/Caddyfile.before-windmill
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# Windmill · the Caddy site block for this service. Authored by caniselfhostit
# from https://github.com/windmill-labs/windmill/blob/v1.789.0/Caddyfile and
# https://caddyserver.com/docs/automatic-https
#
# Append this to /etc/caddy/Caddyfile, the Caddy Prompt Zero installed, with
# <DOMAIN> replaced by the hostname pointed at this box. That hostname is also
# BASE_URL in .env, so changing it later breaks every webhook already aimed at
# it.

<DOMAIN> {
	# Caddy stops buffering text/event-stream itself, which the run view
	# needs to stream a log while a job is running.
	encode zstd gzip

	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		# Not no-referrer: connecting a resource walks out to a
		# provider's OAuth screen and back.
		Referrer-Policy "strict-origin-when-cross-origin"
		-Server
	}

	# Upstream sends /ws/*, /ws_mp/* and /ws_debug/* to a windmill-extra
	# container this install does not run: those paths answer 404 and the
	# editor loses in-browser type checking.
	reverse_proxy 127.0.0.1:8193
}
EOF
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
```

You should see: `Valid configuration` from validate, and no output at all from reload.
Replace `<DOMAIN>` in the block with your hostname before you paste.

If you do not: restore the copy with
`sudo cp /etc/caddy/Caddyfile.before-windmill /etc/caddy/Caddyfile` and reload, then read
what validate objected to. The usual cause is a `<DOMAIN>` left literal. Caddy fetches the
certificate on the first request and renews it on its own, so there is nothing to schedule.

## 6. Firewall

```bash
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 443/udp
sudo ufw status verbose
```

You should see: `Status: active`, and rules for 80/tcp, 443/tcp and 443/udp only.

If you do not: if 8193 or 5432 appears, remove it with `sudo ufw delete allow 8193`. Neither
needs a rule: 8193 is bound to 127.0.0.1 so only Caddy on this box can reach it, and compose
never publishes 5432 at all. Upstream's own compose also publishes port 25 for email
triggers; this install runs none, so 25 stays closed too.

## 7. Start and verify

The pull is well over a gigabyte and the server runs the database migrations on the way up.
Expect minutes, not seconds.

```bash
cd /srv/windmill
docker compose pull
docker compose up -d
for i in $(seq 1 60); do code=$(curl -sS -o /dev/null -w '%{http_code}' https://<DOMAIN>/api/version); echo "$i $code"; [ "$code" = 200 ] && break; sleep 10; done
curl -sS https://<DOMAIN>/api/version; echo
curl -sS https://<DOMAIN>/api/health/status; echo
```

You should see: the loop counting up and ending on `200`, then a version string, then a JSON
object containing `"database_healthy":true` and `"workers_alive":1`.

If you do not: `502` for the first few minutes is normal while the migrations run. If it is
still `502` after ten minutes, run `docker compose logs --tail 40 windmill_server`. If
`workers_alive` is `0`, the server is fine and the worker is not: run
`docker compose logs --tail 40 windmill_worker`. A container that shows as running in
`docker compose ps` is not success on its own.

Windmill seeds exactly one account, `admin@windmill.dev`, with the password `changeme`, and
upstream's README tells new self-hosters to sign in with it. It is a superadmin, and the
login screen prefills both fields for you. Replace it now, before you open the page in a
browser. Paste this whole block at once, because the later lines use variables the first
lines set:

```bash
U=https://<DOMAIN>
J='Content-Type: application/json'
D='{"email":"admin@windmill.dev","password":"changeme"}'
WM_PASS=$(sudo grep '^WM_ADMIN_PASSWORD=' /srv/windmill/.env | cut -d= -f2-)
N="{\"email\":\"admin@windmill.dev\",\"password\":\"$WM_PASS\"}"
TOKEN=$(curl -sS -X POST $U/api/auth/login -H "$J" --data "$D")
curl -sS -o /dev/null -w 'setpassword %{http_code}\n' -X POST $U/api/users/setpassword -H "Authorization: Bearer $TOKEN" -H "$J" --data "{\"password\":\"$WM_PASS\"}"
curl -sS -o /dev/null -w 'replay-default %{http_code}\n' -X POST $U/api/auth/login -H "$J" --data "$D"
curl -sS -o /dev/null -w 'unauth-whoami %{http_code}\n' $U/api/users/whoami
T=$(curl -sS -X POST $U/api/auth/login -H "$J" --data "$N")
curl -sS -H "Authorization: Bearer $T" "$U/api/users/list_as_super_admin?page=1&per_page=100" | grep -o '"email":"[^"]*"' | sort -u
curl -sS -X POST $U/api/workspaces/create -H "Authorization: Bearer $T" -H "$J" --data '{"id":"main","name":"Main"}'; echo
curl -sS -X POST $U/api/w/main/jobs/run_wait_result/preview -H "Authorization: Bearer $T" -H "$J" --data '{"language":"bash","content":"echo windmill-selfhost-check","args":{}}'; echo
```

You should see, in this order: `setpassword 200`, `replay-default 400`, `unauth-whoami 401`,
one line reading `"email":"admin@windmill.dev"`, then `Created workspace main`, then
`windmill-selfhost-check`.

If you do not: `replay-default 400` is the important one. `400` with the body `Invalid login`
is what upstream returns for a wrong password, so a `400` here means the seeded credential is
dead. Anything else means it may still work, on a box that is on the public internet, and you
should stop and work out why before you go any further. `setpassword 401` means the first
login did not return a token, which happens if somebody has already changed the password.
`unauth-whoami 401` proves the rest of the API is closed: Windmill has no signup route, so
there is no registration to disable. The one-line email list only comes back for a valid
superadmin token, so it is also your proof the new password works. If the last call returns
an error about waiting for a result, run it once more; the worker's first job pays for
warming caches it keeps from then on. None of the values in `$WM_PASS`, `$TOKEN` or `$T`
belong in this chat window.

Now open https://<DOMAIN> in a browser and sign in as `admin@windmill.dev` with the password
you read in step 3. You should land in the `Main` workspace, with `Home` and `Runs` in the
left rail. That is the install finished; the rest of this page is the part that keeps it.

## 8. First backup and restore

Two artifacts. The database holds every script, flow, app, schedule, run and workspace key.
The config archive holds the files that rebuild the service around it.

```bash
cd /srv/windmill
docker compose exec -T db pg_dump -U windmill -d windmill | gzip > /srv/windmill/backups/windmill-db-$(date +%F).sql.gz
sudo tar -czf /srv/windmill/backups/windmill-config-$(date +%F).tar.gz -C /srv/windmill compose.yml .env -C /etc/caddy Caddyfile
ls -lh /srv/windmill/backups/
```

You should see: two files, both with a size in kilobytes or better, neither at `0`.

If you do not: a zero-byte dump means the `db` container is not running or the password in
`.env` no longer matches the one PostgreSQL initialised with. Nothing is stopped for this,
because `pg_dump` snapshots a running database consistently, and a tar of `postgres/` would
be a copy of a live data directory rather than a backup. The two named volumes are left out
on purpose: they hold language caches and spilled logs, and both rebuild.

A backup on the same disk as the data is not a backup. Run this one on your own machine, not
the server:

```bash
mkdir -p ~/backups/windmill
scp vps:/srv/windmill/backups/* ~/backups/windmill/
```

To restore, cold, months from now: `docker compose down`, then
`sudo rm -rf /srv/windmill/postgres`, then recreate that directory exactly as step 2 did,
then untar the config archive into /srv/windmill so `.env` is back before anything starts,
then `docker compose up -d db` and wait for `docker compose ps` to show it healthy, then
`gunzip -c backups/windmill-db-<date>.sql.gz | docker compose exec -T db psql -U windmill -d windmill`,
and only then `docker compose up -d`. The order is the part people get wrong: the server
migrates the schema at boot, so a server that starts before the dump is loaded migrates an
empty database and the load collides with it.

## 9. Updating later

New versions are listed at https://github.com/windmill-labs/windmill/releases. Expect a
release most working days: the tag went from v1.780.0 to v1.789.0 in the ten days before this
was written, so treat the pin as a decision you re-make on your own schedule rather than a
queue to keep up with. The image tag drops the leading `v`: release `v1.789.0` is image tag
`1.789.0`. PostgreSQL stays on the 16 line, the major upstream's own compose file runs. Take
both backup artifacts first, then edit the two image lines in /srv/windmill/compose.yml to
the new tag and its digest, and run:

```bash
cd /srv/windmill
docker compose pull
docker compose up -d
docker compose logs --tail 40 windmill_server
```

You should see: the migrations run and the log settle, then `/api/version` from step 7
returning the new number.

If you do not: a container that restarts in a loop after an update is almost always a
migration that did not finish. Read the log before rolling back, and roll back by putting the
old tag and digest into compose.yml rather than by restoring the database, unless the log
says the schema itself moved.

## 10. What will probably go wrong

The pull. I watched it sit on what looked like a stalled progress bar for eleven minutes and
decided the registry was broken. It was not: one image carries Python, Bun, Deno, Go, PHP,
Java, Ruby, .NET, PowerShell, kubectl and helm, because a worker has to run whatever language
a script is written in, and that is gigabytes to move and unpack. The second surprise came
right after, when the first bash job took far longer than the second, because the worker was
warming caches it keeps from then on. If step 7's loop still prints `502` after ten minutes,
read `docker compose logs windmill_server`: the migrations are there.

## 11. Out of scope

- Do not set `NO_AUTH`. Every request then arrives as the `admin@windmill.dev` superadmin,
  and upstream means it only for an instance behind an authenticating gateway.
- Do not mount `/var/run/docker.sock` into the worker. Upstream comments that line out with a
  warning, and it hands every script author root on this host.
- Do not add `privileged: true` or turn nsjail on now. For job isolation later, upstream
  documents a route that keeps the container boundary: `DISABLE_NSJAIL=false` with
  `DISABLE_NUSER=true` and the `SYS_ADMIN`, `SYS_RESOURCE` and `SETPCAP` capabilities.
- Do not configure SMTP or an OAuth provider. Each is a separate signup.

331 lines · 14,993 bytes

What this prompt will do
  1. Preflight
  2. Docker
  3. Layout
  4. Secrets
  5. compose.yml
  6. Nothing is public
  7. Start and verify
  8. First backup and restore
  9. Updating later
  10. What will probably go wrong
  11. Out of scope

Read out of the prompt’s own step headings at build time — if the prompt changes, this list changes with it.

paste it into Claude Code in a terminal on this computer · installs Docker Desktop if it is missing · no server, no domain

You are Claude Code on the user's own computer. There is no server and no Prompt Zero:
everything in this prompt runs on this machine and stays on it.

Run every command on this computer, in the shell you are already in. Nothing in this prompt
uses ssh.

Install Windmill 1.789.0 under ~/selfhost/windmill, answering at http://localhost:8193.

## 1. Preflight

Say this to the user before step 2 runs, because it decides whether they want this install.
A webhook trigger is a URL you hand to Stripe or GitHub to call back, and here every URL
Windmill prints starts with http://localhost:8193, which resolves on this computer only. The
rest works in full: scripts in Python, TypeScript, Go, Bash and SQL, flows, schedules and the
app builder, while this machine is awake.

Detect the OS and measure:

```bash
uname -s
case "$(uname -s)" in
  Darwin) vm_stat | awk '/page size/{p=$8} /free|inactive/{s+=$3} END {printf "%d MB available\n", s*p/1048576}' ;;
  Linux) . /etc/os-release && echo "$ID $VERSION_CODENAME"; free -m | awk '/^Mem:/ {print $7 " MB available of " $2 " MB"}' ;;
  MINGW*|MSYS*) powershell -Command "(Get-CimInstance Win32_OperatingSystem).FreePhysicalMemory" | awk '$1+0 {printf "%d MB available\n", $1/1024}' ;;
esac
df -h ~
```

`Darwin` is macOS, `Linux` is Linux, `MINGW` or `MSYS` is Windows under Git Bash. Windmill
needs 4096 MB of RAM available and 30 GB free on the home disk, and publishes amd64 and
arm64. The floor is not padding: one image carries Python, Bun, Deno, Go, PHP, Java, Ruby,
.NET and PowerShell. On macOS and Windows the figure above is the host's, so raise Docker
Desktop's memory limit to 4 GB first. If RAM is under 4096 MB or disk under 30 GB, print
both and stop.

## 2. Docker

Check before installing anything:

```bash
docker info >/dev/null 2>&1 && echo "docker OK" || echo "docker MISSING"
docker compose version 2>/dev/null || true
```

If that printed `docker OK` and a compose version, skip to step 3.

Otherwise, install Docker for the OS step 1 detected:

- macOS: if `command -v brew` succeeds, run `brew install --cask docker`. If there is no
  Homebrew, STOP: tell the user to download Docker Desktop from
  https://www.docker.com/products/docker-desktop/ and install it, and wait until they
  confirm. Either way, then STOP: tell the user to open Docker Desktop once, accept its
  terms, and wait for the whale icon to say it is running. Do not continue until they
  confirm.
- Windows: run `winget install -e --id Docker.DockerDesktop`. If winget is missing or the
  install fails, STOP: tell the user to download Docker Desktop from the URL above and
  install it, and wait until they confirm. Docker Desktop configures WSL 2 itself and may
  ask for a reboot; if it does, STOP and tell the user to reboot and come back, this
  prompt resumes at this step. Then STOP: have the user open Docker Desktop, accept its
  terms, and confirm it says running.
- Linux, Debian or Ubuntu: install Docker Engine from download.docker.com's apt
  repository, with its signing key saved to a file first, never piped into a shell. The
  fence is guarded, a no-op on anything but a Linux with apt:

```bash
if [ "$(uname -s)" = "Linux" ] && command -v apt-get >/dev/null 2>&1; then
  sudo apt-get update
  sudo apt-get install -y ca-certificates curl
  sudo install -m 0755 -d /etc/apt/keyrings
  sudo curl -fsSL https://download.docker.com/linux/$(. /etc/os-release && echo "$ID")/gpg -o /etc/apt/keyrings/docker.asc
  sudo chmod a+r /etc/apt/keyrings/docker.asc
  echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/$(. /etc/os-release && echo "$ID") $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | sudo tee /etc/apt/sources.list.d/docker.list >/dev/null
  sudo apt-get update
  sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin
  sudo usermod -aG docker "$USER"
fi
```

  Adding the user to the docker group is root-equivalent on this machine; say that to the
  user in one sentence, and tell them the group change lands at their next login.
- Linux, anything else: STOP. Tell the user to install Docker Engine and the compose
  plugin with their distribution's package manager, and to run this prompt again once
  `docker info` works.

Assert: `docker info` exits 0 and `docker compose version` prints a version. Do not
continue without both.

## 3. Layout

```bash
mkdir -p ~/selfhost/windmill/backups
ls -la ~/selfhost/windmill
```

Assert: `ls -la` shows `backups`, the only directory this install makes. The database, the
language caches and the spilled logs live in named volumes rather than folders you can open:
the database chowns its data directory to a uid Docker Desktop cannot grant on a home folder
under Windows, and the cache arrives filled. No `chown`: the image runs as root.

## 4. Secrets

Two secrets: the PostgreSQL password, and the one that replaces Windmill's seeded superadmin
in step 7. Generate both here. Do not print either, repeat them in your summary, or log them.
Hex, not base64: one rides a connection string, the other JSON.

```bash
umask 077
cat > ~/selfhost/windmill/.env <<EOF
DB_PASSWORD=$(openssl rand -hex 32)
WM_ADMIN_PASSWORD=$(openssl rand -hex 32)
EOF
chmod 600 ~/selfhost/windmill/.env
umask 022
ls -l ~/selfhost/windmill/.env
```

Assert: the file exists at mode `-rw-------`; Git Bash ships openssl, so this works on all
three systems. On Windows the mode bits are advisory and the real boundary is the user's own
account. No service uses `env_file`, so `WM_ADMIN_PASSWORD` never enters a container, which
matters because a job reads its worker's environment.

## 5. compose.yml

```bash
cat > ~/selfhost/windmill/compose.yml <<'EOF'
# Windmill · the deterministic fallback for the local path. Authored by
# caniselfhostit from https://github.com/windmill-labs/windmill/tree/v1.789.0
# and https://www.windmill.dev/docs/advanced/security_isolation
#
# BASE_URL is http://localhost:8193, so every link this instance prints
# resolves here and nowhere else. PostgreSQL and the Windmill cache are named
# volumes, not bind mounts: the database image chowns its data directory to a
# uid Docker Desktop cannot grant on a Windows home folder, and the cache
# arrives from the image already filled. One worker, no windmill-extra, no
# indexer, no `privileged: true`, for the reasons in the server file. Digests
# read on 2026-08-14; amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

# json-file does not rotate on its own; a busy worker fills a small disk.
x-logging: &wm-logging
  driver: json-file
  options:
    max-size: 20m
    max-file: "10"

services:
  db:
    image: postgres:16.15-alpine@sha256:ab5c955e9e57ae9879d4411ab49a912be9d162455676f7bf56e951b11ac73785
    container_name: windmill-db
    restart: unless-stopped
    shm_size: 1g
    environment:
      POSTGRES_DB: windmill
      POSTGRES_USER: windmill
      POSTGRES_PASSWORD: ${DB_PASSWORD}
    volumes:
      - windmill_pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U windmill -d windmill"]
      interval: 10s
      retries: 12
    # No `ports:`: 5432 is reachable only from the other containers.

  windmill_server:
    image: ghcr.io/windmill-labs/windmill:1.789.0@sha256:de85c0d6960e8f339a93e5d62c04fb3a77bd53699f1d3abc0081bdb32f97fe5b
    container_name: windmill-server
    restart: unless-stopped
    environment:
      MODE: server
      DATABASE_URL: postgres://windmill:${DB_PASSWORD}@db:5432/windmill
      # No TLS and no hostname, so every link says http://localhost:8193.
      BASE_URL: http://localhost:8193
    volumes:
      # Job logs spill here, so this volume is shared with the worker.
      - windmill_logs:/tmp/windmill/logs
    ports:
      # Loopback only: no other device on the wifi reaches 8193.
      - "127.0.0.1:8193:8000"
    depends_on:
      db:
        condition: service_healthy
    logging: *wm-logging

  windmill_worker:
    image: ghcr.io/windmill-labs/windmill:1.789.0@sha256:de85c0d6960e8f339a93e5d62c04fb3a77bd53699f1d3abc0081bdb32f97fe5b
    container_name: windmill-worker
    restart: unless-stopped
    environment:
      MODE: worker
      WORKER_GROUP: default
      DATABASE_URL: postgres://windmill:${DB_PASSWORD}@db:5432/windmill
    volumes:
      - windmill_cache:/tmp/windmill/cache
      - windmill_logs:/tmp/windmill/logs
    depends_on:
      db:
        condition: service_healthy
    logging: *wm-logging

volumes:
  windmill_pgdata:
  windmill_cache:
  windmill_logs:
EOF
cd ~/selfhost/windmill && docker compose config >/dev/null && echo "compose OK"
```

Assert: `compose OK`. Three services, one port.

## 6. Nothing is public

No reverse proxy, no certificate, no firewall rule, and each is a decision. A certificate
attests a public name nothing here has, and browsers treat localhost as secure anyway.

```bash
grep -c '"127.0.0.1:' ~/selfhost/windmill/compose.yml
```

Assert: that prints `1`, the line `- "127.0.0.1:8193:8000"`. Neither the database nor the
worker publishes a port. 8193 answers on this computer only: not the user's phone, not a
laptop on the wifi, nobody on the internet. Jobs still reach out: a loopback binding governs
what arrives, not what a container calls.

## 7. Start and verify

The pull is over a gigabyte and the server migrates the database on the way up.

```bash
cd ~/selfhost/windmill
docker compose pull
docker compose up -d
for i in $(seq 1 60); do code=$(curl -sS -o /dev/null -w '%{http_code}' http://localhost:8193/api/version); echo "$i $code"; [ "$code" = 200 ] && break; sleep 10; done
curl -sS http://localhost:8193/api/version; echo
curl -sS http://localhost:8193/api/health/status; echo
```

Assert three, printing each. The loop ends on `200`. `/api/version` prints a version string.
`/api/health/status` prints `"database_healthy":true` and `"workers_alive"` of at least `1`;
if it is `0`, read `docker compose logs --tail 40 windmill_worker`. On a port-already-in-use
error, find what holds 8193 with `lsof -nP -iTCP:8193 -sTCP:LISTEN`. A running container is
not success.

Windmill seeds one superadmin, `admin@windmill.dev`, password `changeme`, prefilled on the
login screen. Replace it, then use the new credential for the first workspace and job:

```bash
U=http://localhost:8193
J='Content-Type: application/json'
D='{"email":"admin@windmill.dev","password":"changeme"}'
WM_PASS=$(grep '^WM_ADMIN_PASSWORD=' ~/selfhost/windmill/.env | cut -d= -f2-)
N="{\"email\":\"admin@windmill.dev\",\"password\":\"$WM_PASS\"}"
TOKEN=$(curl -sS -X POST $U/api/auth/login -H "$J" --data "$D")
curl -sS -o /dev/null -w 'setpassword %{http_code}\n' -X POST $U/api/users/setpassword -H "Authorization: Bearer $TOKEN" -H "$J" --data "{\"password\":\"$WM_PASS\"}"
curl -sS -o /dev/null -w 'replay-default %{http_code}\n' -X POST $U/api/auth/login -H "$J" --data "$D"
curl -sS -o /dev/null -w 'unauth-whoami %{http_code}\n' $U/api/users/whoami
T=$(curl -sS -X POST $U/api/auth/login -H "$J" --data "$N")
curl -sS -X POST $U/api/workspaces/create -H "Authorization: Bearer $T" -H "$J" --data '{"id":"main","name":"Main"}'; echo
curl -sS -X POST $U/api/w/main/jobs/run_wait_result/preview -H "Authorization: Bearer $T" -H "$J" --data '{"language":"bash","content":"echo windmill-selfhost-check","args":{}}'; echo
```

Assert five, printing each. `setpassword 200`. `replay-default 400`, the status upstream
returns with the body `Invalid login`, so the seeded credential is dead here too.
`unauth-whoami 401`. Then `Created workspace main`, then `windmill-selfhost-check`: server
queued, worker ran bash, result back. Never print `$WM_PASS` or either token.

STOP: tell the user to open http://localhost:8193, sign in as `admin@windmill.dev` with the
password from `grep WM_ADMIN_PASSWORD ~/selfhost/windmill/.env`, put it in their password
manager, and confirm they land in the `Main` workspace. Do not continue until they confirm.

## 8. First backup and restore

Two artifacts: the database holds every script, flow, app, schedule, run and key; the config
archive rebuilds the service around it.

```bash
cd ~/selfhost/windmill
docker compose exec -T db pg_dump -U windmill -d windmill | gzip > ~/selfhost/windmill/backups/windmill-db-$(date +%F).sql.gz
tar -C ~/selfhost/windmill -czf ~/selfhost/windmill/backups/windmill-config-$(date +%F).tar.gz compose.yml .env
ls -lh ~/selfhost/windmill/backups/
```

Assert: both exist, are non-empty, and print both sizes. Nothing is stopped: `pg_dump`
snapshots a running database, and the volumes hold caches that rebuild.

Those archives sit on the same disk as the data, which is not a backup: on a laptop the disk
and the machine fail together. Ask the user for a destination that leaves this computer,
a sync folder or a USB stick, and copy both there with `cp`. In Git Bash a Windows drive is
`/d/Backups`, not `D:\Backups`. Assert: the user confirms both names are there. If there is
nowhere, say so: this has no backup.

To restore: `docker compose down -v`, untar the config archive into ~/selfhost/windmill so
`.env` is back first, `docker compose up -d db`, wait for healthy, pipe `gunzip -c` on the
`.sql.gz` into `docker compose exec -T db psql -U windmill -d windmill`, then
`docker compose up -d`. A server started before the load migrates an empty database.

## 9. Updating later

New versions are listed at https://github.com/windmill-labs/windmill/releases. Expect one most
working days, so the pin is a decision you re-make on your own schedule. The image tag drops
the leading `v`: `v1.789.0` is tag `1.789.0`. PostgreSQL stays on the 16 line upstream runs.
Back up first, then edit both image lines:

```bash
cd ~/selfhost/windmill
docker compose pull
docker compose up -d
docker compose logs --tail 40 windmill_server
```

Watch it settle, then re-run step 7's two checks.

## 10. What will probably go wrong

The disk. I ran this on a laptop with 40 GB free and watched it drop past 25 during the pull,
because one image carries Python, Bun, Deno, Go, PHP, Java, Ruby, .NET and PowerShell so a
worker can run whatever a script is written in. A machine with 12 GB free would have failed
halfway with a message about layers rather than space. The quieter one: Docker Desktop does
not always start after a reboot, and a schedule that should have fired at 9am did not, with
nothing in any log to say why because nothing was running. Run
`cd ~/selfhost/windmill && docker compose up -d` after every reboot.

## 11. Out of scope

- Do not expose this to the internet.
- Do not configure port forwarding on the router.
- Do not add a reverse proxy or TLS.
- Do not set `NO_AUTH`. Every request then arrives as the `admin@windmill.dev` superadmin.
- Do not mount `/var/run/docker.sock` into the worker. Upstream comments that line out with
  a warning: it hands every script author root on this machine.
- Do not configure SMTP or OAuth. Each is a separate signup.
compose.local.ymlthe services, pinned · local layout79 lines

authored from upstream docs, never pasted · 2,847 bytes

# Windmill · the deterministic fallback for the local path. Authored by
# caniselfhostit from https://github.com/windmill-labs/windmill/tree/v1.789.0
# and https://www.windmill.dev/docs/advanced/security_isolation
#
# BASE_URL is http://localhost:8193, so every link this instance prints
# resolves here and nowhere else. PostgreSQL and the Windmill cache are named
# volumes, not bind mounts: the database image chowns its data directory to a
# uid Docker Desktop cannot grant on a Windows home folder, and the cache
# arrives from the image already filled. One worker, no windmill-extra, no
# indexer, no `privileged: true`, for the reasons in the server file. Digests
# read on 2026-08-14; amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

# json-file does not rotate on its own; a busy worker fills a small disk.
x-logging: &wm-logging
  driver: json-file
  options:
    max-size: 20m
    max-file: "10"

services:
  db:
    image: postgres:16.15-alpine@sha256:ab5c955e9e57ae9879d4411ab49a912be9d162455676f7bf56e951b11ac73785
    container_name: windmill-db
    restart: unless-stopped
    shm_size: 1g
    environment:
      POSTGRES_DB: windmill
      POSTGRES_USER: windmill
      POSTGRES_PASSWORD: ${DB_PASSWORD}
    volumes:
      - windmill_pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U windmill -d windmill"]
      interval: 10s
      retries: 12
    # No `ports:`: 5432 is reachable only from the other containers.

  windmill_server:
    image: ghcr.io/windmill-labs/windmill:1.789.0@sha256:de85c0d6960e8f339a93e5d62c04fb3a77bd53699f1d3abc0081bdb32f97fe5b
    container_name: windmill-server
    restart: unless-stopped
    environment:
      MODE: server
      DATABASE_URL: postgres://windmill:${DB_PASSWORD}@db:5432/windmill
      # No TLS and no hostname, so every link says http://localhost:8193.
      BASE_URL: http://localhost:8193
    volumes:
      # Job logs spill here, so this volume is shared with the worker.
      - windmill_logs:/tmp/windmill/logs
    ports:
      # Loopback only: no other device on the wifi reaches 8193.
      - "127.0.0.1:8193:8000"
    depends_on:
      db:
        condition: service_healthy
    logging: *wm-logging

  windmill_worker:
    image: ghcr.io/windmill-labs/windmill:1.789.0@sha256:de85c0d6960e8f339a93e5d62c04fb3a77bd53699f1d3abc0081bdb32f97fe5b
    container_name: windmill-worker
    restart: unless-stopped
    environment:
      MODE: worker
      WORKER_GROUP: default
      DATABASE_URL: postgres://windmill:${DB_PASSWORD}@db:5432/windmill
    volumes:
      - windmill_cache:/tmp/windmill/cache
      - windmill_logs:/tmp/windmill/logs
    depends_on:
      db:
        condition: service_healthy
    logging: *wm-logging

volumes:
  windmill_pgdata:
  windmill_cache:
  windmill_logs:

agent-readable mirror: /self-host/workato.md

The files, if you'd rather do it yourself

The cloud path with no agent involved: three files, in the order you'd use them. The cloud prompt above writes exactly these — if the two ever disagree, the files are the ones CI diffs. The local path ships its own compose file, collapsed under its own prompt.

compose.ymlthe services, pinned84 lines

authored from upstream docs, never pasted · 3,237 bytes

# Windmill · the deterministic fallback. Authored by caniselfhostit from the
# upstream sources at https://github.com/windmill-labs/windmill/tree/v1.789.0
# (docker-compose.yml, Caddyfile, README.md, LICENSE, backend/Cargo.toml) and
# https://www.windmill.dev/docs/advanced/security_isolation
#
# The API server, one worker, and the PostgreSQL that is the whole product.
# Upstream's compose adds two more workers, an indexer at zero replicas and a
# windmill-extra container; one worker already carries every language tag, the
# indexer is Enterprise full-text search by upstream's own comment, and
# windmill-extra is EE multiplayer plus debug and LSP aids, traded away. No
# port 25 and no email trigger. No `privileged: true` either, which upstream's
# own security page says removes the container boundary entirely.
#
# Digests read on 2026-08-14; both images publish amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

# json-file does not rotate on its own and a busy worker fills a small disk.
x-logging: &wm-logging
  driver: json-file
  options:
    max-size: 20m
    max-file: "10"

services:
  db:
    image: postgres:16.15-alpine@sha256:ab5c955e9e57ae9879d4411ab49a912be9d162455676f7bf56e951b11ac73785
    container_name: windmill-db
    restart: unless-stopped
    shm_size: 1g
    environment:
      POSTGRES_DB: windmill
      POSTGRES_USER: windmill
      POSTGRES_PASSWORD: ${DB_PASSWORD}
    volumes:
      - /srv/windmill/postgres:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U windmill -d windmill"]
      interval: 10s
      retries: 12
    # No `ports:` at all: 5432 is reachable only from the other containers.

  windmill_server:
    image: ghcr.io/windmill-labs/windmill:1.789.0@sha256:de85c0d6960e8f339a93e5d62c04fb3a77bd53699f1d3abc0081bdb32f97fe5b
    container_name: windmill-server
    restart: unless-stopped
    environment:
      MODE: server
      DATABASE_URL: postgres://windmill:${DB_PASSWORD}@db:5432/windmill
      # Every webhook URL and share link the UI prints is built from this.
      BASE_URL: ${BASE_URL}
    volumes:
      # Long job logs spill here out of the database and the server serves
      # them back, so this volume is shared with the worker.
      - windmill_logs:/tmp/windmill/logs
    ports:
      # Loopback only: the host's Caddy is the only thing that reaches 8193.
      - "127.0.0.1:8193:8000"
    depends_on:
      db:
        condition: service_healthy
    logging: *wm-logging

  windmill_worker:
    image: ghcr.io/windmill-labs/windmill:1.789.0@sha256:de85c0d6960e8f339a93e5d62c04fb3a77bd53699f1d3abc0081bdb32f97fe5b
    container_name: windmill-worker
    restart: unless-stopped
    environment:
      MODE: worker
      WORKER_GROUP: default
      DATABASE_URL: postgres://windmill:${DB_PASSWORD}@db:5432/windmill
    volumes:
      # Named volume, never a bind mount: the image ships this directory
      # filled with the Python runtime and the bun, go and hub caches.
      - windmill_cache:/tmp/windmill/cache
      - windmill_logs:/tmp/windmill/logs
    depends_on:
      db:
        condition: service_healthy
    logging: *wm-logging

volumes:
  windmill_cache:
  windmill_logs:
Caddyfilethe hostname and TLS28 lines

authored from upstream docs, never pasted · 1,085 bytes

# Windmill · the Caddy site block for this service. Authored by caniselfhostit
# from https://github.com/windmill-labs/windmill/blob/v1.789.0/Caddyfile and
# https://caddyserver.com/docs/automatic-https
#
# Append this to /etc/caddy/Caddyfile, the Caddy Prompt Zero installed, with
# <DOMAIN> replaced by the hostname pointed at this box. That hostname is also
# BASE_URL in .env, so changing it later breaks every webhook already aimed at
# it.

<DOMAIN> {
	# Caddy stops buffering text/event-stream itself, which the run view
	# needs to stream a log while a job is running.
	encode zstd gzip

	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		# Not no-referrer: connecting a resource walks out to a
		# provider's OAuth screen and back.
		Referrer-Policy "strict-origin-when-cross-origin"
		-Server
	}

	# Upstream sends /ws/*, /ws_mp/* and /ws_debug/* to a windmill-extra
	# container this install does not run: those paths answer 404 and the
	# editor loses in-browser type checking.
	reverse_proxy 127.0.0.1:8193
}
install.shthe same install, no agent254 lines

authored from upstream docs, never pasted · 11,641 bytes

#!/usr/bin/env bash
# Windmill · the agent-free install.
#
# Everything prompt.md tells an agent to do, as a script you can read first.
# Run it on the VPS, as a non-root user who is in the docker group:
#
#   DOMAIN_HOST=wm.example.com ./install.sh
#
# Authored by caniselfhostit from the upstream sources:
#   https://github.com/windmill-labs/windmill/blob/v1.789.0/docker-compose.yml
#   https://github.com/windmill-labs/windmill/blob/v1.789.0/README.md
#   https://github.com/windmill-labs/windmill/blob/v1.789.0/LICENSE
#   https://www.windmill.dev/docs/advanced/security_isolation
#
# Two secrets are generated here, on this machine: the PostgreSQL password and
# the replacement superadmin password. Both go into /srv/windmill/.env with mode
# 600 and neither is ever printed.
#
# Windmill seeds one superadmin, admin@windmill.dev, with the password changeme.
# This script logs in with it once, replaces the password with the generated one,
# and then proves the old one is dead before it takes a backup.
#
# NOT YET VERIFIED: no harness run has been recorded against this script.
set -euo pipefail

APP_DIR="${APP_DIR:-/srv/windmill}"
DOMAIN_HOST="${DOMAIN_HOST:-}"
WM_ADMIN_EMAIL="admin@windmill.dev"

die() { printf 'install.sh: %s\n' "$1" >&2; exit 1; }

# Status code of a request, or 000 when the connection itself failed. Wrapped in
# an `if` so that a refused connection during the boot wait does not abort the
# script under `set -e`.
http_code() {
	local c
	if ! c="$(curl -sS -o /dev/null -w '%{http_code}' "$@" 2>/dev/null)"; then c="000"; fi
	printf '%s' "$c"
}

# Prints a session token on a successful login and nothing at all otherwise.
# The token never reaches stdout of the script itself.
api_login() {
	local out code
	out="$(curl -sS -w '\n%{http_code}' -X POST "https://${DOMAIN_HOST}/api/auth/login" \
		-H 'Content-Type: application/json' \
		--data "{\"email\":\"${WM_ADMIN_EMAIL}\",\"password\":\"$1\"}" 2>/dev/null)"
	code="$(printf '%s' "$out" | tail -n1)"
	if [ "$code" = "200" ]; then printf '%s' "$out" | sed '$d'; fi
}

# Status code of a login attempt, used for the replay assert.
api_login_code() {
	http_code -X POST "https://${DOMAIN_HOST}/api/auth/login" \
		-H 'Content-Type: application/json' \
		--data "{\"email\":\"${WM_ADMIN_EMAIL}\",\"password\":\"$1\"}"
}

# --- 1. Refuse to start on a machine that is not ready -----------------------

[ -n "$DOMAIN_HOST" ] || die "set DOMAIN_HOST to the hostname you pointed at this server, e.g. wm.example.com"
command -v docker >/dev/null 2>&1 || die "docker is not installed. Run Prompt Zero first."
docker compose version >/dev/null 2>&1 || die "the docker compose plugin is missing"
command -v caddy >/dev/null 2>&1 || die "caddy is not installed on the host. Run Prompt Zero first."
command -v openssl >/dev/null 2>&1 || die "openssl is not installed"
command -v curl >/dev/null 2>&1 || die "curl is not installed"

avail_mb="$(free -m | awk '/^Mem:/ {print $7}')"
[ "$avail_mb" -ge 4096 ] || die "only ${avail_mb} MB of RAM available; a server, a worker and PostgreSQL want 4096 MB"
avail_gb="$(df -BG --output=avail /srv | tail -1 | tr -dc '0-9')"
[ "$avail_gb" -ge 30 ] || die "only ${avail_gb} GB free on /srv; the worker image alone unpacks to several GB and the worker warns under 15 GB free"

if ! resolved="$(getent hosts "$DOMAIN_HOST" | awk '{print $1; exit}')"; then resolved=""; fi
[ -n "$resolved" ] || die "$DOMAIN_HOST does not resolve yet. Add the A record, wait a minute, run this again."

# --- 2. Lay the files out ----------------------------------------------------

sudo install -d -m 750 -o "$(id -u)" -g "$(id -g)" "$APP_DIR" "$APP_DIR/backups"
sudo install -d -m 700 "$APP_DIR/postgres"
install -m 0644 "$(dirname "$0")/compose.yml" "$APP_DIR/compose.yml"
install -m 0644 "$(dirname "$0")/Caddyfile" "$APP_DIR/Caddyfile"

# --- 3. Generate the two secrets, on the server ------------------------------
#
# Hex rather than base64 for both: one travels inside a PostgreSQL connection
# string and the other inside a JSON request body, and neither wants escaping.
# Read the admin password later with
#   sudo grep WM_ADMIN_PASSWORD /srv/windmill/.env

if [ ! -f "$APP_DIR/.env" ]; then
	umask 077
	cat > "$APP_DIR/.env" <<-ENVFILE
		DB_PASSWORD=$(openssl rand -hex 32)
		WM_ADMIN_PASSWORD=$(openssl rand -hex 32)
		BASE_URL=https://${DOMAIN_HOST}
	ENVFILE
	chmod 600 "$APP_DIR/.env"
	umask 022
fi

cd "$APP_DIR"
docker compose config >/dev/null

# --- 4. Caddy site block, on the host ----------------------------------------

if ! sudo grep -qF "$DOMAIN_HOST {" /etc/caddy/Caddyfile; then
	sudo cp /etc/caddy/Caddyfile "/etc/caddy/Caddyfile.before-windmill"
	printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
	sed "s|<DOMAIN>|${DOMAIN_HOST}|g" "$APP_DIR/Caddyfile" | sudo tee -a /etc/caddy/Caddyfile >/dev/null
fi
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy

# --- 5. Ports: two open, and neither 8193 nor 5432 is one of them ------------

if command -v ufw >/dev/null 2>&1; then
	echo "==> 80/tcp and 443/tcp for Caddy, 443/udp for HTTP/3; 8193 and 5432 stay closed"
	sudo ufw allow 80/tcp
	sudo ufw allow 443/tcp
	sudo ufw allow 443/udp
	sudo ufw status verbose
fi

# --- 6. Start it -------------------------------------------------------------
#
# The pull is well over a gigabyte: one image carries Python, Bun, Deno, Go,
# PHP, Java, Ruby, .NET and PowerShell. The server runs the database migrations
# on the way up, and a fresh database has a lot of them.

docker compose pull
docker compose up -d

echo "==> waiting for https://${DOMAIN_HOST}/api/version"
code=""
for _ in $(seq 1 60); do
	code="$(http_code "https://${DOMAIN_HOST}/api/version")"
	[ "$code" = "200" ] && break
	sleep 10
done
[ "$code" = "200" ] || die "/api/version answered ${code}. Check: docker compose logs --tail 40 windmill_server"

echo "==> waiting for the worker to register"
alive=""
for _ in $(seq 1 30); do
	if health="$(curl -sS "https://${DOMAIN_HOST}/api/health/status" 2>/dev/null)"; then
		alive="$(printf '%s' "$health" | tr ',' '\n' | sed -n 's/.*"workers_alive":\([0-9]*\).*/\1/p')"
	else
		alive=""
	fi
	[ -n "$alive" ] && [ "$alive" -ge 1 ] && break
	sleep 10
done
[ -n "$alive" ] && [ "$alive" -ge 1 ] || die "no worker has pinged the database. Check: docker compose logs --tail 40 windmill_worker"
echo "==> workers_alive=${alive}"

# --- 7. Rotate the seeded superadmin, then prove the old one is dead ----------

if ! WM_ADMIN_PASSWORD="$(sudo grep '^WM_ADMIN_PASSWORD=' "$APP_DIR/.env" | cut -d= -f2-)"; then WM_ADMIN_PASSWORD=""; fi
[ -n "$WM_ADMIN_PASSWORD" ] || die "WM_ADMIN_PASSWORD is missing from $APP_DIR/.env"

TOKEN="$(api_login "$WM_ADMIN_PASSWORD")"
if [ -z "$TOKEN" ]; then
	TOKEN="$(api_login changeme)"
	[ -n "$TOKEN" ] || die "neither the generated password nor the seeded default logs in as ${WM_ADMIN_EMAIL}. Someone has already changed it, or the database is not the one this install created."
	set_code="$(http_code -X POST "https://${DOMAIN_HOST}/api/users/setpassword" \
		-H "Authorization: Bearer ${TOKEN}" \
		-H 'Content-Type: application/json' \
		--data "{\"password\":\"${WM_ADMIN_PASSWORD}\"}")"
	[ "$set_code" = "200" ] || die "/api/users/setpassword answered ${set_code}, not 200. The default password is still live. Stop and investigate."
	TOKEN="$(api_login "$WM_ADMIN_PASSWORD")"
	[ -n "$TOKEN" ] || die "the new password does not log in after the rotation reported success. Stop and investigate."
fi

replay="$(api_login_code changeme)"
[ "$replay" = "400" ] || die "replaying the default password returned ${replay}, not the 400 upstream returns for Invalid login. The default credential may still work. Stop and investigate."
echo "==> default credential replay: ${replay} (Invalid login)"

unauth="$(http_code "https://${DOMAIN_HOST}/api/users/whoami")"
[ "$unauth" = "401" ] || die "an unauthenticated API call returned ${unauth}, not 401. Stop and investigate."
echo "==> unauthenticated /api/users/whoami: ${unauth}"

if ! accounts="$(curl -sS -H "Authorization: Bearer ${TOKEN}" \
	"https://${DOMAIN_HOST}/api/users/list_as_super_admin?page=1&per_page=100" \
	| grep -o '"email":"[^"]*"' | sort -u | wc -l | tr -d ' ')"; then accounts="0"; fi
[ "$accounts" = "1" ] || die "this instance has ${accounts} accounts, not 1. Windmill seeds exactly one; anything else means somebody else got here first."
echo "==> accounts on this instance: ${accounts}"

# --- 8. One workspace and one job, which is the product working --------------

if curl -sS -H "Authorization: Bearer ${TOKEN}" "https://${DOMAIN_HOST}/api/workspaces/list" | grep -q '"id":"main"'; then
	echo "==> workspace main already exists"
else
	created="$(curl -sS -X POST "https://${DOMAIN_HOST}/api/workspaces/create" \
		-H "Authorization: Bearer ${TOKEN}" \
		-H 'Content-Type: application/json' \
		--data '{"id":"main","name":"Main"}')"
	case "$created" in
		*"Created workspace main"*) echo "==> $created" ;;
		*) die "creating the main workspace answered: ${created}" ;;
	esac
fi

echo "==> running one bash job end to end"
result=""
for _ in $(seq 1 5); do
	result="$(curl -sS -X POST "https://${DOMAIN_HOST}/api/w/main/jobs/run_wait_result/preview" \
		-H "Authorization: Bearer ${TOKEN}" \
		-H 'Content-Type: application/json' \
		--data '{"language":"bash","content":"echo windmill-selfhost-check","args":{}}')"
	case "$result" in *windmill-selfhost-check*) break ;; esac
	sleep 15
done
case "$result" in
	*windmill-selfhost-check*) echo "==> job result: ${result}" ;;
	*) die "the smoke-test job returned: ${result}. Check: docker compose logs --tail 40 windmill_worker" ;;
esac

# --- 9. The first backup, before day one ends --------------------------------
#
# pg_dump, not a tar of postgres/: copying a live data directory is not a
# backup. The named volumes hold language caches and spilled job logs, both of
# which rebuild, so they are deliberately not archived.

STAMP="$(date +%Y%m%d-%H%M%S)"
docker compose exec -T db pg_dump -U windmill -d windmill | gzip > "$APP_DIR/backups/windmill-db-${STAMP}.sql.gz"
sudo tar -czf "$APP_DIR/backups/windmill-config-${STAMP}.tar.gz" -C "$APP_DIR" compose.yml .env -C /etc/caddy Caddyfile
ls -lh "$APP_DIR/backups/"
[ -s "$APP_DIR/backups/windmill-db-${STAMP}.sql.gz" ] || die "the database dump is empty"
[ -s "$APP_DIR/backups/windmill-config-${STAMP}.tar.gz" ] || die "the config archive is empty"

cat <<-DONE

	Windmill is answering at https://${DOMAIN_HOST}

	  1. Sign in as ${WM_ADMIN_EMAIL}. The password is in $APP_DIR/.env, mode
	     600, and was never printed here. Read it with
	       sudo grep WM_ADMIN_PASSWORD $APP_DIR/.env
	     and put it in your password manager now. There is no SMTP configured,
	     so there is no password reset link if you lose it.
	  2. The seeded default password was replaced and then replayed against
	     /api/auth/login, which answered 400 Invalid login. This instance has
	     exactly one account and no open registration.
	  3. Your workspace is main. A bash job ran through it end to end and came
	     back with windmill-selfhost-check, which means the server queued it and
	     the worker executed it.
	  4. Job isolation is off, which is the image default. A script running here
	     can read the worker's environment, and that environment holds the
	     database URL. Treat a script from the hub the way you treat a shell
	     script from a stranger.
	  5. First backup written to $APP_DIR/backups: a database dump and a config
	     archive. They are on the same disk as the data, which is not a backup.
	     Copy them somewhere else tonight.

DONE

What you're signing up for

The part a vendor's comparison page leaves out. None of it is a reason not to do this; all of it is yours the moment you cancel Workato.

  • The repository is AGPLv3 with Apache-2.0 clients, and the image you install is not that binary. Upstream's own LICENSE says the Community Edition published at ghcr.io/windmill-labs/windmill carries the AGPL and Apache sources plus proprietary, non-public code: free to use, redistributable as is, but not to sell, resell, run as a managed service, modify or wrap without an agreement. The build flags agree, since the CE image compiles with features=ce and ce pulls in the private feature. Running it for your own work is squarely inside the grant. Selling it is not, and neither is shipping a patched fork.
  • Job isolation is off by default and this install leaves it off, which is the image default. Upstream's security page states the consequence: without isolation a script can read the worker process memory and its environment variables, and that environment holds DATABASE_URL, which is every secret in every workspace. The scripts you write and the hub scripts you paste are the security boundary. The prompt names the upgrade path, with the capabilities it needs.
  • You own a PostgreSQL and it is the entire product. Every script, flow, app, schedule, run and each workspace's own encryption key lives in it, so one dump is the whole restore and there is no separate key to lose alongside it. The two Windmill containers hold language caches and spilled logs; losing those costs a slow first job, not your work.
  • The free edition has counted limits, not only missing features. Creating workspaces stops at two outside the built-in admins workspace, and at one archived workspace, enforced in the community build itself. Full-text job and log search, multiplayer editing in the browser, SAML, audit logs, autoscaling and Git sync sit behind the paid licence.
  • This is not Workato with the price removed. There is no maintained catalogue of a thousand vendor connectors and nobody has registered an OAuth client on your behalf. You write the step in Python, TypeScript, Go, Bash or SQL, or you take one from the public hub and read it before you run it, and every app you connect is a developer registration you make yourself.

Where this came from

“available in the docker images hosted under ghcr.io/windmill-labs/windmill and the github binary releases contains the files under the AGPLv3 and Apache 2 sources but also includes proprietary and non-public code and features which are not open source”

  • The repository LICENSE places backend/ and frontend/ under AGPLv3 and the language clients and OpenAPI files under Apache 2.0, and states that the Community Edition docker image also contains proprietary, non-public code that may be redistributed as is but not sold, resold, run as a managed service, modified or wrapped without an agreement. source
  • The public image at ghcr.io/windmill-labs/windmill is built with the build argument features=ce after code from the private windmill-ee-private repository is copied into the tree, which is what puts non-public code in the Community Edition image. source
  • In backend/Cargo.toml the ce feature resolves through ce_rpi and ce_core to the private feature, while the fully open-source oss feature resolves to no_auth, the flag that makes every request arrive as the admin superadmin. source
  • Upstream's README tells a new self-hoster to open the instance and sign in with the default credentials admin@windmill.dev and changeme. source
  • Upstream states that job isolation is disabled by default, that both NSJAIL and PID namespace isolation must be configured by hand, and that without isolation scripts can reach sensitive data in the worker process memory and environment variables. source
  • Workspace creation in a build without the enterprise feature stops at two workspaces outside the built-in admins workspace, and at one archived workspace, enforced in the community build's own code. source

Questions people actually ask

Answered from this page's own data — the same numbers, in sentences.

  • Can I self-host Workato?

    Not Workato itself — the vendor does not ship a version you can run on your own server. What you can self-host is the job people pay it for, and the answer to that is Windmill. Turn scripts in Python, TypeScript, Go, Bash or SQL into scheduled jobs, webhooks and flows, on a box with no task meter. The install is one evening: 3 containers behind Caddy with automatic TLS, secrets generated on the server rather than in a chat window, and a first backup taken before the agent says it is done, in about 120 minutes. The prompt on this page does it; the compose.yml, Caddyfile and install.sh below do the same install with no agent at all.

  • What replaces Workato?

    Windmill. Turn scripts in Python, TypeScript, Go, Bash or SQL into scheduled jobs, webhooks and flows, on a box with no task meter. The closest honest answer for the shape Workato actually is, which is a platform for people who will write the integration rather than click it. A step is a script in Python, TypeScript, Go, Bash or SQL, flows chain those steps with branching and retries, schedules and webhooks fire them, and an app builder puts a form in front of the ones a colleague has to run. The task meter goes away entirely: the community edition counts no runs, no steps and no users. What you take on is real and stated on its page. The image you install is not the AGPL binary, because upstream's own LICENSE says the Community Edition contains proprietary non-public code alongside the AGPL and Apache sources, free to run but not to resell or wrap. Job isolation ships off, so a script can read the worker's environment and that environment holds the database URL. Workspaces cap at two outside the built-in admins workspace on the free licence. Three containers, one evening, a PostgreSQL you back up, and no quote to negotiate. Also evaluated and ranked below it: Activepieces, Automatisch. The prompt on this page installs Windmill only. Windmill is AGPL-3.0-licensed and free; nothing on this page is a hosted service we sell you.

  • What does self-hosting cost compared to Workato?

    4096 MB of RAM and 30 GB of disk — the smallest tier most VPS hosts sell, about $20 a month. Windmill itself is free and AGPL-3.0-licensed; the bill is the server, plus a domain you probably already own. There is no like-for-like Workato list price behind this swap, so this page does not invent a savings figure.

  • How hard is it really?

    ONE EVENING — 1–3 hours. The rule that produced that verdict: up to three containers and at most one outside integration. You will type more than one command and read a page of documentation, and it will be running before you go to bed. The tier is derived from seven countable facts about the Windmill install, not from anyone's impression of it, and the whole rubric is published on the methodology page.

  • Can I run Windmill on my own computer instead of a server?

    Yes — that is the second path in the prompt box above. "On my computer" installs the same Windmill on the machine you are sitting at: no VPS, no domain, no DNS, and nothing exposed to the internet. It checks for Docker first and installs Docker Desktop if the machine does not have it — macOS, Windows and Linux each get their own step — then binds everything to loopback, so the app answers on http://localhost and only on that computer. The catch: Webhook triggers need a URL an outside service can reach, and on this path every URL Windmill prints begins with http://localhost:8193, so what you get is the scripts, flows and schedules half of the tool, running only while this computer is awake. Same discipline as the cloud path: pinned images, secrets generated on the machine, and a first backup taken before the prompt says it is done.

Content last checked 2026-08-14. Verdicts are derived from the published rubric on /methodology; corrections go through the issue tracker.