Why self-host Windmill on a VPS
Windmill runs continuously on your server and exposes each of your scripts as a token-authenticated REST endpoint, a scheduled task or an auto-generated interface. Unlike a serverless function, there is no cold start and no execution limit imposed by a third party. Your Python, TypeScript or Bash scripts reach the databases and internal services of your VPS directly, without going through a cloud gateway. The backend is written in Rust: stateless API servers and workers that pull jobs from a queue stored in PostgreSQL, which lets you add workers without touching the rest.
The choice comes down mostly to how you work. If your need is to connect SaaS tools on a visual canvas, n8n remains more direct; for very long business processes with guaranteed resumption, Temporal is built for that. Windmill targets teams that already write code and want to make it runnable, schedulable and usable by non-developers within minutes. On licensing, the core is AGPLv3 and the Community Edition image is free, with quotas and features reserved for the Enterprise edition; reselling it or offering it as a managed service to your clients requires a commercial license.
Concrete benefits of self-hosting
- No per-execution billing: thousands of jobs per day without a cloud bill.
- Direct access to the VPS databases and internal services, without a tunnel.
- Secrets and API keys stored encrypted in Windmill and never leaving your server.
- Input forms and REST endpoints generated automatically for each script, from the signature of its
mainfunction. - Additional workers that can be added on the fly to absorb load peaks.
- Several isolated workspaces on a single instance: one per team, per project or per environment.
- Versioning: each deployment of a script is kept, and Git Sync pushes the workspace to a Git repository (included in Community Edition for workspaces of at most 2 users).
Technical prerequisites
Windmill requires at least 2 vCPU and 4 GB of RAM. The official stack starts PostgreSQL 18, the server, three general-purpose workers capped at 2 GB of memory each, a native worker for lightweight jobs, the windmill_extra service (LSP autocompletion and the editor's debugger) and a Caddy in front: on 4 GB, reduce the number of workers. Plan 20 GB of disk for the database, the logs and the workers' dependency cache, which grows with every pip or npm package used. Ubuntu 22.04/24.04 or Debian 12 with Docker Engine and the Docker Compose v2 plugin is enough, plus a domain name pointed at the VPS for the TLS certificate. No GPU is required. If you plan to run heavy Python workers (ML, pandas on large datasets), go for at least 4 vCPU and 8 GB of RAM.
Size the VPS to the workload
- The vendor's rule: about 1 worker per vCPU and 1 to 2 GB of RAM per worker. A job that exceeds its worker's memory limit (2048 MB by default) is killed with exit code 137.
- Solo use or trial: 2 vCPU, 4 GB of RAM, 20 GB of disk, with a single general-purpose worker on top of the native worker. Enough for scheduled scripts and a few webhooks.
- Small team: 4 vCPU, 8 GB of RAM, 40 GB of disk. The three workers of the official file and PostgreSQL fit on the same host without saturating memory.
- Several teams or heavy Python jobs: 8 vCPU, 16 GB of RAM, 80 GB of disk for six to eight workers. At that point, a separate PostgreSQL database simplifies backups and upgrades.
Deploy Windmill with Docker Compose
Download the official files
Connect to your VPS over SSH and download the three reference files published in the Windmill repository: the
docker-compose.yml, the reverse proxy'sCaddyfileand the.env:mkdir -p /opt/windmill && cd /opt/windmill curl -fsSL https://raw.githubusercontent.com/windmill-labs/windmill/main/docker-compose.yml -o docker-compose.yml curl -fsSL https://raw.githubusercontent.com/windmill-labs/windmill/main/Caddyfile -o Caddyfile curl -fsSL https://raw.githubusercontent.com/windmill-labs/windmill/main/.env -o .envThere is no need to clone the whole repository. These files follow the
mainbranch: reread them before each update, because the stack evolves (move to PostgreSQL 18, thewindmill_extraservice…).Fill in the .env file
The
.envholds only two variables. Replace the default PostgreSQL password and pin the image to a released version rather thanmain(1.864.1 as of 06/11/2026, to be checked on the GitHub releases page):DATABASE_URL=postgres://postgres:MOT_DE_PASSE_FORT@db/windmill?sslmode=disable WM_IMAGE=ghcr.io/windmill-labs/windmill:1.864.1Copy the same password into
POSTGRES_PASSWORD, under thedbservice of thedocker-compose.yml. Thedbhost is the name of the PostgreSQL service in the stack: do not replace it withlocalhost. To use an existing database, pointDATABASE_URLto it and set thedbservice toreplicas: 0.Start the stack
Launch everything and follow the server's startup:
docker compose up -d docker compose ps docker compose logs -f windmill_serverdocker compose psmust listdb,windmill_server, three replicas ofwindmill_worker,windmill_worker_native,windmill_extraandcaddy; the full-text search indexer (windmill_indexer) stays at zero replicas, as it is an Enterprise feature. The server waits for PostgreSQL to pass its healthcheck, then applies the schema migrations itself. It listens on port 8000 of the Docker network without being published on the host: Caddy is what receives the traffic.Configure the reverse proxy and TLS
The simplest option is to let the bundled Caddy obtain the Let's Encrypt certificate. In the
caddyservice of thedocker-compose.yml, uncomment port 443 and replaceBASE_URL=":80"with your domain, then rundocker compose up -dagain:caddy: ports: - 80:80 - 25:25 - 443:443 environment: - BASE_URL=windmill.example.comKeep port 80 open for validation. If an nginx already serves other sites on the VPS, remove the
caddyservice, publish127.0.0.1:8000:8000onwindmill_serverand127.0.0.1:3000:3000onwindmill_extra, then reproduce theCaddyfilerouting: the/ws/,/ws_mp/and/ws_debug/paths go towindmill_extra, everything else to the server.location ~ ^/ws(_mp|_debug)?/ { proxy_pass http://127.0.0.1:3000; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; proxy_set_header Host $host; } location / { proxy_pass http://127.0.0.1:8000; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; proxy_set_header Host $host; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; }The
UpgradeandConnectionheaders are essential: the editor uses WebSockets for autocompletion and debugging. The certificate is then obtained withcertbot --nginx -d windmill.example.com.Log in and secure
Open
https://windmill.example.comand log in with the default superadmin account,[email protected]/changeme. The first-run wizard offers to replace it with your own account: do it, because if you skip the step the default credentials stay active, and that is the most common flaw on publicly exposed Windmill instances. In the Instance settings, then set the Base URL to the publichttps://address: Windmill uses it to build webhook URLs and the return links of flows. Finally create your first workspace with a short identifier (prod,data-team).Adjust the number of workers
The official file starts three replicas of
windmill_worker, each capped at 2048 MB. On a 4 GB VPS, go down to one or two; on a bigger machine, go up to one worker per vCPU:docker compose up -d --scale windmill_worker=2 docker compose ps windmill_workerTo make the setting permanent, change the service's
deploy.replicasin thedocker-compose.yml. The server does not need to restart: workers register themselves and appear on the interface's Workers page, with their group (defaultornative).Update Windmill
Change the tag in
WM_IMAGEafter reading the release notes, then:docker compose pull docker compose up -d docker image pruneThe
pulldownloads the new images while the old containers are still running; on restart, the server migrates the database by itself and the workers finish their running jobs within Docker's stop timeout (30 seconds). Back up the database just before, and only rundocker image prune -aif the VPS hosts nothing else: the command also removes the images of other projects.
Configuration after installation
Once the instance is online, six settings decide how your scripts will run in production. They are all made from the interface, workspace by workspace.
The essential settings
- Workspaces: a workspace isolates scripts, flows, secrets and members. Create one per team or per environment rather than mixing everything, and keep the superadmin role for administering the instance.
- Variables and secrets: all variables are encrypted with a symmetric key specific to the workspace; those marked secret are no longer displayed in the interface once entered. This is where API tokens and passwords belong, never in the script's code.
- Resources: typed JSON objects (PostgreSQL connection, SMTP account, OAuth credentials). A parameter typed
postgresqlin a script then only offers resources of that type in the generated form. - Schedules: each script or flow accepts one or more schedules in five-field cron format, with an optional seconds field at the start. The schedule error handler is an Enterprise feature; in Community Edition, watch failed executions from the runs list.
- Webhooks: a script is called asynchronously through
/api/w/<workspace>/jobs/run/p/<path>(response: the job identifier) or synchronously through/api/w/<workspace>/jobs/run_wait_result/p/<path>(response: the result). Authenticate with theAuthorization: Bearer <token>header rather than?token=, which ends up in access logs. - Git Sync: in the workspace settings, link a repository to push scripts, flows and resources to it on every deployment. Free in Community Edition up to 2 users per workspace; automatic deployment from Git, however, is reserved for the Enterprise edition.
Example: an end-to-end Python script
This script queries an API, keeps the active items and inserts them into PostgreSQL through a Windmill resource. Create a Python script, paste the code and run it from the editor:
# requirements:
# httpx
# psycopg2-binary
from typing import TypedDict
import httpx
import psycopg2
class postgresql(TypedDict):
host: str
port: int
user: str
dbname: str
sslmode: str
password: str
def main(api_url: str, db: postgresql) -> dict:
resp = httpx.get(api_url, timeout=10)
resp.raise_for_status()
rows = [r for r in resp.json() if r.get("active")]
conn = psycopg2.connect(
host=db["host"], port=db["port"], user=db["user"],
password=db["password"], dbname=db["dbname"], sslmode=db["sslmode"],
)
with conn, conn.cursor() as cur:
cur.executemany(
"INSERT INTO items (id, name) VALUES (%(id)s, %(name)s) ON CONFLICT DO NOTHING",
rows,
)
conn.close()
return {"candidates": len(rows), "source": api_url}The # requirements: comments replace the automatic inference of dependencies from imports: useful here, because the psycopg2 import is published under the psycopg2-binary package. Windmill resolves these packages, writes a lockfile and caches them on the worker, so the same version of the script always runs with the same dependencies. The postgresql class, named after the resource type, makes a resource picker appear in the form. Once deployed under the path f/etl/import_items, the script can be called from any tool:
curl -X POST "https://windmill.example.com/api/w/prod/jobs/run_wait_result/p/f/etl/import_items" \
-H "Authorization: Bearer $WM_TOKEN" \
-H "Content-Type: application/json" \
-d '{"api_url": "https://api.example.com/items"}'Hardening the instance
Never publish ports 8000 and 3000 on the public interface: only the reverse proxy should reach them, locally. Back up the database with docker compose exec -T db pg_dumpall -U postgres | gzip > /backups/windmill-$(date +%F).sql.gz: the dump must cover the whole cluster, not just the windmill database, because Windmill creates side databases and attaches its security policies to cluster-level roles. This dump contains your encrypted secrets: store it off the VPS and protect it like a secret. Only mount the Docker socket in the workers if you trust every user of the instance, since it gives scripts control of the host. Finally, leave REQUIRE_SIGNED_DEBUG_REQUESTS=true on windmill_extra: disabling it on an instance reachable from the Internet exposes a debugger able to run code without authentication.
Troubleshooting: first-deployment errors
Most incidents come from the database, worker memory or the reverse proxy. The symptoms and their fixes:
Symptoms and fixes
- The
dbcontainer stops right after an update: the volume was created by PostgreSQL 16 and the image moved to 18, which refuses to read it. Follow the migration procedure in the documentation (full dump then restore), or pinpostgres:16with its former mount point: version 16 remains maintained until November 2028. - Job interrupted with code 137: it exceeded its worker's memory limit. Increase
resources.limits.memoryon thewindmill_workerservice, or reduce the size of the data the script loads into memory. - Server or workers unable to reach the database:
DATABASE_URLpoints tolocalhostinstead of thedbservice, or the password differs fromPOSTGRES_PASSWORD. Fix the.env, then rundocker compose up -dto recreate the containers. ModuleNotFoundErroror failing dependency installation: the worker downloads packages on the first run and must reachpypi.organdfiles.pythonhosted.orgover outbound HTTPS. Behind a corporate proxy, thePY_TRUSTED_HOSTandPY_INDEX_CERTvariables handle trust; a private index is declared in the Instance settings (Enterprise edition).- Editor without autocompletion, silent debugger: the reverse proxy does not route
/ws/,/ws_mp/and/ws_debug/towindmill_extra, or it strips theUpgradeandConnectionheaders. Reuse the routing of the officialCaddyfile.
Windmill, n8n or Temporal
Scroll the table
| Criterion | Windmill | n8n | Temporal |
|---|---|---|---|
| Writing workflows | Python, TypeScript, Go, Bash, SQL… scripts assembled into flows | Node canvas; Code node in JavaScript or Python | Application code via SDK (Go, Java, Python, TypeScript, .NET, PHP, Ruby, Rust) |
| License | AGPLv3 for the core; free Community Edition with quotas, licensed Enterprise features | Sustainable Use License (fair-code), `.ee` files under a commercial license | MIT for the server |
| Interface | Web editor, generated forms and internal apps | Visual node editor | Web UI for observation; code is written outside the tool |
| Best for | Teams that write code and want to expose it fast | Visual integrations between SaaS tools | Long processes with strong durability requirements |
Deploy it in one click from the ServOrbit Marketplace. If you prefer to skip manual configuration, the ServOrbit Marketplace provisions Windmill automatically — server, two workers, PostgreSQL 16, nginx reverse proxy and a Let's Encrypt certificate — in a few minutes. The "First access" section of your client area shows the URL and generated credentials. The Windmill listing, reachable from the button at the end of this article, gets you started.