Back to Article List

How to self-host Windmill on a VPS with Docker Compose

How to self-host Windmill on a VPS with Docker Compose - How to self-host Windmill on a VPS with Docker Compose

Self-hosting Windmill on a VPS takes about an hour, and a good part of that hour is Docker downloading images. Windmill's own self-host page gets you to docker compose up -d and a login screen on port 80. This guide carries on from there to a Windmill on your own domain, with HTTPS, a replaced default account and a backup that restores. Everything below was run on a clean Ubuntu 24.04 server on 29 October 2026, Windmill CE 1.852.0 upgraded to 1.853.1 along the way.

You need a VPS running Ubuntu 24.04 with root access, and a domain or subdomain whose A record already points at the server's IP. If you came here from n8n, the n8n self-hosting guide covers the same ground for that tool, and the two installs look alike until the worker section. The commands assume you're logged in as root, which is how a fresh Ubuntu VPS hands itself over; prefix them with sudo if you work from another user.

Which server size Windmill needs

A VPS with 4 vCPU and 8 GB of RAM runs Windmill's default compose with room for normal use; 16 GB is the comfortable choice when memory-heavy jobs can land on every worker at once; and the disk should be 40 GB or more. Those are our figures, inferred from a test on a bigger server, so here is what they rest on.

ResourceWindmill saysWe measured (8 vCPU / 16 GB server, compose as shipped)
RAM2 CPU cores, 4 GB and 20 GB of disk "can run the full stack" (the "No-ops self-host" page); 1 worker per vCPU and 1 to 2 GB per worker (the self-host docs)633 MiB at idle across all eight containers, 819 MiB peak with 66 jobs submitted at once; 1.6 GB used on the whole host, load average 1.39
Worker capsthree default workers and one native worker, each limited to 2 GB in the compose file8 GiB if all four hit their cap at once; one job that tried was killed at the cap, see the RAM section
Disk20 GB15.2 GB of images right after install, 21.3 GB during an upgrade while the old image was still there

The 2 GB figures are ceilings, so Windmill's 4 GB minimum matches what the stack used at idle and under our load. The caps only cost memory when a job uses it. Our Ubuntu image ships without swap, so on an 8 GB server four jobs at their cap at the same time would leave the kernel choosing what to kill; 16 GB covers that case, 8 GB covers everything else we saw.

Step 1: Install Docker Engine on Ubuntu 24.04

Windmill's compose file wants Docker Engine with the Compose plugin, and the version in Ubuntu's own repositories lags. Docker's Install Docker Engine on Ubuntu page is the source for the block below; it changes from time to time, so if the page shows something different from this, follow the page. A server deployed from LumaDock's Docker template already has Docker installed, so skip to step 2 in that case and run only the two checks at the end of this one; they also tell you if the Compose plugin is there.

The first line Docker's page gives removes packages that could conflict. On a fresh server every one of them is missing, so it prints eight lines of dpkg: no packages found matching ... and then 0 to remove. That's the expected result.

sudo apt remove $(dpkg --get-selections docker.io docker-compose docker-compose-v2 docker-doc docker-buildx podman-docker containerd runc | cut -f1)

Then the repository. The current page writes a deb822 file at /etc/apt/sources.list.d/docker.sources instead of the old one-line docker.list that older guides describe.

sudo apt update
sudo apt install ca-certificates curl
sudo install -m 0755 -d /etc/apt/keyrings
sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
sudo chmod a+r /etc/apt/keyrings/docker.asc

sudo tee /etc/apt/sources.list.d/docker.sources <<EOF
Types: deb
URIs: https://download.docker.com/linux/ubuntu
Suites: $(. /etc/os-release && echo "${UBUNTU_CODENAME:-$VERSION_CODENAME}")
Components: stable
Architectures: $(dpkg --print-architecture)
Signed-By: /etc/apt/keyrings/docker.asc
EOF

sudo apt update
sudo apt install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin

apt installs Docker Engine, the CLI, containerd, Buildx and the Compose plugin from Docker's repository

Seven new packages from Docker's own repository, 102 MB in all.

On this image the ca-certificates curl line only upgrades what is already there (four packages, counting the libcurl libraries); apt asks once and moves on. The last command pulled 102 MB and finished in about 20 seconds here, installing docker-ce 29.9.2 and the Compose plugin 5.6.1. Check both before going on:

sudo docker run hello-world
docker compose version

Expect Hello from Docker! in the first output and Docker Compose version v5.6.1 or newer in the second. Older write-ups talk about Compose v2 and a docker-compose binary with a hyphen. The plugin's command is docker compose with a space, and v5 is the current line.

docker compose version prints Docker Compose version v5.6.1

The line to look for; anything that starts with v2 or says docker-compose with a hyphen is an older install.

Step 2: Enable ufw before Docker publishes a port

ufw is installed on the image and switched off. Turn it on with SSH, HTTP and HTTPS allowed, in that order, because a firewall enabled without an SSH rule locks you out at your next login.

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

It asks Command may disrupt existing ssh connections. Proceed with operation (y|n)?, and y is the right answer once the OpenSSH rule is in. ufw status verbose should then list those three rules, each shown again for IPv6.

ufw has a blind spot with Docker. Docker writes its own iptables rules, and they sit in front of ufw's. A port published with -p or a ports: entry in a compose file is open to the internet even though ufw status never mentions it. Docker says so on the install page, in the "Firewall limitations" note, and it's easy to read past. We proved it on this server before Windmill existed: a throwaway nginx container published on 8080 answered 200 from another machine, while a plain Python web server on 8081 with no ufw rule timed out as it should.

docker run -d --name ufwtest -p 8080:80 nginx:alpine
# from any other machine:
curl -sS -o /dev/null -w '%{http_code}' http://YOUR_SERVER_IP:8080
docker rm -f ufwtest

curl from another machine to port 8080 returns 200 although ufw never allowed 8080

Run from a different machine, with the server's IP in place of ours.

curl to a plain web server on port 8081 with no ufw rule times out after 10 seconds

Same server, same minute, a plain Python web server on 8081 instead of a container: 10 seconds, then nothing.

Once ufwtest is gone, 8080 times out again. For the rest of this guide that means: ufw covers programs running directly on the server, so for Docker you decide what's exposed by what you publish. Windmill's compose publishes 80 and 25 by default (443 joins them when you enable HTTPS in step 7), and 25 is the one you'll comment out in step 8. If you ever need to filter a published port by source address, Docker's networking docs describe the DOCKER-USER chain made for that.

Step 3: Download Windmill's Docker Compose files

Create the folder as windmill, in your home directory, and fetch the three files exactly as Windmill's self-host page lists them.

mkdir ~/windmill && cd ~/windmill
curl https://raw.githubusercontent.com/windmill-labs/windmill/main/docker-compose.yml -o docker-compose.yml
curl https://raw.githubusercontent.com/windmill-labs/windmill/main/Caddyfile -o Caddyfile
curl https://raw.githubusercontent.com/windmill-labs/windmill/main/.env -o .env

Docker Compose names volumes after the project folder, so this layout gives you windmill_db_data, windmill_caddy_data and four more with the same prefix, and Windmill's own reset instructions assume the windmill_db_data name. Call the folder something else and every later command that names a volume needs translating.

Two things about these files. They come from Windmill's main branch, so what you download tracks the newest release and can differ in small ways from what this guide shows; on 29 October the files matched tag v1.853.1 byte for byte. And docker-compose.yml opens with version: "3.7", which Compose v5 flags on every single command: the attribute `version` is obsolete, it will be ignored. It's harmless. Delete the first line of the file if the warning bothers you.

Step 4: Change the Postgres password in both files

The shipped files use the password changeme in two places that have to agree: POSTGRES_PASSWORD in docker-compose.yml, which is what the Postgres container sets on its first start, and DATABASE_URL in .env, which is what Windmill uses to connect. Generate a password and put the same one in both. The two sed lines below do exactly that; grep -c changeme at the end should report 0 for both files.

PGPASS=$(openssl rand -hex 24)
sed -i "s/POSTGRES_PASSWORD: changeme/POSTGRES_PASSWORD: $PGPASS/" docker-compose.yml
sed -i "s|postgres://postgres:changeme@|postgres://postgres:$PGPASS@|" .env
grep -c changeme docker-compose.yml .env

Do this before the first docker compose up. Postgres reads POSTGRES_PASSWORD only when its data volume is empty, so a password changed after the first start does nothing until the volume is deleted. A half-changed password was the first failure we hit, and it doesn't look like a password problem; the troubleshooting section describes it.

While you're in .env, pin the Windmill version. As shipped, the WM_IMAGE line points at the floating :main tag, and the compose file sets pull_policy: always, so every docker compose up -d you ever run would pull whatever main points at that minute and upgrade you without asking. main moved at least once during our test day with no release in between. Open Windmill's releases page and take the newest tag without the leading v. On 29 October that was 1.853.1, which is the tag used below:

sed -i 's|^WM_IMAGE=.*|WM_IMAGE=ghcr.io/windmill-labs/windmill:1.853.1|' .env
chmod 600 .env docker-compose.yml

The chmod is there because both files now hold the database password and were created world-readable. I'd pin every production Windmill: an unattended upgrade in the middle of a restart makes an incident harder to diagnose. The cost of pinning is on you: Windmill's release lifecycle page says security fixes ship only in the latest release, so a pinned install has to be upgraded on purpose and often.

Step 5: Start Windmill

docker compose up -d

The first start downloads about 4.2 GB of compressed images. On our server it took 11 minutes 12 seconds, almost all of it in the pull, with one layer of the windmill-extra image crawling at about 1 MB/s from GitHub's registry. The self-host page's "less than a minute" describes a start with the images already cached (20 seconds here). If the terminal shows Pulling lines for ten minutes, nothing is wrong.

time docker compose up -d: images pulled and all containers started after real 11m12s

Compose cut most of the progress lines; the two that matter are the last image pulled and the time at the bottom.

docker compose ps
curl http://localhost/api/version
curl http://localhost/api/health/status

You want eight containers Up: db, windmill_server, three windmill_worker replicas, windmill_worker_native, windmill_extra and caddy. The version call returns CE v1.853.1 (or whatever you pinned) and the health call returns {"status":"healthy",...,"workers_alive":11}. Eleven is right: the single native worker container spawns eight native subworkers, as the worker groups page in Windmill's docs explains, and 3 + 8 = 11.

docker compose ps lists caddy, db, windmill_extra, windmill_server, three windmill_worker and windmill_worker_native, all Up

Check the PORTS column on the caddy row: 80 and 25 published, 443 listed but not mapped yet.

curl /api/health/status returns status healthy, database_healthy true, workers_alive 11

If workers_alive is lower than 11 a minute after the start, a worker container is still restarting; check docker compose ps -a.

Look at the PORTS column of docker compose ps for the caddy container while you're here. As shipped it publishes 0.0.0.0:80 and 0.0.0.0:25, and after step 2 you know what that means. Port 25 as shipped accepts a TCP connection and closes it, because no mail server runs until an email domain is configured; still, as shipped it's a published port doing nothing useful, and it goes away in step 8.

Step 6: Run the Quick setup

Open http://YOUR_SERVER_IP in a browser. Chrome may first show This site doesn't support a secure connection with a Continue to site button; that's Chrome's HTTPS-first mode objecting to plain HTTP on an IP address, and it goes away once HTTPS is on in the next step. Click through.

Chrome warning This site doesn't support a secure connection with a Continue to site button

Chrome's HTTPS-first mode objecting to plain HTTP on an IP address. Continue to site; HTTPS comes in step 7.

The login form comes prefilled: "Default credentials [email protected] / changeme have been prefilled." The login stays prefilled with the default account until step 4 of the wizard replaces it, for you and for anyone else who reaches the address. Do the setup now, in one sitting, and if the VPS is going to sit for a while before you get to it, stop the proxy with docker compose stop caddy until you do.

Windmill login page: Welcome! Default credentials admin@windmill.dev / changeme have been prefilled

The first login page fills the default account in for you, and for anyone else who reaches the address.

After the first login Windmill shows a Welcome page with three choices: Skip, Advanced setup and Quick setup. Windmill's docs describe the earlier flow (log in, get redirected to Instance settings, create a new account, create a workspace); the release we tested runs a four-step Quick setup instead. The screens below were captured on 1.852.0.

Welcome to Windmill card with Skip, Advanced setup and Quick setup buttons

Quick setup is the right-hand button. Skip leaves the default account in place.

The four steps:

  1. Core. Base url is auto-detected from your browser as http://YOUR_SERVER_IP and marked "not yet saved". Below it: License key (Enterprise only), Email domain, Job isolation and retention. Leave Job isolation exactly as it is: the three buttons show nothing selected, yet isolation is on for the default workers through the compose file, and we didn't test what clicking one does. Save & Next.
  2. Authentication. Single sign-on toggles ("Limited to 10 SSO users" on the Community Edition) and a Disable password login switch. Keep password login on until SSO is configured and tested in another browser.
  3. AI. Providers and models, all off. Next.
  4. Root login & Resource Types. "Replace the default superadmin account with a secure email and password." This is the step that matters. Enter your own email and a strong password, then Set account & finish. The resource types were already synced from Windmill's Hub when the page loaded (282 of them on our install), with the daily sync switched on.

Quick setup step 1 Core: Base url http://38.79.x.x marked Auto-detected from browser, not yet saved

The orange note under Base url means the value is a guess from your browser; it is saved when you click Save & Next.

Quick setup step 4, Superadmin login: replace the default superadmin account, then Set account & finish

Both fields filled, Set account & finish lights up. The resource types were synced on their own when the page loaded.

Windmill then logs you out, and the login form is no longer prefilled. Sign in with the account you just created. The password table on our install held only the new account afterwards; [email protected] was replaced, and trying it against the API returned 400 Bad request: Invalid login. The wizard never forces this though. Every step carries a Skip link, and a reader who skips step 4 keeps changeme live and prefilled. If that's you, open Instance settings, Users, add yourself as Superadmin, then delete the default account.

Select a workspace page listing only Admins, with a + Create a new workspace button

Admins is Windmill's own management workspace; your scripts go in the one you create here.

You land on "Select a workspace" with only Admins listed. Click + Create a new workspace, give it a name and an ID and you're in. Open Workers from the left menu once: the default group shows three workers at 35 MB each with a 2048 MB limit, the native group eight and a reports group with zero. The reports group comes from a commented-out service in the compose file, so zero is right.

Workers page: default 3 workers, native 8, reports 0; each default worker 35MB with a 2048MB limit

The Limits column is where the 2048 MB cap from the compose file shows up; the reports tab stays at zero on the shipped file.

Step 7: Put Windmill on your domain with HTTPS

The caddy container in the compose file is already a full reverse proxy with automatic certificates; it just starts in plain-HTTP mode. Two edits in the caddy: service of docker-compose.yml switch it over. Comment out - BASE_URL=":80", put your domain in the commented BASE_URL=mydomain.com line below it and uncomment - 443:443 in the ports list. The compose comments next to those lines describe the same change. With a domain called windmill.example.com:

sed -i -e 's|^      - BASE_URL=":80"|      # - BASE_URL=":80"|' \
       -e 's|^      # - BASE_URL=mydomain.com .*|      - BASE_URL=windmill.example.com|' \
       -e 's|^      # - 443:443 .*|      - 443:443|' docker-compose.yml
docker compose up -d

Only the caddy container was recreated here, in 4 seconds. Caddy then asks Let's Encrypt for a certificate on its own, and on our server it had one about 5 seconds later, using the tls-alpn-01 challenge on port 443. The issuer was Let's Encrypt's YE1 intermediate, valid for 90 days; renewing it is Caddy's job and the log shows the maintenance task starting, though 90 days is longer than our test. Windmill's docs say to expose 443 "instead of" 80. Keep 80 open as well: it serves the redirect from http to https, and it carries the http-01 challenge Caddy falls back to when the tls-alpn-01 one fails, which happened once on our server while 443 was briefly firewalled.

docker compose logs caddy | grep -E "certificate|challenge"
curl -sSI http://windmill.example.com
echo | openssl s_client -connect windmill.example.com:443 -servername windmill.example.com 2>/dev/null | openssl x509 -noout -issuer -dates

Caddy log line: certificate obtained successfully for the domain, issuer acme-v02.api.letsencrypt.org

The line to wait for. If the log shows "Timeout during connect" and "retrying" instead, a firewall is in front of 80 or 443.

curl -I to the http address returns HTTP/1.1 308 Permanent Redirect with a Location header pointing at https

A 308 here is Caddy, so the proxy is up; a connection refused would mean the container didn't start.

openssl x509 shows issuer Let's Encrypt CN YE1, notBefore Oct 29 2026, notAfter Jan 27 2027

The notAfter date is the one to diary if you ever stop Caddy for long; it renews only while running.

The log shows certificate obtained successfully, the HTTP request answers 308 Permanent Redirect to the https address, and openssl prints the issuer and the dates. One side effect to expect: the old http://YOUR_SERVER_IP address stops working. Caddy redirects it to https://YOUR_SERVER_IP, for which it has no certificate, and a browser tab still open on the IP fails with errors like "Could not load worker groups: TypeError: Failed to fetch". Close that tab and use the domain. The same applies to curl http://localhost/api/... on the server itself, which now gets the 308; health checks go to the domain from here on.

Caddy's BASE_URL never reaches the Windmill server, which has its own idea of its address. The Quick setup saved http://YOUR_SERVER_IP as Base url, and that is now wrong: it's the address Windmill uses for OAuth and SSO callbacks and for the resume links of approval steps. Log in over https, open Instance settings from the Settings menu at the bottom left, then Core, and change Base url to https://windmill.example.com. Review changes, then Save settings. One thing to know about the Review changes box: on our install it showed jwt_secret, the key that signs every session token, in plain text alongside the change. Don't screenshot it and don't save settings while screen-sharing.

Instance settings, Core: Base url set to https://windmill.example.com

Core is the first entry in the left column; the field is at the top.

Step 8: Close port 25

Port 25 exists for Windmill's email triggers, which also need an MX record and the Email domain setting from step 6. If you're not going to receive mail into Windmill, comment the mapping out and restart Caddy:

sed -i 's|^      - 25:25|      # - 25:25|' docker-compose.yml
docker compose up -d
ss -tulpn | grep -E ':(25|80|443) '

Only 80 and 443 should remain, and nc -v YOUR_SERVER_IP 25 from the server answers Connection refused. HTTPS survives the restart; the certificate lives in the windmill_caddy_data volume.

ss -tlpn shows docker-proxy listening on 80 and 443 only, plus sshd on 22; nothing on port 25

docker-proxy is Docker's own listener for published ports, which is why these rows never appear in ufw.

A few things the shipped compose already gets right, so you don't have to: container logs rotate at 20 MB with 10 files kept, compressed, on all eight containers (up to 200 MB of logs per container; .env has commented LOG_MAX_SIZE and LOG_MAX_FILE lines to lower that); the Postgres port is not published; the Docker socket is not mounted into the workers, and the compose comment above that line explains why you should leave it that way. The three default workers do run with privileged: true plus FAVOR_UNSHARE_PID=true, the pair that gives them PID namespace isolation for jobs, and the native worker has neither.

Step 9: Decide on telemetry

Telemetry is on by default. Instance settings, Advanced, Telemetry describes it as anonymous usage data and lists what goes out:

  • Instance version and base URL, so your domain is in there
  • Job counts and durations per language
  • Login types, user and seat counts
  • Worker count, vCPUs and memory
  • Development instance status
  • Feature usage and adoption counts, including AI model and public Hub names
  • Counts of workspaces, scripts, flows and apps

The switch underneath the list, Disable telemetry, turns it off entirely. I turn it off on anything that handles customer data and leave it on for a sandbox, since the counts are how a small open-source team learns what people run.

Instance settings Telemetry: list of anonymous usage data collected, and the Disable telemetry switch

Search for "telemetry" in the settings search box; the page sits under Advanced.

That is the shape the stack keeps from here on: two public ports, a superadmin you created, a pinned image and telemetry set the way you want it.

How much RAM Windmill uses under load

The numbers in the sizing table came from docker stats sampled every 10 seconds. At idle, 10 minutes after a clean start, windmill_server used 216 MiB, Postgres 210 MiB, the extra container (language servers and the debugger) 55 MiB, each default worker 33 to 34 MiB, the native worker 39 MiB and Caddy 11 MiB. All eight together: 633 MiB median, 688 MiB peak.

For the load test we created three scripts in a workspace through the API: a Python script that imports requests and sleeps 20 seconds, a TypeScript script on Bun with a 2-second timer and a native TypeScript fetch. Then we submitted 66 runs at once (24 Python, 21 Bun, 21 native). All 66 succeeded; the last finished 179 seconds after submission. Peak memory across the containers was 819 MiB; Postgres was the largest single consumer throughout, at 340 to 350 MiB. CPU never mattered: total container CPU peaked at 35% of one core. What did matter was the number of default workers. Three of them means three Python jobs at a time, and the queue drained at that pace. If your jobs are long and many, add workers before adding RAM.

docker stats during a burst of jobs: workers 47 to 54 MiB of a 2 GiB limit, db 351 MiB, server 219 MiB, caddy 13 MiB

A second, smaller burst (18 jobs) taken for this picture. The MEM USAGE / LIMIT column is the one to watch on your own server.

Then one Python job that allocates 2.5 GB. It failed after 2.7 seconds with exit code 137, and the run page shows ExecutionErr: exit code for "python run": 137. The kernel's cgroup OOM killer took the job at the 2048 MB limit set on that worker in the compose file; dmesg shows Memory cgroup out of memory: Killed process ... (python3.13).

That was the whole blast radius. The worker container stayed up with a restart count of zero, the other jobs ran and /api/health/status stayed healthy. Windmill sets oom_score_adj to 1000 on the job process so the kernel picks the job before the worker. A job that needs more than 2 GB gets its own worker group with a higher limit, which the worker groups docs describe; raising the limit on the default workers instead moves the problem to the host.

kernel log: Memory cgroup out of memory: Killed process (python3.13) with anon-rss about 2 GB, oom_score_adj 1000

CONSTRAINT_MEMCG on the oom-kill line means a container limit did this, so the server itself was never short of RAM.

Windmill run page: py_alloc_2500mb Failed after 2.695s, ExecutionErr exit code for python run: 137

Windmill reports it as a normal failed run; nothing on this page says "memory", so 137 is the clue.

That last point is the reason for the 16 GB recommendation. The four worker caps add up to 8 GiB. On an 8 GB server with no swap, four jobs at their cap at the same time, on top of roughly 0.8 GB for the database, windmill_server and the extra container and whatever the OS holds, is more than the machine has. The global OOM killer then chooses, and it won't necessarily choose a job. Nothing in our test came close to that. We have not measured a CPU-bound load either; the workers have no CPU limit, so a Python job doing real work takes a full core each, and the 1 vCPU per worker rule is the one to size against there.

Every big idea starts small

Start on a small VPS and add resources from the panel as your project grows, keeping the same IP and setup and paying only the difference.

Upgrade a pinned Windmill install

Windmill releases almost daily (about 440 tagged releases in the last 12 months, per the release lifecycle page) and the UI shows a notice in the top right when a newer version exists, "CE v1.852.0 to v1.853.1" style. With a pinned tag the upgrade is three steps: change the tag in .env, pull, restart.

sed -i 's|^WM_IMAGE=.*|WM_IMAGE=ghcr.io/windmill-labs/windmill:NEW_TAG|' .env
docker compose pull windmill_server
docker compose up -d
curl https://windmill.example.com/api/version

Windmill's docs give the order as stop the workers, pull, up. We ran it that way once: the stop took under a second, the pull took 7 minutes 12 seconds and the recreate 7 seconds, so for about seven and a half minutes no default worker existed and nothing but native jobs ran. Pull first, then up -d. Compose recreates the server and workers in seconds, running jobs get what the docs call a graceful exit (up to Docker's 30-second stop timeout), and the database migrations run on their own at startup. Our 88 completed jobs, the scripts and the workspace all came through unchanged on 1.853.1.

docker compose pull of windmill 1.853.1 finishes after real 7m12s

The two "Already exists" layers are the base image; everything above them was downloaded again.

The old image stays on disk, 6.2 GB of it. The docs suggest docker builder prune for this; that command targets build cache, not images, and freed 0 bytes here. The command that removes images no container uses is docker image prune -a; it asks for confirmation and reclaimed 6.1 GB. Right after an upgrade, workers_alive in the health check and the worker count on the Workers page jump above what you run: 19 instead of 11 on our server, and 30 after a later restart. The old containers' entries take a few minutes to age out of the worker_ping table, and the logs mention Minimal worker version as the old number until they do. The health status stayed "healthy" throughout.

docker image prune -a reclaims 6.101GB; docker images lists windmill 1.853.1, windmill-extra, postgres:18 and caddy-l4

Run without -f, the command asks Are you sure before it deletes anything; the list afterwards should show one windmill tag.

One container doesn't follow your pin. windmill-extra, the language-server and debugger image, is on :latest in the compose file regardless of WM_IMAGE, so any up -d can pull a new one. In our run it was already 1.853.1 on both pulls, one release ahead of the pinned 1.852.0 until we upgraded.

Back up and restore the Windmill database

Windmill keeps all of its state in Postgres, and we found no dedicated page in the docs on backing up a self-hosted install. What they do have is the Postgres 18 upgrade page, which walks through dumping and restoring the whole cluster, and that is the procedure we used. Two details from that page matter: it dumps the entire cluster with pg_dumpall rather than one database, because Windmill creates extra databases and cluster-level roles that a single-database dump silently loses; and it tells you which single error to expect on restore.

cd ~/windmill
docker compose down && docker compose up --wait db
docker compose exec -T db pg_dumpall -U postgres > cluster.sql
chmod 600 cluster.sql
grep '^CREATE DATABASE' cluster.sql
docker compose up -d

The dump took 1.6 seconds and 1.1 MB on a small install. The grep should list at least windmill. The chmod is my addition, the docs don't have it: the file is created readable by everyone and contains the password hashes of every user and the jwt_secret. Copy it off the server; a dump that lives on the disk it describes disappears with that disk.

To restore onto a fresh database, stop the stack, remove the data volume, bring up only Postgres, drop the empty windmill database it created, feed the dump in, rebuild statistics, start everything:

docker compose down
docker volume rm windmill_db_data
docker compose up --wait db
docker compose exec -T db psql -U postgres -d postgres -c 'DROP DATABASE windmill'
docker compose exec -T db psql -U postgres -d postgres < cluster.sql > restore.log 2>&1
grep -i '^ERROR' restore.log
docker compose exec -T db vacuumdb -U postgres --all --analyze-in-stages
docker compose up -d

The grep must print exactly one line, ERROR: role "postgres" already exists, which the upgrade page predicts (the dump tries to recreate the role Postgres already made). Anything else in that output means stop and read restore.log. We tested it by creating a marker script, dumping, deleting the script, restoring and finding the script back with the same content hash; policies and roles counted the same before and after (360 and 5), logins worked and the stack was down for under a minute in total.

grep -i ^ERROR restore.log prints one line: ERROR: role "postgres" already exists

Two spaces after ERROR: is how psql prints it; grep for ^ERROR catches it either way.

Fix 502 Bad Gateway and other first-day errors

502 Bad Gateway in the browser, containers restarting

This is the Postgres password changed in one file only. docker compose up -d reports Started for everything and exits 0, so it looks fine. Then docker compose ps -a shows windmill_server and the four workers in Restarting (1), Caddy answers every request with HTTP/1.1 502 Bad Gateway and an empty body, and docker compose logs windmill_server repeats Error: Connecting to database: error returned from database: password authentication failed for user "postgres". (The Failed to install rustls crypto provider line above it appears on healthy starts too; ignore it.) On a fresh install the fix is docker compose down -v, the same password in both files, up -d. The -v deletes every volume, including the database, so it is only for an install with nothing in it yet; on a running install change the password inside Postgres instead.

docker compose ps -a shows windmill_server and the four workers in Restarting (1) while db, caddy and extra are Up

The -a matters: without it Compose hides containers that are mid-restart, and the list looks shorter than it should.

windmill_server log repeats Error: Connecting to database: password authentication failed for user postgres

Every restart adds the same five lines; the Error line is the one to read.

Four symptoms with a fix earlier in the guide

  • The first start looks hung. It's pulling; ten minutes of Pulling lines is normal the first time (step 5). docker compose ps in a second terminal shows the containers appearing as each image lands.
  • Failed to fetch after enabling HTTPS. A tab is still open on http://YOUR_SERVER_IP; Caddy redirects the IP to https and has no certificate for it (step 7). Use the domain.
  • workers_alive is higher than the number of workers. Stale worker_ping rows after a restart or upgrade; they drop off by themselves.
  • [email protected] still logs in. Step 4 of the Quick setup was skipped. Instance settings, Users: add your account as Superadmin, log in with it, delete [email protected] from its row's menu. We did that delete over the API after a reset, and the default login returned 400 from then on.

ERROR lines from the native worker at startup

Two red lines on every start of windmill_worker_native: unshare test command failed ... Unshare isolation will NOT be available and a second one containing Nsjail sandboxing will NOT be available. The native worker isn't privileged in the shipped compose, so it can't set up the isolation the default workers use, and it says so loudly. Native TypeScript fetch jobs ran fine regardless, 21 of 21 in our test.

Not a broken install. If the lines bother you, the security isolation page in Windmill's docs describes how to give the native worker the same privileges; the shipped file leaves it as is, and so did we.

Questions?

Can I use an external PostgreSQL database with self-hosted Windmill?

Yes, Windmill's platform page says any PostgreSQL 14 or newer instance works, including managed services such as RDS, Cloud SQL and Azure Database for PostgreSQL. Point DATABASE_URL in .env at it and set the db service's replicas to 0 in docker-compose.yml so the local Postgres doesn't start. When your database user is not a superuser, Windmill can't create its own roles and the server fails with role "windmill_admin" does not exist; the self-host docs have you run init-db-as-superuser.sql from the Windmill repository with a privileged connection first.

Barry Sandford
About the author

Barry Sandford

Barry spent nearly 15 years in network engineering, mostly with UK hosting providers and regional ISPs. He worked in NOC operations and network design, including infrastructure migrations, and still keeps a lab environment at home to test configurations before he writes about them. His favorite topics tend to be the parts of infrastructure that become visible when something goes wrong: routing, DNS, address planning, firewalls and connectivity between systems that each appear fine on their own. IPv6 has been running on his home network for years. Barry lives in West Yorkshire, has an allotment, follows non-league football and does quite a lot of walking, usually with an OS map folded into a pocket.

Deploy your next project in seconds

Bring your winning ideas online faster, with modern hardware and unmetered bandwidth. Join a European cloud trusted by thousands of developers and businesses worldwide.