Docker

Windshift provides official Docker images built as minimal scratch containers. The multi-stage build produces a small image containing only the compiled binary, CA certificates, and timezone data.

Quick Start

docker run -d \
  --name windshift \
  -p 8080:8080 \
  --tmpfs /tmp:exec,size=64M \
  -v windshift-data:/data \
  -e BASE_URL=http://localhost:8080 \
  -e SSO_SECRET=$(openssl rand -hex 32) \
  ghcr.io/windshiftapp/windshift:latest

Note: This generates a random secret on each docker run. For production, generate a secret once and pass it explicitly so it persists across container restarts - see the Docker Compose examples below.

Docker Compose

The recommended way to run Windshift in production. Create a docker-compose.yml:

services:
  windshift:
    image: ghcr.io/windshiftapp/windshift:latest
    restart: unless-stopped
    ports:
      - "8080:8080"
    tmpfs:
      - /tmp:exec,size=64M
    environment:
      - BASE_URL=https://windshift.example.com
      - SSO_SECRET=${SSO_SECRET}
      - DB_PATH=/data/windshift.db
      - ATTACHMENT_PATH=/data/attachments
    volumes:
      - windshift-data:/data

volumes:
  windshift-data:

Plain HTTP only works for localhost

The compose example above assumes you serve Windshift over HTTPS, with TLS terminated either by Windshift itself or by a reverse proxy in front of it (see the Traefik example below). BASE_URL=http://localhost:8080 is also fine for trying things out on the machine itself.

What does not work is a plain-http BASE_URL with any other hostname or IP, such as http://192.168.1.50:8080 or an internal DNS name. Windshift uses credentialed cross-origin requests, and the CORS layer refuses insecure (http) origins other than localhost. The server starts, but logs:

Failed to create CORS middleware error="cors: for security reasons, insecure origin patterns like \"http://myhost.internal:8080\" cannot be allowed..."

and every browser request from that origin fails with CORS_CONFIG_ERROR. Pick one of these instead:

  1. Recommended: terminate TLS at a reverse proxy and set USE_PROXY=true (Traefik example below, or your existing proxy).

  2. Let Windshift terminate TLS directly with --tls-cert and --tls-key.

  3. For a homelab or test box on a trusted LAN where HTTPS is not an option, opt in to plain http explicitly:

    environment:
      - BASE_URL=http://192.168.1.50:8080
      - ALLOW_INSECURE_HTTP=true

    Sessions and data then travel unencrypted, so anyone on the network path can read or hijack them. Everything else (CSRF protection, rate limiting) keeps working normally. Do not use this for production deployments.

Before First Startup

Generate an SSO_SECRET and create a .env file before running docker compose up. This secret secures both SSO state and session cookies.

# Generate the secret
openssl rand -hex 32

Add it to a .env file alongside your other settings:

DOMAIN=windshift.example.com
BASE_URL=https://windshift.example.com
PORT=8080
SSO_SECRET=<your-generated-secret>
POSTGRES_PASSWORD=    # only needed for PostgreSQL
LETSENCRYPT_EMAIL=    # only needed for Traefik

With PostgreSQL

To use PostgreSQL instead of SQLite, add a postgres service:

services:
  windshift:
    image: ghcr.io/windshiftapp/windshift:latest
    restart: unless-stopped
    ports:
      - "8080:8080"
    tmpfs:
      - /tmp:exec,size=64M
    environment:
      - BASE_URL=https://windshift.example.com
      - SSO_SECRET=${SSO_SECRET}
      - POSTGRES_CONNECTION_STRING=postgres://windshift:${POSTGRES_PASSWORD}@postgres:5432/windshift?sslmode=disable
      - ATTACHMENT_PATH=/data/attachments
    volumes:
      - windshift-data:/data
    depends_on:
      postgres:
        condition: service_healthy

  postgres:
    image: postgres:17-alpine
    restart: unless-stopped
    environment:
      - POSTGRES_USER=windshift
      - POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
      - POSTGRES_DB=windshift
    volumes:
      - postgres-data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U windshift"]
      interval: 5s
      timeout: 5s
      retries: 5

volumes:
  windshift-data:
  postgres-data:

With Traefik (HTTPS)

Add Traefik for automatic HTTPS with Let's Encrypt:

services:
  windshift:
    image: ghcr.io/windshiftapp/windshift:latest
    restart: unless-stopped
    tmpfs:
      - /tmp:exec,size=64M
    environment:
      - BASE_URL=https://${DOMAIN}
      - SSO_SECRET=${SSO_SECRET}
      - USE_PROXY=true
      - ALLOWED_HOSTS=${DOMAIN}
      - DB_PATH=/data/windshift.db
      - ATTACHMENT_PATH=/data/attachments
    volumes:
      - windshift-data:/data
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.windshift.rule=Host(`${DOMAIN}`)"
      - "traefik.http.routers.windshift.entrypoints=websecure"
      - "traefik.http.routers.windshift.tls.certresolver=letsencrypt"
      - "traefik.http.services.windshift.loadbalancer.server.port=8080"

  traefik:
    image: traefik:v3.4
    restart: unless-stopped
    command:
      - "--providers.docker=true"
      - "--providers.docker.exposedbydefault=false"
      - "--entrypoints.web.address=:80"
      - "--entrypoints.websecure.address=:443"
      - "--certificatesresolvers.letsencrypt.acme.email=${LETSENCRYPT_EMAIL}"
      - "--certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json"
      - "--certificatesresolvers.letsencrypt.acme.httpchallenge.entrypoint=web"
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock:ro
      - letsencrypt-data:/letsencrypt

volumes:
  windshift-data:
  letsencrypt-data:

When running behind a reverse proxy, always set:

  • USE_PROXY=true - Trusts X-Forwarded-Proto and X-Forwarded-For headers
  • BASE_URL - Public URL for generating links in emails, SSO redirects, WebAuthn, and calendar feeds
  • ALLOWED_HOSTS - Restricts which hostnames Windshift will accept requests for

Do not publish the Windshift backend port directly when USE_PROXY=true; only the proxy should be able to connect to it.

Docker Image Details

The official image uses a multi-stage build:

  1. Frontend build - Node.js 25-alpine, runs npm ci and builds with Vite
  2. Backend build - Go 1.26-alpine, compiles a static binary (CGO_ENABLED=0)
  3. Runtime - Scratch image with CA certs and timezone data

The final image runs as an unprivileged user (UID 65534) and exposes port 8080.

The /tmp tmpfs mount

The scratch image contains no /tmp directory at all — only what the build copies in. Windshift needs a temp directory for two things:

  • SQLite writes WAL and other temporary files to /tmp, and a tmpfs avoids filesystem compatibility issues there.
  • The coding-agent runner uses it as scratch space for git operations: a per-invocation GIT_ASKPASS credential helper (so repository tokens never appear in command lines or .git/config) and a sanitized staging repository for pushes.

Mount a tmpfs at /tmp in every deployment:

windshift:
  image: ghcr.io/windshiftapp/windshift:latest
  tmpfs:
    - /tmp:exec,size=64M
  volumes:
    - windshift-data:/data

Two details in that one line matter:

  • exec is required. Docker mounts tmpfs with noexec by default, which prevents git from executing the askpass helper script — coding-agent runs would fail with a git credential error even though /tmp exists. The exec option lifts that.
  • Use the short syntax shown above. The long volumes: syntax (type: tmpfs with a tmpfs: sub-key) offers no way to disable noexec, and its mode: field is a YAML footgun — an unquoted mode: 1777 is parsed as decimal and mounts with the wrong permissions. The short syntax defaults to the correct sticky, world-writable mode (1777).

Without the mount, the symptom depends on the feature: SQLite fails on WAL operations, and coding-agent runs fail with errors like prepare checkout: ... setup askpass: stat /tmp: no such file or directory. With the mount but without exec, coding-agent runs fail with a permission error executing the credential helper instead.

Local AI models from Docker

Local / Custom AI connections are SSRF-protected by default: server-side HTTP clients refuse to dial loopback and private addresses. If Windshift needs to reach a model server on the Docker host or a private network, enable the global private-egress switch and then configure the AI connection in the admin UI:

services:
  windshift:
    image: ghcr.io/windshiftapp/windshift:latest
    environment:
      - ALLOW_LOCAL_CONNECTIONS=true

Then use a Local / Custom base URL such as:

http://172.17.0.1:11434/v1

On Docker Desktop, host.docker.internal usually works as the hostname instead of the bridge IP.

ALLOW_LOCAL_CONNECTIONS=true applies to all server-side outbound HTTP (LLM providers, SCM integrations, Jira import, OIDC, webhooks), not just AI connections. Only enable it when your network policy keeps sensitive internal endpoints (cloud metadata services, admin panels) out of reach of the Windshift host. See Configuration Options for details.

Optional Services

Windshift supports additional companion services that run as separate containers:

  • Coding Agent Runner - Runs coding agents server-side in ephemeral per-run containers and opens draft pull requests