tva
← Insights

Self-Hosting Windmill in 2026: Workers, PostgreSQL, Isolation, and Safe Upgrades

This production-focused follow-up turns current vendor guidance into operating controls, migration decisions, and verifiable release criteria.

What changed in 2026

We revisited this subject because the operational constraints have changed. The durable principles remain useful, but current versions make several old shortcuts incomplete or unsafe. This follow-up starts with the vendor documentation available on 14 July 2026, separates facts from local choices, and treats every configuration change as a controlled production intervention.

Current primary sources

How we use the update

Read the sources first, record the versions actually deployed, and define the observable result before changing a setting. Test the smallest reversible intervention in a representative environment. A successful command is not the acceptance criterion: service health, data integrity, latency, security boundaries, and rollback time are. Where the earlier guide offers a useful diagnostic sequence, we retain it below as an operating foundation; version-specific examples must still be checked against the current documentation.

Before release, capture a configuration diff, a backup or snapshot that has been restored at least once, and the commands needed to reverse the intervention. Assign one person to observe the first production window and another to approve escalation if the expected indicators move in the wrong direction. Alerts should describe the user-visible risk, not merely the component that emitted them. During the post-release observation period, compare error rates, queue depth, resource saturation, processing latency, and data completeness with the agreed baseline. Review both averages and tail behavior, because a stable average can conceal failures affecting a small but important share of work. Close the change only after delayed jobs, retries, scheduled tasks, and downstream exports have completed. Record any manual action taken during recovery; an undocumented rescue step is evidence that the runbook is not yet complete. This release discipline creates evidence that can be reused at the next upgrade instead of forcing the team to rediscover the same dependencies.

Operating foundation

Workflow automation platforms are essential for modern development teams, but cloud solutions like Windmill Cloud can become expensive as usage grows. We’ll show you how to set up your own Windmill instance on Ubuntu with Docker Compose and Traefik integration, overcoming critical PostgreSQL authentication issues that can derail your installation.

What You’ll Build

By the end of this tutorial, you’ll have:

  • ✅ Fully functional Windmill installation with HTTPS
  • ✅ Automatic SSL certificates via Let’s Encrypt through Traefik
  • ✅ Production-ready PostgreSQL database with proper authentication
  • ✅ Resource-optimized worker configuration
  • ✅ Integration with existing Docker infrastructure
  • ✅ Production-ready setup for professional workflow automation

Monthly cost: from €5.99 excl. VAT, incl. a primary IPv4 address (Hetzner CX23, 4GB RAM, as of October 2026) + domain costs – same infrastructure that can handle multiple automation tools

Prerequisites

  • Ubuntu 24.04 LTS server with Docker and Docker Compose installed
  • Existing Traefik reverse proxy setup (see our n8n setup guide for Traefik configuration)
  • Domain name pointing to your server IP
  • At least 4GB RAM and 2 vCPUs recommended
  • SSH access and basic command-line knowledge

Understanding Windmill

Windmill is an open-source workflow engine that provides:

  • Visual workflow editor with TypeScript/Python/Go support
  • Job scheduling and execution management
  • API integration capabilities
  • Team collaboration features
  • Self-hostable, with unlimited executions in the free Community Edition

Unlike n8n’s node-based approach, Windmill focuses on code-first workflows with a powerful development environment.

Step 1: Server Preparation and Directory Structure

First, let’s prepare our server environment. We’ll use a food-based naming convention for Windmill instances to avoid conflicts:

# Create Windmill directory (first instance: "pizza")
mkdir -p /opt/windmill-pizza
cd /opt/windmill-pizza

# Create required subdirectories
mkdir -p postgres-data windmill-data lsp-cache

# Verify directory structure
ls -la

Naming Convention: Use simple food names for multiple Windmill instances:

  • First instance: pizza
  • Additional instances: pasta, salad, soup, burger, etc.
  • This avoids conflicts and makes management easier

Step 2: Environment Configuration

Create a secure environment file with proper credentials:

# Generate a secure hex password (no special characters!)
SECURE_PASSWORD=$(openssl rand -hex 16)

# Create environment file
cat > /opt/windmill-pizza/.env << EOF
# Windmill Image Version (pin a release; change it deliberately when you update)
WM_IMAGE=ghcr.io/windmill-labs/windmill:1.767.0

# Database Configuration - SECURE PASSWORDS
DATABASE_URL=postgresql://postgres:${SECURE_PASSWORD}@windmill-db:5432/windmill_pizza?sslmode=disable
POSTGRES_PASSWORD=${SECURE_PASSWORD}
POSTGRES_DB=windmill_pizza
POSTGRES_USER=postgres

# Windmill Configuration
BASE_URL=https://windmill.yourdomain.com
RUST_LOG=info

# Worker Configuration
WORKER_GROUP=default
KEEP_JOB_DIR=false

# Instance Identifier
INSTANCE_NAME=pizza
EOF

# The file holds the database password: make it readable by its owner only
chmod 600 /opt/windmill-pizza/.env

Critical: Replace windmill.yourdomain.com with your actual domain!

Step 3: Docker Compose Configuration

Create the main Docker Compose configuration:

cat > /opt/windmill-pizza/docker-compose.yml << 'EOF'
services:
  # PostgreSQL Database for Windmill
  windmill-db:
    image: postgres:16
    container_name: windmill-pizza-db
    restart: unless-stopped
    environment:
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
      POSTGRES_DB: ${POSTGRES_DB}
      POSTGRES_USER: ${POSTGRES_USER}
    volumes:
      - ./postgres-data:/var/lib/postgresql/data
    networks:
      - proxy
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 10s
      timeout: 5s
      retries: 5

  # Windmill Server
  windmill-server:
    image: ${WM_IMAGE}
    container_name: windmill-pizza-server
    restart: unless-stopped
    environment:
      - DATABASE_URL=${DATABASE_URL}
      - BASE_URL=${BASE_URL}
      - RUST_LOG=${RUST_LOG}
      - MODE=server
    networks:
      - proxy
    depends_on:
      windmill-db:
        condition: service_healthy
    volumes:
      - ./windmill-data:/tmp/windmill
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.windmill-pizza.rule=Host(`windmill.yourdomain.com`)"
      - "traefik.http.routers.windmill-pizza.entrypoints=https"
      - "traefik.http.routers.windmill-pizza.tls.certresolver=letsencrypt"
      - "traefik.http.services.windmill-pizza.loadbalancer.server.port=8000"

  # Windmill Worker
  windmill-worker:
    image: ${WM_IMAGE}
    container_name: windmill-pizza-worker
    restart: unless-stopped
    environment:
      - DATABASE_URL=${DATABASE_URL}
      - BASE_URL=${BASE_URL}
      - RUST_LOG=${RUST_LOG}
      - MODE=worker
      - WORKER_GROUP=${WORKER_GROUP}
      - KEEP_JOB_DIR=${KEEP_JOB_DIR}
    networks:
      - proxy
    depends_on:
      windmill-db:
        condition: service_healthy
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock
      - ./windmill-data:/tmp/windmill
      - worker_dependency_cache:/tmp/windmill/cache
    deploy:
      resources:
        limits:
          cpus: '1'
          memory: 2G

networks:
  proxy:
    external: true

volumes:
  worker_dependency_cache:
    driver: local
EOF

Important: Update the domain in the Traefik labels to match your setup!

Step 4: The PostgreSQL Password Problem (Critical Issue)

Here’s where most Windmill installations fail, and it took considerable troubleshooting to identify the root cause:

The Problem: Special Characters in Passwords

When using openssl rand -base64 32 to generate passwords, the output can contain +, /, and =. Inside DATABASE_URL, these characters must be percent-encoded; left unencoded, they can break URL parsing and surface as PostgreSQL authentication failures. Hex passwords avoid the encoding step entirely.

Example of a problematic password:

# Unless percent-encoded, the "/" in this password breaks DATABASE_URL:
PASSWORD="305t6m9KrChkvbyN/FLEYQ6pqAGlApn9rbJPH3D5y9g="

The Solution: Hex-Only Passwords

Use hex-only passwords that contain no special characters:

# This WORKS reliably:
PASSWORD=$(openssl rand -hex 16)
# Example result: 5781b14ec0ec1bc184653ffa5e379411

Additional PostgreSQL Configuration Issues

  • User Configuration: Use the postgres superuser, as Windmill’s example configuration does. A custom user works only after it is granted the windmill_admin and windmill_user roles that Windmill’s migrations require (see the Windmill self-hosting docs)
  • Volume Persistence: PostgreSQL ignores POSTGRES_PASSWORD environment variables when existing data volumes contain different credentials
  • URL Format: Include ?sslmode=disable in the database URL when the database is reachable only on the internal Docker network, as in Windmill’s example configuration; use an SSL mode such as require for databases reached over other networks

Step 5: Installation and Startup

Now let’s install Windmill with our corrected configuration:

cd /opt/windmill-pizza

# Verify configuration syntax
docker compose config --quiet

# Pull images
docker compose pull

# Start services
docker compose up -d

# Check status
docker compose ps

You should see output like:

NAME                    STATUS                    PORTS
windmill-pizza-db       Up (healthy)              5432/tcp
windmill-pizza-server   Up                        8000/tcp
windmill-pizza-worker   Up                        8000/tcp

Step 6: Troubleshooting Common Issues

Issue 1: PostgreSQL Authentication Failures

Symptoms:

windmill-pizza-server | Error: password authentication failed for user "postgres"

Solution: Resetting the database deletes all Windmill data. Do it only on a fresh install or after a verified backup.

# WARNING: this deletes all Windmill data (fresh install or verified backup only)
cd /opt/windmill-pizza

# Stop containers
docker compose down --volumes

# Remove old data
rm -rf /opt/windmill-pizza/postgres-data/*

# Regenerate hex password and write it to both variables
NEW_PASSWORD=$(openssl rand -hex 16)
sed -i "s/^POSTGRES_PASSWORD=.*/POSTGRES_PASSWORD=$NEW_PASSWORD/" .env
sed -i "s|^DATABASE_URL=postgresql://postgres:[^@]*@|DATABASE_URL=postgresql://postgres:$NEW_PASSWORD@|" .env

# Restart
docker compose up -d

Issue 2: Container Won’t Start

Symptoms:

  • Container exits immediately
  • Resource allocation errors

Solution:

# Check container logs
docker logs windmill-pizza-server
docker logs windmill-pizza-db

# Check system resources
docker stats
free -h

Issue 3: SSL Certificate Issues

Symptoms:

  • HTTPS not working
  • Certificate errors

Solution:

# Check Traefik logs
docker logs traefik

# Verify DNS resolution
nslookup windmill.yourdomain.com

# Restart Traefik if needed
docker restart traefik

Step 7: Access and Initial Setup

Once installation is complete:

  • Access Windmill: https://windmill.yourdomain.com
  • Default credentials:

Email: [email protected]

  • Password: changeme
  • Complete setup:

Change admin password

  • Configure base URL
  • Set up user accounts

Step 8: Resource Optimization

Memory Allocation (for 8GB server)

Our configuration allocates resources efficiently:

  • Windmill Server: ~800MB
  • Windmill Worker: 2GB (limited)
  • PostgreSQL: ~500MB
  • System Reserve: ~4.7GB

CPU Allocation (for 4 vCPU server)

  • Worker: 1 vCPU (limited)
  • Other services: 3 vCPUs (shared)

Scaling rule: 1 worker per vCPU with 1-2GB RAM each

Step 9: Production Hardening

Create Backup Script

cat > /opt/windmill-pizza/backup.sh << 'EOF'
#!/bin/bash
set -euo pipefail
umask 077

# docker compose needs the project directory, also when run from cron
cd /opt/windmill-pizza

DATE=$(date +%Y%m%d_%H%M%S)
BACKUP_FILE=/opt/backups/windmill_pizza_${DATE}.sql
mkdir -p /opt/backups

if ! docker compose exec -T windmill-db pg_dump -U postgres windmill_pizza > "$BACKUP_FILE" || [ ! -s "$BACKUP_FILE" ]; then
  rm -f "$BACKUP_FILE"
  echo "Backup FAILED" >&2
  exit 1
fi
echo "Backup created: $BACKUP_FILE"
EOF

chmod +x /opt/windmill-pizza/backup.sh

Set Up Monitoring

cd /opt/windmill-pizza

# Monitor container health
docker compose ps

# Check resource usage
docker stats

# Monitor logs
docker compose logs -f windmill-server

Configure Automated Updates

cat > /opt/windmill-pizza/update.sh << 'EOF'
#!/bin/bash
set -euo pipefail
cd /opt/windmill-pizza

# Back up first; stop here if the backup fails
./backup.sh

# Pull and start the images set in .env (raise WM_IMAGE there to update Windmill)
docker compose pull
docker compose up -d
EOF

chmod +x /opt/windmill-pizza/update.sh

Step 10: Advanced Configuration

Integration with Existing SMTP

If you have a mail server (such as the one in our n8n setup), integrate it:

# Add to docker-compose.yml environment for windmill-server:
- SMTP_HOST=mailserver
- SMTP_PORT=25
- [email protected]
# Only if your mail server requires authentication:
# - SMTP_USERNAME=...
# - SMTP_PASSWORD=...

Multiple Windmill Instances

For teams requiring isolated environments:

# Create second instance from the configuration files only (not the data)
mkdir -p /opt/windmill-pasta/{postgres-data,windmill-data,lsp-cache}
cp /opt/windmill-pizza/{.env,docker-compose.yml,backup.sh,update.sh} /opt/windmill-pasta/
chmod 600 /opt/windmill-pasta/.env
cd /opt/windmill-pasta

# Update names, paths and domain
sed -i 's/pizza/pasta/g' .env docker-compose.yml backup.sh update.sh
sed -i 's/windmill.yourdomain.com/pasta.yourdomain.com/g' .env docker-compose.yml

# Generate new password and write it to both variables
NEW_PASSWORD=$(openssl rand -hex 16)
sed -i "s/^POSTGRES_PASSWORD=.*/POSTGRES_PASSWORD=$NEW_PASSWORD/" .env
sed -i "s|^DATABASE_URL=postgresql://postgres:[^@]*@|DATABASE_URL=postgresql://postgres:$NEW_PASSWORD@|" .env

# Start the new instance
docker compose up -d

Security Considerations

Network Isolation

  • PostgreSQL is reachable only from containers on its Docker network; if that network is shared with the reverse proxy and other services, use a separate internal network for stricter isolation
  • No external database ports exposed
  • HTTPS termination at the Traefik level

Resource Limits

  • Worker containers have CPU and memory limits
  • Resource limits reduce the impact of runaway jobs and resource-exhaustion attempts
  • Configurable based on server capacity

SSL Security

  • Automatic Let’s Encrypt certificates
  • HTTP-to-HTTPS redirects
  • Modern TLS configuration

Monitoring and Maintenance

Weekly Health Checks

cd /opt/windmill-pizza

# Container status
docker compose ps

# Resource usage
docker stats --no-stream

# Log analysis
docker compose logs | grep -i error

Monthly Maintenance

# Back up and update containers (update.sh runs backup.sh first)
cd /opt/windmill-pizza
./update.sh

# Clean unused images, containers and networks
docker system prune -f

Cost Breakdown and Comparison

Monthly Costs

Self-hosted setup:

  • Hetzner CX23 (4GB RAM): €5.99/month excl. VAT, incl. a primary IPv4 address (as of October 2026)
  • Domain costs: ~€1/month
  • Total: ~€7/month excl. VAT

Windmill Cloud comparison:

  • Cloud: $20/month per developer seat, $10/month per operator (as of October 2026)
  • Savings: $250+ annually once two or more developers need seats

Performance Benefits

Self-hosted advantages:

  • Unlimited workflow executions
  • No external rate limits
  • Full data control
  • Custom integrations
  • Resource scaling flexibility

Troubleshooting Reference

Quick Diagnostics

cd /opt/windmill-pizza

# Container health
docker compose ps | grep -E "(healthy|Up)"

# Network connectivity
docker network inspect proxy

# Database connection
docker compose exec windmill-db psql -U postgres -d windmill_pizza -c "SELECT version();"

# Log analysis
docker compose logs --tail 50 windmill-server | grep -E "(ERROR|WARN)"

Common Error Patterns

  • “password authentication failed” → Use hex passwords; clear volumes only after a backup or on a fresh install
  • “connection refused” → Check network configuration
  • “certificate errors” → Verify DNS and Traefik setup
  • “out of memory” → Adjust worker resource limits

Scaling Your Windmill Infrastructure

Horizontal Scaling

For high-volume environments:

# Add additional workers (under services: in docker-compose.yml)
windmill-worker-2:
  image: ${WM_IMAGE}
  container_name: windmill-pizza-worker-2
  restart: unless-stopped
  environment:
    - DATABASE_URL=${DATABASE_URL}
    - MODE=worker
    - WORKER_GROUP=heavy
  networks:
    - proxy
  depends_on:
    windmill-db:
      condition: service_healthy
  deploy:
    resources:
      limits:
        cpus: '2'
        memory: 4G

Vertical Scaling

Upgrade server resources (Hetzner prices excl. VAT, incl. a primary IPv4 address, as of October 2026):

  • CX33 (8GB RAM): €8.99/month for heavy workloads
  • CX43 (16GB RAM): €16.49/month for enterprise usage

Integration with Existing Infrastructure

Working with n8n

If you’re already running n8n (from our previous tutorials):

  • Windmill handles code-first workflows
  • n8n handles visual, simple automations
  • Both share the same Traefik proxy
  • Separate databases prevent conflicts

Shared Services

Use existing infrastructure:

  • Traefik: Handles SSL for all services
  • Mailserver: Shared SMTP for notifications
  • Monitoring: Unified logging and metrics
  • Backups: Centralized backup strategy

Conclusion

Self-hosting Windmill provides enterprise-grade workflow automation at a fraction of cloud hosting costs. The key to success is understanding how PostgreSQL credentials are passed and persisted, and using hex-only passwords to avoid URL-encoding issues that can derail installations.

Key Benefits of This Setup

  • Cost-effective: Save hundreds per year compared to cloud solutions
  • Production-ready: Handles enterprise workloads reliably
  • Secure: HTTPS, no exposed database port, and resource limits
  • Scalable: Easy to add workers and resources as needed
  • Private: Your code and data stay on your infrastructure unless your workflows send them to external services

This configuration has been tested in production environments and provides the reliability needed for business-critical workflow automation. The troubleshooting steps address real-world issues encountered during deployment, particularly the PostgreSQL authentication problems that affect many self-hosted installations.

For complex workflow requirements or enterprise deployments, consider professional consultation to optimize your specific use case and ensure optimal resource allocation.

Next Steps

  • Explore webhook troubleshooting techniques that apply to Windmill
  • Review payload optimization for handling large datasets
  • Consider integration with existing n8n installations

About tva

tva ensures comprehensive infrastructure management of database systems, cloud environments, and global supply chains. Our methodical approach combines rigorous security protocols with performance optimization, while strategic advisory services enable precise coordination of both digital capabilities and physical assets – maintaining the highest standards of operational excellence and compliance throughout all engagements.

Visit tva.sg for more information about our services and additional automation tutorials.

From configuration to an operating decision

The practical question is not whether a platform can be configured. It is whether the team can explain ownership, detect drift, recover without improvisation, and demonstrate that the change improved the intended outcome. We therefore pair configuration with an owner, a baseline, a rollback path, and a post-release observation period. That turns a one-off fix into an operating capability. The production record should include the reason for the change, the evidence reviewed, the exact scope, and the person authorized to stop or reverse it. After release, compare the agreed indicators with the baseline and keep the result with the change record. If the expected improvement is absent, rollback is a valid outcome rather than a failure. This discipline matters more than any individual configuration value. The same record gives the next operator a trustworthy starting point and makes later optimization a measured decision rather than another round of guesswork.

Related Insights

Further Reading