Docker

Windshift provides official Docker images as minimal scratch containers. The multi-stage build includes only the compiled binary, CA certificates, and timezone data.

Before a production deployment, choose a trust profile and complete the production-ready self-hosting checklist.

Quick Start (local trial)

This command is for a local trial only. It is the one place in this guide that uses the latest tag.

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

Confirm that it came up:

curl http://localhost:8080/readyz
curl http://localhost:8080/api/version

Note: This command generates a random secret on each docker run. For production, generate a secret once and pass it explicitly. See the Docker Compose examples below.

Docker Compose

For production, use Docker Compose. Create docker-compose.yml:

services:
  windshift:
    image: ghcr.io/windshiftapp/windshift:${WINDSHIFT_VERSION}
    restart: unless-stopped
    ports:
      - "8080:8080"
    tmpfs:
      - /tmp:exec,size=64M
    mem_limit: ${WINDSHIFT_CONTAINER_MEMORY_LIMIT:-2g}
    environment:
      - BASE_URL=https://windshift.example.com
      - SSO_SECRET=${SSO_SECRET}
      - SESSION_IP_BINDING=${SESSION_IP_BINDING:-log}
      - WEBAUTHN_RP_ID=windshift.example.com
      - TLS_SKIP_VERIFY=${TLS_SKIP_VERIFY:-false}
      - WINDSHIFT_MEMORY_LIMIT_MB=${WINDSHIFT_MEMORY_LIMIT_MB:-2048}
      - DB_PATH=/data/windshift.db
      - ATTACHMENT_PATH=/data/attachments
    volumes:
      - windshift-data:/data
    healthcheck:
      test: ["CMD", "/windshift", "healthcheck"]
      interval: 15s
      timeout: 5s
      retries: 5
      start_period: 30s

volumes:
  windshift-data:

Pin the image version

Production examples in this guide read the image tag from WINDSHIFT_VERSION in your .env file. Pin it to a published release rather than tracking latest, so a docker compose pull cannot change the running application unexpectedly:

WINDSHIFT_VERSION=v0.8.5

The current release is shown on the download page. Use latest only for the trial command above.

To pin by digest instead, resolve it once and use the @sha256: form:

docker buildx imagetools inspect ghcr.io/windshiftapp/windshift:v0.8.5
image: ghcr.io/windshiftapp/windshift@sha256:<digest>

A digest is immutable, so it also survives a re-tagged release.

Set memory budgets

The shipped Docker Compose deployment uses a 2g container limit and a 2048 MiB Windshift process budget. Keep these values aligned:

services:
  windshift:
    mem_limit: ${WINDSHIFT_CONTAINER_MEMORY_LIMIT:-2g}
    environment:
      - WINDSHIFT_MEMORY_LIMIT_MB=${WINDSHIFT_MEMORY_LIMIT_MB:-2048}

WINDSHIFT_MEMORY_LIMIT_MB uses MiB. Docker accepts memory-unit syntax such as 2g for WINDSHIFT_CONTAINER_MEMORY_LIMIT. The process budget is a soft target, not a hard RSS ceiling. Set the container limit to at least the process budget and leave room for native allocations, database buffers, goroutine stacks, and temporary request data.

Windshift derives an 80% Go heap target and a BigCache budget equal to 25% of the process budget, capped at 512 MiB. Administrators can inspect utilization, hit and miss counts, and evictions under Admin → Diagnostics → Cache memory.

Plain HTTP only works for localhost

The Compose example assumes HTTPS. Terminate TLS at Windshift or at a reverse proxy. For local testing, use BASE_URL=http://localhost:8080.

A plain-HTTP BASE_URL does not work with another hostname or IP, such as http://192.168.1.50:8080 or an internal DNS name. Windshift uses credentialed cross-origin requests. Its CORS layer rejects insecure HTTP origins except 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..."

Every browser request from that origin then fails with CORS_CONFIG_ERROR. Use one of these options:

  1. Recommended: Terminate TLS at a reverse proxy and set USE_PROXY=true.

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

  3. On a trusted LAN or test host where HTTPS is not available, opt in to plain HTTP:

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

    Sessions and data then travel unencrypted. Anyone on the network path can read or hijack them. CSRF protection and rate limiting still work. Do not use this for production.

Before First Startup

Before you run docker compose up, generate an SSO_SECRET and create a .env file. This secret secures SSO state and session cookies.

# Generate the secret
openssl rand -hex 32

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

WINDSHIFT_VERSION=v0.8.5
WINDSHIFT_MEMORY_LIMIT_MB=2048
WINDSHIFT_CONTAINER_MEMORY_LIMIT=2g
DOMAIN=windshift.example.com
BASE_URL=https://windshift.example.com
PORT=8080
SSO_SECRET=<your-generated-secret>
SESSION_IP_BINDING=log
WEBAUTHN_RP_ID=windshift.example.com
TLS_SKIP_VERIFY=false
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:${WINDSHIFT_VERSION}
    restart: unless-stopped
    ports:
      - "8080:8080"
    tmpfs:
      - /tmp:exec,size=64M
    environment:
      - BASE_URL=https://windshift.example.com
      - SSO_SECRET=${SSO_SECRET}
      - SESSION_IP_BINDING=${SESSION_IP_BINDING:-log}
      - WEBAUTHN_RP_ID=windshift.example.com
      - TLS_SKIP_VERIFY=${TLS_SKIP_VERIFY:-false}
      - POSTGRES_CONNECTION_STRING=postgres://windshift:${POSTGRES_PASSWORD}@postgres:5432/windshift?sslmode=disable
      - ATTACHMENT_PATH=/data/attachments
    volumes:
      - windshift-data:/data
    healthcheck:
      test: ["CMD", "/windshift", "healthcheck"]
      interval: 15s
      timeout: 5s
      retries: 5
      start_period: 30s
    depends_on:
      postgres:
        condition: service_healthy

  postgres:
    image: postgres:18
    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:${WINDSHIFT_VERSION}
    restart: unless-stopped
    tmpfs:
      - /tmp:exec,size=64M
    mem_limit: ${WINDSHIFT_CONTAINER_MEMORY_LIMIT:-2g}
    environment:
      - BASE_URL=https://${DOMAIN}
      - SSO_SECRET=${SSO_SECRET}
      - SESSION_IP_BINDING=${SESSION_IP_BINDING:-log}
      - WEBAUTHN_RP_ID=${DOMAIN}
      - TLS_SKIP_VERIFY=${TLS_SKIP_VERIFY:-false}
      - WINDSHIFT_MEMORY_LIMIT_MB=${WINDSHIFT_MEMORY_LIMIT_MB:-2048}
      - USE_PROXY=true
      - ALLOWED_HOSTS=${DOMAIN}
      - DB_PATH=/data/windshift.db
      - ATTACHMENT_PATH=/data/attachments
    volumes:
      - windshift-data:/data
    healthcheck:
      test: ["CMD", "/windshift", "healthcheck"]
      interval: 15s
      timeout: 5s
      retries: 5
      start_period: 30s
    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.0
    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 you run behind a reverse proxy, set:

  • USE_PROXY=true: Trusts X-Forwarded-Proto and X-Forwarded-For headers.
  • BASE_URL: Public URL for email links, SSO redirects, WebAuthn, and calendar feeds.
  • ALLOWED_HOSTS: Optional browser-origin allowlist for CORS, CSRF, WebAuthn, and SSO redirect validation. Windshift derives it from BASE_URL for a single domain. It does not filter Host headers.

Do not publish the Windshift backend port directly when USE_PROXY=true. Only the proxy should connect to it.

WebAuthn relying-party ID in containers

Passkeys bind to a relying-party ID (RP ID). The WebAuthn protocol uses a bare hostname with no scheme, port, or path. Windshift 0.8.5 and later also accept a full HTTP or HTTPS URL in WEBAUTHN_RP_ID and extract its hostname.

localhost is valid for local development. Browsers permit the HTTP exception for localhost, but not for arbitrary hostnames. A single-label hostname such as windshift is not valid for passkeys. Use localhost or a dotted hostname.

Windshift resolves it in this order:

  1. WEBAUTHN_RP_ID, when set.
  2. The hostname from BASE_URL.
  3. The process host name.

In a container, the process host name is the container ID, not the hostname the browser used. On releases that fall through to step 3, the startup log shows a random RP ID and passkey registration fails:

WebAuthn configuration initialized rp_id=<container-id>

Set WEBAUTHN_RP_ID explicitly in every containerized deployment. It is unambiguous, and it is also required when the browser-visible host differs from the host in BASE_URL:

environment:
  - BASE_URL=https://windshift.example.com
  - WEBAUTHN_RP_ID=windshift.example.com

For a local trial, use WEBAUTHN_RP_ID=localhost. Check the resolved value in the startup log:

docker compose logs windshift | grep rp_id

Verify the deployment

Run these checks after docker compose up -d. They confirm that the container is up, that the expected version is running, and that Windshift reached its database.

# Container state; the health column reflects the Compose healthcheck.
docker compose ps

# Liveness: the HTTP process and router are serving.
curl http://localhost:8080/healthz
# {"status":"ok"}

# Readiness: the database is reachable. This is the primary success check.
curl http://localhost:8080/readyz
# {"status":"ready","database":"ok"}

# Which build is running.
curl http://localhost:8080/api/version
# {"version":"0.8.5","commit":"...","date":"...","name":"..."}

# Whether the first-run setup assistant is still pending.
curl http://localhost:8080/api/setup/status
# {"setup_completed":false,"admin_user_created":false,...}

# Startup errors and the selected database engine.
docker compose logs windshift

/readyz returns HTTP 503 with {"status":"not_ready","database":"unavailable"} while the database is unreachable, and recovers without a restart once it comes back. Use it for orchestrator readiness probes and rollout gates.

The startup log states which engine was selected. Confirm it matches your configuration:

connecting to SQLite database
connecting to PostgreSQL database

/healthz and /readyz are unauthenticated and are not part of the API prefix. /api/health does not exist, and /health returns the frontend shell, so neither works as a probe.

Build the image from source

The published images cover normal deployments. Build locally when you are testing a release candidate or a change of your own.

git clone https://github.com/Windshiftapp/core.git
cd core
docker build -t windshift:local .

The repository ships a .dockerignore that restricts the build context to the files the image needs. Without it, a developer checkout sends multi-gigabyte build contexts, so do not build from a checkout that removed it.

/api/version reports dev unless you supply version metadata. The Dockerfile accepts four build arguments, which the release pipeline passes:

Build arg Purpose
VERSION Version string reported by /api/version and the About page.
RELEASE_NAME Human-readable release name.
COMMIT Source commit of the build.
BUILD_DATE Build timestamp.
docker build \
  --build-arg VERSION=0.8.5-dev \
  --build-arg RELEASE_NAME="0.8.5 Development" \
  --build-arg COMMIT="$(git rev-parse HEAD)" \
  --build-arg BUILD_DATE="$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
  -t windshift:0.8.5-dev .

Use a version string that cannot be mistaken for a published release, so a local build is never confused with an official image.

Then verify the metadata in the running container:

curl http://localhost:8080/api/version

Official multi-architecture releases are built and pushed by release.sh in the core repository, which forwards the same build arguments.

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 certificates and timezone data.

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

The /tmp tmpfs mount

Every Windshift deployment must mount a tmpfs at /tmp with exec. This applies to SQLite and PostgreSQL alike, and it is not conditional on coding agents.

The scratch image has no /tmp directory. Windshift needs a temporary directory for two purposes:

  • Large multipart uploads can spill data to /tmp after they exceed the 32 MiB memory threshold. This affects every deployment.
  • The coding-agent runner uses /tmp for git operations. It creates a per-invocation GIT_ASKPASS helper and a sanitized staging repository for pushes. Repository tokens never appear in command lines or .git/config. This is an additional reason for the mount, not the condition for it.
windshift:
  image: ghcr.io/windshiftapp/windshift:${WINDSHIFT_VERSION}
  tmpfs:
    - /tmp:exec,size=64M
  volumes:
    - windshift-data:/data

Two details matter:

  • exec is required. Docker mounts tmpfs with noexec by default. Git then cannot execute the askpass helper, even when /tmp exists. The exec option allows it.
  • Use the short syntax shown above. The long volumes: syntax (type: tmpfs with a tmpfs: sub-key) cannot disable noexec. Its mode: field also has a YAML pitfall. An unquoted mode: 1777 parses as decimal and sets incorrect permissions. The short syntax uses the correct sticky, world-writable mode (1777).

Without this mount, large attachment uploads can fail when they need temporary storage. Coding-agent runs can also fail with prepare checkout: ... setup askpass: stat /tmp: no such file or directory. Without exec, git cannot execute the credential helper.

Local AI models from Docker

By default, server-side HTTP clients allow Local / Custom AI connections to loopback and private addresses. To restore private-address blocking, set the global switch to false. Then configure the AI connection in the admin UI:

services:
  windshift:
    image: ghcr.io/windshiftapp/windshift:${WINDSHIFT_VERSION}
    environment:
      - ALLOW_LOCAL_CONNECTIONS=false

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=false applies to all server-side outbound HTTP, not only AI connections. This includes LLM providers, SCM integrations, Jira import, OIDC, webhooks, and SMTP. Private or local endpoints will not connect while the switch is false. See Configuration Options for details.

Optional Services

Windshift supports companion services in separate containers:

  • Coding Agent Runner: Runs coding agents server-side in one ephemeral container per job and opens draft pull requests.