Deployment guide

Installing Windmill on a VPS: self-hosted automation

Deploy on a VPS Cloud →

Tutorial

Automation

Installing Windmill on a VPS: self-hosted automation

Windmill is an open source platform that turns code (Python, TypeScript, Go, Bash, SQL) into workflows, APIs and internal interfaces. Designed for developers, it combines the flexibility of code with the speed of no-code. Self-hosted on a VPS, it becomes your automation engine and your internal app builder, without depending on a SaaS.

13 min read7 steps
Contents· Why self-host Windmill on a VPS1/12
  1. 01Why self-host Windmill on a VPS
  2. 02Concrete benefits of self-hosting
  3. 03Technical prerequisites
  4. 04Size the VPS to the workload
  5. 05Deploy Windmill with Docker Compose
  6. 06Configuration after installation
  7. 07The essential settings
  8. 08Example: an end-to-end Python script
  9. 09Hardening the instance
  10. 10Troubleshooting: first-deployment errors
  11. 11Symptoms and fixes
  12. 12Windmill, n8n or Temporal

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 main function.
  • 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

  1. 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's Caddyfile and 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 .env

    There is no need to clone the whole repository. These files follow the main branch: reread them before each update, because the stack evolves (move to PostgreSQL 18, the windmill_extra service…).

  2. Fill in the .env file

    The .env holds only two variables. Replace the default PostgreSQL password and pin the image to a released version rather than main (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.1

    Copy the same password into POSTGRES_PASSWORD, under the db service of the docker-compose.yml. The db host is the name of the PostgreSQL service in the stack: do not replace it with localhost. To use an existing database, point DATABASE_URL to it and set the db service to replicas: 0.

  3. Start the stack

    Launch everything and follow the server's startup:

    docker compose up -d
    docker compose ps
    docker compose logs -f windmill_server

    docker compose ps must list db, windmill_server, three replicas of windmill_worker, windmill_worker_native, windmill_extra and caddy; 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.

  4. Configure the reverse proxy and TLS

    The simplest option is to let the bundled Caddy obtain the Let's Encrypt certificate. In the caddy service of the docker-compose.yml, uncomment port 443 and replace BASE_URL=":80" with your domain, then run docker compose up -d again:

      caddy:
        ports:
          - 80:80
          - 25:25
          - 443:443
        environment:
          - BASE_URL=windmill.example.com

    Keep port 80 open for validation. If an nginx already serves other sites on the VPS, remove the caddy service, publish 127.0.0.1:8000:8000 on windmill_server and 127.0.0.1:3000:3000 on windmill_extra, then reproduce the Caddyfile routing: the /ws/, /ws_mp/ and /ws_debug/ paths go to windmill_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 Upgrade and Connection headers are essential: the editor uses WebSockets for autocompletion and debugging. The certificate is then obtained with certbot --nginx -d windmill.example.com.

  5. Log in and secure

    Open https://windmill.example.com and 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 public https:// 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).

  6. 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_worker

    To make the setting permanent, change the service's deploy.replicas in the docker-compose.yml. The server does not need to restart: workers register themselves and appear on the interface's Workers page, with their group (default or native).

  7. Update Windmill

    Change the tag in WM_IMAGE after reading the release notes, then:

    docker compose pull
    docker compose up -d
    docker image prune

    The pull downloads 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 run docker image prune -a if 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 postgresql in 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 the Authorization: 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 db container 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 pin postgres:16 with 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.memory on the windmill_worker service, or reduce the size of the data the script loads into memory.
  • Server or workers unable to reach the database: DATABASE_URL points to localhost instead of the db service, or the password differs from POSTGRES_PASSWORD. Fix the .env, then run docker compose up -d to recreate the containers.
  • ModuleNotFoundError or failing dependency installation: the worker downloads packages on the first run and must reach pypi.org and files.pythonhosted.org over outbound HTTPS. Behind a corporate proxy, the PY_TRUSTED_HOST and PY_INDEX_CERT variables 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/ to windmill_extra, or it strips the Upgrade and Connection headers. Reuse the routing of the official Caddyfile.

Windmill, n8n or Temporal

Scroll the table

CriterionWindmilln8nTemporal
Writing workflowsPython, TypeScript, Go, Bash, SQL… scripts assembled into flowsNode canvas; Code node in JavaScript or PythonApplication code via SDK (Go, Java, Python, TypeScript, .NET, PHP, Ruby, Rust)
LicenseAGPLv3 for the core; free Community Edition with quotas, licensed Enterprise featuresSustainable Use License (fair-code), `.ee` files under a commercial licenseMIT for the server
InterfaceWeb editor, generated forms and internal appsVisual node editorWeb UI for observation; code is written outside the tool
Best forTeams that write code and want to expose it fastVisual integrations between SaaS toolsLong 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.

Deploy Windmill in one click on your ServOrbit VPS

The ServOrbit Marketplace automatically provisions Windmill with PostgreSQL 16, two workers and a reverse proxy — your scripts run in production in minutes, with no configuration needed.