Install with Docker Compose
Run Stackweaver and its database, queue, object storage and identity provider on one host. The downloadable bundle uses pre-built images; you do not need the application source. Use Helm when deploying into Kubernetes.
Before you start
- Docker Engine and Compose v2, plus a POSIX shell and OpenSSL for the commands below.
- At least 4 GB free RAM; allow 8 GB for an evaluation with workload execution. Size production capacity for the runs you expect, rather than treating these as a capacity guarantee.
- Ports 5173 (frontend), 8022 (API) and 8080 (Zitadel) available.
- Pull access to every required image. For private packages, obtain access and authenticate
with
docker login ghcr.io; see GHCR access.
The supplied localhost passwords and storage keys are evaluation defaults. Before exposing a new install, choose unique database/storage credentials and a Zitadel master key/password in the bundle's bootstrap input files. Keep existing keys when moving a configured installation.
For a new installation, review these coupled inputs before copying .env.example:
| Setting | Locations that must agree |
|---|---|
| Database password | POSTGRES_PASSWORD in .env.example and Database.Postgres.Admin.Password / Database.Postgres.User.Password in zitadel-defaults.yaml. |
| Zitadel administrator password | FirstInstance.Org.Human.Password in zitadel-init-steps.yaml is the first-instance password. Keep the bundle's ZITADEL_ADMIN_PASSWORD input consistent; changing that env input alone does not change an existing account. |
| Zitadel master key | ZITADEL_MASTERKEY in .env.example: exactly 32 bytes. Keep it unchanged for existing identity data. |
| Object-storage credentials | STORAGE_ACCESS_KEY (GK plus 24 hex characters) and STORAGE_SECRET_KEY (64 hex characters) in .env.example; Garage initialization imports these values. |
Do not apply a first-instance password change to an existing database and expect account rotation. Use the account's password-change flow instead.
1. Download the complete bundle
Use Download in this file browser and extract the archive into an empty directory. Keep
all files together, including the hidden .env.example, SQL, nginx and Zitadel YAML files.
Run the following commands in that directory, not in a source checkout's deploy/ directory.
.env.example
# StackWeaver Docker Compose - Environment Variables
# Copy this file to .env before starting: cp .env.example .env
#
# On first startup, the zitadel-init container auto-generates Zitadel
# credentials and appends them to this file. Do not edit those values.
# ── Database ─────────────────────────────────────────────────────────
POSTGRES_USER=iac
POSTGRES_PASSWORD=iac_password
POSTGRES_DB=iac_platform
# ── Object Storage (Garage S3-compatible) ────────────────────────────
STORAGE_ACCESS_KEY=GK000000000000000000000000
STORAGE_SECRET_KEY=abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789
# ── Encryption ───────────────────────────────────────────────────────
# REQUIRED. 32-byte (64 hex chars) AES-256 key for encrypting sensitive data
# at rest (Ansible credentials, variable values, etc.). Generate a unique key:
# openssl rand -hex 32
# The ansible-runner refuses to start with a missing or all-zero key, and
# docker compose will refuse to bring the stack up until you set a real value.
ENCRYPTION_KEY=
# ── Zitadel ──────────────────────────────────────────────────────────
# Masterkey for Zitadel encryption (must be exactly 32 characters)
ZITADEL_MASTERKEY=LfcvxoyK4yAwsyp5WYeA3siK61yl7064
# Admin password for the default Zitadel admin user ([email protected])
ZITADEL_ADMIN_PASSWORD=Password1!
# Zitadel issuer URL (change if using a custom domain)
ZITADEL_ISSUER=http://localhost:8080
# The domain Zitadel serves (ExternalDomain in zitadel-defaults.yaml, without scheme or port).
# The API names the Zitadel instance with it, so it must be right before the first start. If you
# change the external domain, change it here and in ZITADEL_ISSUER together.
ZITADEL_EXTERNAL_HOST=localhost
# ── Frontend ─────────────────────────────────────────────────────────
# The frontend-config container writes this into env.js, the SPA's runtime configuration,
# each time the stack is brought up. Change it if using a custom domain or reverse proxy.
VITE_API_URL=http://localhost:5173/api/v2
# ── Application URL ─────────────────────────────────────────────────
# Public URL of the StackWeaver frontend SPA. Browser sign-in returns to
# this URL followed by /auth/callback, which must be registered in
# zitadel-init.yaml, and the API trusts this origin for browser requests.
# Anything other than http://localhost needs HTTPS: the session cookie is
# Secure. Also used for IdP success/failure URLs and GitHub App callbacks.
STACKWEAVER_APP_URL=http://localhost:5173
# ── Browser sessions (optional) ─────────────────────────────────────
# Go durations. Unset means signed out after 1h without activity or 12h
# after sign-in, and checked against Zitadel every 5m.
# STACKWEAVER_SESSION_IDLE_TIMEOUT=1h
# STACKWEAVER_SESSION_MAX_LIFETIME=12h
# STACKWEAVER_SESSION_LIVENESS_INTERVAL=5m
# ── GitHub App (optional) ────────────────────────────────────────────
# GITHUB_APP_ID=
# GITHUB_APP_NAME=
# The webhook secret belongs in vcs.env, not here.
# ── Custom Domain (optional) ────────────────────────────────────────
# ZITADEL_CUSTOM_DOMAINS=auth.example.com
# Only when the SPA is served from an origin other than STACKWEAVER_APP_URL:
# CORS_EXTRA_ORIGINS=https://app.example.com
# ── Log Level ────────────────────────────────────────────────────────
LOG_LEVEL=info
# ─────────────────────────────────────────────────────────────────────
# Everything below this line is auto-generated by zitadel-init.
# Do not edit manually.
# ─────────────────────────────────────────────────────────────────────
docker-compose.public-site.yml
# Optional public-indexing override. Keep the same project name as the base install.
services:
frontend:
volumes:
- ./public-site-settings.conf:/etc/nginx/public-site-settings.conf:ro
docker-compose.yml
# StackWeaver - Production Docker Compose
# Uses pre-built container images from GHCR (no source code required).
#
# Quick start (the same commands as the guide, README.md one level up):
# cp .env.example .env
# cp sso.env.example sso.env
# cp vcs.env.example vcs.env
# cp oidc.env.example oidc.env
# sed -i.bak "s|^OIDC_SIGNING_KEY=.*|OIDC_SIGNING_KEY=$(openssl genrsa 2048 | openssl base64 -A)|" oidc.env
# sed -i.bak "s/^ENCRYPTION_KEY=.*/ENCRYPTION_KEY=$(openssl rand -hex 32)/" .env
# rm -f oidc.env.bak .env.bak
# docker compose up -d
# Then follow zitadel-init (docker compose logs -f zitadel-init) and open http://localhost:5173.
# Single user-defined bridge network. Services reach each other by Docker DNS
# service name (postgres, redis, garage, zitadel, api, ...), and only the three
# host-facing ports are published, so the stack behaves the same on Linux, macOS
# and Windows and no internal service is exposed on the host interface.
networks:
stackweaver:
driver: bridge
volumes:
postgres_data:
garage_data:
garage_meta:
runner-workspaces:
ansible-workspaces: # isolated from the OpenTofu runner so neither can read the other's staged credentials
zitadel-pat:
services:
# ── Infrastructure ──────────────────────────────────────────────────
postgres:
image: postgres:17
container_name: postgres
restart: unless-stopped
environment:
POSTGRES_USER: ${POSTGRES_USER:-iac}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-iac_password}
POSTGRES_DB: ${POSTGRES_DB:-iac_platform}
volumes:
- postgres_data:/var/lib/postgresql/data
- ./init-postgres.sql:/docker-entrypoint-initdb.d/init-postgres.sql:ro
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-iac} -d ${POSTGRES_DB:-iac_platform}"]
interval: 10s
timeout: 5s
retries: 5
networks: [stackweaver]
redis:
image: redis:8-alpine
container_name: redis
restart: unless-stopped
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
networks: [stackweaver]
garage:
image: dxflrs/garage:v2.4.1
container_name: garage
restart: unless-stopped
volumes:
- garage_data:/var/lib/garage/data
- garage_meta:/var/lib/garage/meta
- ./garage.toml:/etc/garage.toml:ro
networks: [stackweaver]
garage-init:
image: alpine:3.24
container_name: garage-init
depends_on:
garage:
condition: service_started
restart: "no"
networks: [stackweaver]
volumes:
- ./garage.toml:/etc/garage.toml:ro
- garage_meta:/var/lib/garage/meta:ro
entrypoint: ["/bin/sh", "-c"]
command:
- |
set -e
KEY_ID="$${STORAGE_ACCESS_KEY}"
KEY_SECRET="$${STORAGE_SECRET_KEY}"
# Download Garage CLI binary
ARCH=$$(uname -m)
wget -qO /usr/local/bin/garage \
"https://garagehq.deuxfleurs.fr/_releases/v2.4.1/$${ARCH}-unknown-linux-musl/garage"
chmod +x /usr/local/bin/garage
# Wait for Garage RPC
echo "Waiting for Garage..."
until garage status 2>/dev/null | grep -qi "Hostname"; do sleep 1; done
echo "Garage is ready"
# Get node ID
NODE_ID=$$(garage status 2>/dev/null | grep -v "^=\|^ID" | head -1 | awk '{print $$1}')
# Assign layout if not yet configured
CURRENT_VER=$$(garage layout show 2>/dev/null \
| grep "Current cluster layout version:" | awk '{print $$NF}' || echo "0")
if [ "$${CURRENT_VER}" = "0" ]; then
garage layout assign -z dc1 -c 1G "$${NODE_ID}"
garage layout apply --version 1
fi
# Import key, create bucket, grant access (all idempotent)
garage key import --yes -n stackweaver "$${KEY_ID}" "$${KEY_SECRET}" 2>/dev/null || true
garage bucket create stackweaver 2>/dev/null || true
garage bucket allow --read --write --owner stackweaver --key "$${KEY_ID}" 2>/dev/null || true
echo "Garage initialized successfully"
environment:
- STORAGE_ACCESS_KEY=${STORAGE_ACCESS_KEY:-GK000000000000000000000000}
- STORAGE_SECRET_KEY=${STORAGE_SECRET_KEY:-abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789}
# ── Authentication ──────────────────────────────────────────────────
zitadel:
image: ghcr.io/zitadel/zitadel:latest
container_name: zitadel
restart: unless-stopped
# tlsMode decides the scheme of the issuer Zitadel puts in every token. `disabled` serves
# plain HTTP, which is right for http://localhost:8080 and matches ZITADEL_ISSUER, so the API
# accepts the tokens. Behind a reverse proxy that terminates TLS, set ZITADEL_TLS_MODE=external
# in .env (together with ExternalSecure in zitadel-defaults.yaml and an https ZITADEL_ISSUER).
command: >
start-from-init
--config /etc/zitadel/defaults.yaml
--steps /etc/zitadel/init-steps.yaml
--masterkey "${ZITADEL_MASTERKEY:-LfcvxoyK4yAwsyp5WYeA3siK61yl7064}"
--tlsMode ${ZITADEL_TLS_MODE:-disabled}
env_file:
- ./.env
environment:
ZITADEL_SERVICE_USER_TOKEN: ${ZITADEL_LOGIN_SERVICE_USER_TOKEN:-}
ZITADEL_DATABASE_POSTGRES_HOST: postgres
# The login UI is the Stackweaver SPA, served from
# the frontend container on port 5173. Zitadel's LoginV2.BaseURI
# points at the SPA's /login route; LoginV2 origin must match the
# SPA's origin for CORS/CSP to line up.
ZITADEL_LOGIN_UI_BASE_URL: ${ZITADEL_LOGIN_UI_BASE_URL:-http://localhost:5173/login}
ZITADEL_LOGIN_UI_ORIGIN: ${ZITADEL_LOGIN_UI_ORIGIN:-http://localhost:5173}
volumes:
- ./zitadel-defaults.yaml:/etc/zitadel/defaults.yaml:ro
- ./zitadel-init-steps.yaml:/etc/zitadel/init-steps.yaml:ro
- zitadel-pat:/pat
user: "0"
depends_on:
postgres:
condition: service_healthy
networks: [stackweaver]
ports:
- "8080:8080" # published: the browser hits the OIDC issuer/authorize/discovery endpoints
# No separate login UI container runs: the login pages are part of the
# Stackweaver SPA, and the auth proxy in the API container (`/auth/*`
# routes) handles all Zitadel session API calls and OIDC endpoint
# forwarding. zitadel-init below provisions the `login-ui-service`
# Zitadel machine user and its PAT, which the auth proxy, the TOTP
# service and the Zitadel webhook handler in the API container consume
# through the env var ZITADEL_LOGIN_SERVICE_USER_TOKEN.
# zitadel-init also configures Zitadel's email provider and login policy from the SMTP_* and
# STACKWEAVER_SELF_REGISTRATION values in sso.env. The API and zitadel-init both run in email
# mode (STACKWEAVER_NOTIFICATION_MODE unset): auth codes are only ever delivered by Zitadel.
zitadel-init:
image: ghcr.io/vhco-pro/stackweaver-zitadel-init:latest
container_name: zitadel-init
user: "${UID:-1000}:${GID:-1000}"
env_file:
- ./.env
- ./sso.env
environment:
# ZITADEL_ISSUER is the OIDC issuer URL the SPA points at. It has
# no path suffix: the Stackweaver SPA serves login at `/login/*`.
ZITADEL_ISSUER: ${ZITADEL_ISSUER:-http://localhost:8080}
ZITADEL_INTERNAL_ADDR: zitadel:8080
ZITADEL_DOMAIN: ${ZITADEL_EXTERNAL_HOST:-localhost}
PROJECT_ROOT: /config
ZITADEL_ADMIN_USERNAME: [email protected]
ZITADEL_ADMIN_PASSWORD: ${ZITADEL_ADMIN_PASSWORD:-Password1!}
ZITADEL_PAT_PATH: /pat/admin.pat
ZITADEL_PAT: ${ZITADEL_PAT:-}
ZITADEL_CUSTOM_DOMAINS: ${ZITADEL_CUSTOM_DOMAINS:-}
volumes:
- .:/config/deploy
- zitadel-pat:/pat:ro
depends_on:
zitadel:
condition: service_started
restart: "no"
networks: [stackweaver]
# ── Application ─────────────────────────────────────────────────────
api:
image: ghcr.io/vhco-pro/stackweaver-api:latest
container_name: api
restart: unless-stopped
env_file:
- ./.env
- ./sso.env
- ./vcs.env
- ./oidc.env
environment:
# No CONFIG_PATH is set: everything the API needs is supplied below, so it runs in the same
# environment-only mode as the Helm chart. Pointing at the image's baked config.yaml would
# tie this file to wherever that image happens to put it, and an explicitly-set path to a
# missing file is fatal by design.
LOG_LEVEL: ${LOG_LEVEL:-info}
# Compose fixes this container's environment when `docker compose up` starts, before
# zitadel-init has generated the client IDs and the login service token. The API therefore
# reads .env itself when it starts, by which time zitadel-init has finished (see depends_on),
# and the values in the file win. That is what makes a single `docker compose up -d` enough.
STACKWEAVER_ENV_FILE: /config/stackweaver/.env
# Database. These must track the postgres service's own POSTGRES_* values - with no config
# file in play, environment variables are the API's only source for them.
DATABASE_HOST: postgres
DATABASE_USER: ${POSTGRES_USER:-iac}
DATABASE_PASSWORD: ${POSTGRES_PASSWORD:-iac_password}
DATABASE_NAME: ${POSTGRES_DB:-iac_platform}
REDIS_HOST: redis
ZITADEL_ISSUER: ${ZITADEL_ISSUER:-http://localhost:8080}
ZITADEL_API_CLIENT_ID: ${ZITADEL_API_CLIENT_ID:-}
ZITADEL_API_CLIENT_SECRET: ${ZITADEL_API_CLIENT_SECRET:-}
ZITADEL_LOGIN_SERVICE_USER_TOKEN: ${ZITADEL_LOGIN_SERVICE_USER_TOKEN:-}
ZITADEL_BFF_CLIENT_ID: ${ZITADEL_BFF_CLIENT_ID:-}
ZITADEL_BFF_CLIENT_SECRET: ${ZITADEL_BFF_CLIENT_SECRET:-}
ZITADEL_INTERNAL_ADDR: zitadel:8080
# The bootstrap admin's email, the same literal zitadel-init receives: the API declares it
# the first platform administrator unless STACKWEAVER_PLATFORM_ADMINS is set in sso.env.
ZITADEL_ADMIN_USERNAME: [email protected]
# The auth proxy reaches Zitadel as zitadel:8080, a Host no instance domain matches, so it
# names the instance with this header. Compose assembles it here from ZITADEL_EXTERNAL_HOST,
# so that key must hold your ExternalDomain in .env before the first start (.env.example
# ships localhost); it also makes IdP callbacks use that domain.
CUSTOM_REQUEST_HEADERS: "x-zitadel-instance-host:${ZITADEL_EXTERNAL_HOST:-}"
CORS_EXTRA_ORIGINS: ${CORS_EXTRA_ORIGINS:-}
# Browser session lifetimes (Go durations); unset means 1h idle, 12h in total, checked
# against Zitadel every 5m.
STACKWEAVER_SESSION_IDLE_TIMEOUT: ${STACKWEAVER_SESSION_IDLE_TIMEOUT:-}
STACKWEAVER_SESSION_MAX_LIFETIME: ${STACKWEAVER_SESSION_MAX_LIFETIME:-}
STACKWEAVER_SESSION_LIVENESS_INTERVAL: ${STACKWEAVER_SESSION_LIVENESS_INTERVAL:-}
ZITADEL_WEBHOOK_IDP_SYNC_KEY: ${ZITADEL_WEBHOOK_IDP_SYNC_KEY:-}
ZITADEL_WEBHOOK_COMPLEMENT_TOKEN_KEY: ${ZITADEL_WEBHOOK_COMPLEMENT_TOKEN_KEY:-}
STACKWEAVER_APP_URL: ${STACKWEAVER_APP_URL:-http://localhost:5173}
ANSIBLE_ENCRYPTION_KEY: ${ENCRYPTION_KEY:-00000000000000000000000000000000}
# Storage (Garage S3-compatible)
STORAGE_BACKEND: s3
STORAGE_ENDPOINT: garage:3900
STORAGE_ACCESS_KEY: ${STORAGE_ACCESS_KEY:-GK000000000000000000000000}
STORAGE_SECRET_KEY: ${STORAGE_SECRET_KEY:-abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789}
STORAGE_BUCKET: stackweaver
STORAGE_REGION: garage
STORAGE_USE_SSL: "false"
STORAGE_FORCE_PATH_STYLE: "true"
# GitHub App (optional - uncomment and configure to enable)
# GITHUB_APP_ID: ${GITHUB_APP_ID:-}
# GITHUB_APP_NAME: ${GITHUB_APP_NAME:-}
# GITHUB_APP_PRIVATE_KEY_PATH: /etc/github-app-private-key.pem
# The webhook secret is read from vcs.env; do not set it here.
volumes:
# Read at startup through STACKWEAVER_ENV_FILE (see above). The directory is mounted, not
# the file: an editor that saves by replacing .env would leave a single-file mount pointing
# at the old copy.
- .:/config/stackweaver:ro
# Uncomment to mount GitHub App private key:
# - ./github-app-private-key.pem:/etc/github-app-private-key.pem:ro
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
zitadel:
condition: service_started
zitadel-init:
condition: service_completed_successfully
garage-init:
condition: service_completed_successfully
networks: [stackweaver]
ports:
- "8022:8022" # published for CLI clients and webhooks; the browser reaches the API through the frontend
# Writes env.js, the frontend's runtime configuration, once zitadel-init has generated the
# client ID. The frontend image has no shell, so it cannot do this itself.
frontend-config:
image: alpine:3.24
container_name: frontend-config
# Same user as zitadel-init, so env.js belongs to you like .env does.
user: "${UID:-1000}:${GID:-1000}"
depends_on:
zitadel-init:
condition: service_completed_successfully
restart: "no"
network_mode: none
environment:
VITE_API_URL: ${VITE_API_URL:-http://localhost:5173/api/v2}
volumes:
- .:/work
entrypoint: ["/bin/sh", "-c"]
command:
- |
set -eu
# Docker leaves a directory here if the frontend was ever started before this file
# existed; it has to go before the file can be written.
if [ -d /work/env.js ]; then rmdir /work/env.js; fi
# Written in place (no rename), so a running frontend's mount keeps pointing at it.
cat > /work/env.js << EOF
window.__STACKWEAVER__ = {
VITE_API_URL: "$$VITE_API_URL",
};
EOF
chmod 644 /work/env.js
echo "wrote env.js"
frontend:
image: ghcr.io/vhco-pro/stackweaver-frontend:latest
container_name: frontend
restart: unless-stopped
volumes:
# Runtime configuration, written by frontend-config. Short syntax on purpose: it is
# resolved when this container starts, after frontend-config has run.
- ./env.js:/usr/share/nginx/html/env.js:ro
# Serves the API under this origin; see the file for why.
- ./frontend-proxy.conf:/etc/nginx/stackweaver.d/api-proxy.conf:ro
depends_on:
frontend-config:
condition: service_completed_successfully
api:
condition: service_started
networks: [stackweaver]
ports:
- "5173:8080" # published: this is the app itself (the image listens on 8080)
orchestrator:
image: ghcr.io/vhco-pro/stackweaver-orchestrator:latest
container_name: orchestrator
restart: unless-stopped
env_file:
- ./.env
- ./vcs.env
environment:
# Neither binary reads a config file, so no CONFIG_PATH here; the database credentials
# below must track the postgres service's own POSTGRES_* values.
DATABASE_HOST: postgres
DATABASE_USER: ${POSTGRES_USER:-iac}
DATABASE_PASSWORD: ${POSTGRES_PASSWORD:-iac_password}
DATABASE_NAME: ${POSTGRES_DB:-iac_platform}
REDIS_HOST: redis
REDIS_PORT: "6379"
REDIS_PASSWORD: ""
REDIS_DB: "0"
# GitHub App (optional - uncomment and configure to enable)
# GITHUB_APP_ID: ${GITHUB_APP_ID:-}
# GITHUB_APP_NAME: ${GITHUB_APP_NAME:-}
# GITHUB_APP_PRIVATE_KEY_PATH: /etc/github-app-private-key.pem
# Uncomment to mount GitHub App private key:
# volumes:
# - ./github-app-private-key.pem:/etc/github-app-private-key.pem:ro
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
api:
condition: service_started
networks: [stackweaver]
runner:
image: ghcr.io/vhco-pro/stackweaver-opentofu-runner:latest
container_name: runner
restart: unless-stopped
env_file:
- ./.env
- ./oidc.env
environment:
# Neither binary reads a config file, so no CONFIG_PATH here; the database credentials
# below must track the postgres service's own POSTGRES_* values.
DATABASE_HOST: postgres
DATABASE_USER: ${POSTGRES_USER:-iac}
DATABASE_PASSWORD: ${POSTGRES_PASSWORD:-iac_password}
DATABASE_NAME: ${POSTGRES_DB:-iac_platform}
REDIS_HOST: redis
REDIS_PORT: "6379"
REDIS_PASSWORD: ""
REDIS_DB: "0"
# Storage (Garage S3-compatible)
STORAGE_BACKEND: s3
STORAGE_ENDPOINT: garage:3900
STORAGE_ACCESS_KEY: ${STORAGE_ACCESS_KEY:-GK000000000000000000000000}
STORAGE_SECRET_KEY: ${STORAGE_SECRET_KEY:-abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789}
STORAGE_BUCKET: stackweaver
STORAGE_REGION: garage
STORAGE_USE_SSL: "false"
STORAGE_FORCE_PATH_STYLE: "true"
ENCRYPTION_KEY: ${ENCRYPTION_KEY:-00000000000000000000000000000000}
volumes:
- runner-workspaces:/home/stackweaver/workspaces
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
garage-init:
condition: service_completed_successfully
orchestrator:
condition: service_started
networks: [stackweaver]
ansible-runner:
image: ghcr.io/vhco-pro/stackweaver-ansible-runner:latest
container_name: ansible-runner
restart: unless-stopped
env_file:
- ./.env
- ./vcs.env
- ./oidc.env
environment:
REDIS_HOST: redis
REDIS_PORT: "6379"
REDIS_PASSWORD: ""
DATABASE_HOST: postgres
DATABASE_PORT: "5432"
DATABASE_USER: ${POSTGRES_USER:-iac}
DATABASE_PASSWORD: ${POSTGRES_PASSWORD:-iac_password}
DATABASE_NAME: ${POSTGRES_DB:-iac_platform}
# Storage (Garage S3-compatible)
STORAGE_BACKEND: s3
STORAGE_ENDPOINT: garage:3900
STORAGE_ACCESS_KEY: ${STORAGE_ACCESS_KEY:-GK000000000000000000000000}
STORAGE_SECRET_KEY: ${STORAGE_SECRET_KEY:-abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789}
STORAGE_BUCKET: stackweaver
STORAGE_REGION: garage
STORAGE_USE_SSL: "false"
STORAGE_FORCE_PATH_STYLE: "true"
# Required: the runner refuses to start with a missing or all-zero key.
# Compose fails fast here until ENCRYPTION_KEY is set in .env.
ANSIBLE_ENCRYPTION_KEY: "${ENCRYPTION_KEY:?set ENCRYPTION_KEY in .env - generate one with openssl rand -hex 32}"
WORKSPACES_DIR: /home/stackweaver/workspaces
# Clean each job's scratch dir (SSH keys, vault passwords, inventory
# secrets) on completion. Set to "true" only for short-lived debugging.
ANSIBLE_RUNNER_KEEP_WORKSPACE: "false"
volumes:
- ansible-workspaces:/home/stackweaver/workspaces
# Defense-in-depth: the runner needs no Linux capabilities (Ansible privilege
# escalation happens on the target host over SSH, not in this container), and
# a PID cap blunts fork-bomb / runaway-playbook resource exhaustion.
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
pids_limit: 512
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
garage-init:
condition: service_completed_successfully
api:
condition: service_started
networks: [stackweaver]
frontend-proxy.conf
# Serves the StackWeaver API under the frontend's own origin, so the browser only ever talks to
# one address (http://localhost:5173 by default).
#
# The frontend's Content-Security-Policy allows calls to its own origin only, which is what a
# Kubernetes ingress gives it by routing the app and the API under one hostname. This file does
# the same job for Docker Compose: the frontend's nginx includes it at server scope (it is
# mounted into /etc/nginx/stackweaver.d/) and forwards the API's paths to the api service.
# The routes mirror the chart's ingress.
# Docker's embedded DNS. Resolving at request time keeps the proxy working after the api
# container is recreated with a new address.
resolver 127.0.0.11 valid=10s ipv6=off;
set $stackweaver_api http://api:8022;
proxy_http_version 1.1;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $http_host;
# Run logs are streamed; do not hold them back.
proxy_buffering off;
proxy_read_timeout 300s;
# `^~` keeps the image's static-asset rule from claiming an API path that ends in .js or .json.
location ^~ /api/v2 {
# Configuration and state uploads are larger than the frontend's own limit.
client_max_body_size 100m;
proxy_pass $stackweaver_api;
}
# The two auth routes that belong to the single-page app, not to the API.
location ^~ /auth/callback {
set $sw_noindex "noindex, nofollow";
try_files /app-shell.html =404;
}
location ^~ /auth/login {
set $sw_noindex "noindex, nofollow";
try_files /app-shell.html =404;
}
location ^~ /auth {
proxy_pass $stackweaver_api;
}
location = /.well-known/terraform.json {
proxy_pass $stackweaver_api;
}
location = /.well-known/openid-configuration {
proxy_pass $stackweaver_api;
}
location = /.well-known/jwks {
proxy_pass $stackweaver_api;
}
location ^~ /v1/modules {
proxy_pass $stackweaver_api;
}
garage.toml
metadata_dir = "/var/lib/garage/meta"
data_dir = "/var/lib/garage/data"
db_engine = "sqlite"
replication_factor = 1
rpc_bind_addr = "[::]:3901"
rpc_public_addr = "garage:3901"
rpc_secret = "0000000000000000000000000000000000000000000000000000000000000000"
[s3_api]
s3_region = "garage"
api_bind_addr = "[::]:3900"
root_domain = ".s3.garage.localhost"
[s3_web]
bind_addr = "[::]:3902"
root_domain = ".web.garage.localhost"
index = "index.html"
[k2v_api]
api_bind_addr = "[::]:3904"
[admin]
api_bind_addr = "[::]:3903"
admin_token = "stackweaver-dev-admin-token"
metrics_token = "stackweaver-dev-metrics-token"
init-postgres.sql
-- Enable UUID extension for uuid_generate_v4() function
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
oidc.env.example
# OIDC workload identity signing key. Read by the api and runner services.
# Copy to oidc.env before starting the stack: cp oidc.env.example oidc.env
#
# OIDC_SIGNING_KEY is required: the API refuses to start without it, and the
# runners start with workload identity disabled. OIDC_ISSUER_URL may be left
# empty. See the OIDC configuration guide for the full setup.
# Base64-encoded RSA-2048 private key used to sign workload identity tokens.
# The API and the runners MUST share the same key: it is the key the runner
# signs with and the key the API publishes in its JWKS endpoint.
#
# Generate with: openssl genrsa 2048 | base64 -w 0
OIDC_SIGNING_KEY=
# Public issuer URL embedded in issued tokens and served in the discovery
# document. Must exactly match the issuer configured on the federated identity
# credential at your cloud provider. Defaults to API_URL when unset.
OIDC_ISSUER_URL=
public-site-settings.conf.example
# Copy to public-site-settings.conf and use a domain you control.
# This example publishes at the root path and explicitly opts into indexing.
set $sw_site_url "https://stackweaver.example.com";
set $sw_base_path "";
set $sw_noindex "";
set $sw_sitemap_line "Sitemap: https://stackweaver.example.com/sitemap.xml";
sso.env.example
# SSO / external IdP credentials. Read by the zitadel-init and api services.
# Copy to sso.env before starting the stack: cp sso.env.example sso.env
#
# Every value may be left empty. An empty file is valid and simply means no
# external identity provider is configured - local Zitadel accounts still work.
# See the SSO user guides for provider-specific setup.
# Azure AD / Entra ID. AZURE_AD_TENANT_ID is required: without it any Microsoft
# account could sign in and create an account. To allow that deliberately, set
# AZURE_AD_ALLOW_MULTI_TENANT=true instead.
AZURE_AD_CLIENT_ID=
AZURE_AD_CLIENT_SECRET=
AZURE_AD_TENANT_ID=
AZURE_AD_ALLOW_MULTI_TENANT=false
# Generic OIDC (Okta, Cognito, etc.)
OIDC_IDP_NAME=
OIDC_IDP_ISSUER=
OIDC_IDP_CLIENT_ID=
OIDC_IDP_CLIENT_SECRET=
# Identity mail (SMTP). zitadel-init configures this as Zitadel's email provider,
# which sends verification, password-reset and email login codes. Without
# SMTP_HOST, self-registration, self-service password reset and email codes are
# switched off. Any transactional provider with SMTP submission works (port 587
# STARTTLS or 465 TLS); the sending domain needs SPF, DKIM and DMARC records.
SMTP_HOST=
SMTP_USER=
SMTP_PASSWORD=
SMTP_SENDER_ADDRESS=
SMTP_SENDER_NAME=Stackweaver
SMTP_TLS=true
# Let anyone create an account. Takes effect only when SMTP_HOST is set, because
# new accounts must verify their email.
STACKWEAVER_SELF_REGISTRATION=false
# Team sync (API). ENABLE_OIDC_TEAM_SYNC maps IdP groups onto Stackweaver teams;
# OIDC_REMOVE_FROM_NON_SSO_TEAMS also strips memberships the IdP does not grant.
ENABLE_OIDC_TEAM_SYNC=true
OIDC_REMOVE_FROM_NON_SSO_TEAMS=false
vcs.env.example
# VCS provider credentials. Read by the api service.
# Copy to vcs.env before starting the stack: cp vcs.env.example vcs.env
#
# Every value may be left empty. An empty file is valid and simply means no VCS
# provider is connected - workspaces can still be driven through the API, the CLI
# and the Terraform provider. See the VCS user guides for provider setup.
# GITHUB_WEBHOOK_SECRET - required for GitHub webhooks. Incoming deliveries are
# authenticated by HMAC-SHA256 of the body against this value, and the endpoint
# rejects every delivery with 401 while it is unset. Use the same value in the
# GitHub App's webhook settings.
#GITHUB_WEBHOOK_SECRET=
# GitHub App OAuth client - REQUIRED to connect a GitHub App installation. Connecting
# asks the person to authorize the App at GitHub, which confirms they can access the
# installation being connected. Copy the "Client ID" from the App's settings page and
# generate a client secret there, and register <your frontend URL>/vcs/github/authorized
# as a Callback URL of the App (for example
# http://localhost:5173/vcs/github/authorized). Escape every `$` in the secret as `$$`.
#GITHUB_APP_CLIENT_ID=
#GITHUB_APP_CLIENT_SECRET=
# Public base URL of this Stackweaver API, used to register webhooks automatically.
# Must be reachable from the VCS provider, so a public hostname or tunnel URL -
# not localhost.
# Example: STACKWEAVER_WEBHOOK_BASE_URL=https://stackweaver.example.com:8022
#STACKWEAVER_WEBHOOK_BASE_URL=
# Azure DevOps - Microsoft Entra ID OAuth2.
# Register at https://portal.azure.com -> Microsoft Entra ID -> App registrations.
#AZURE_DEVOPS_CLIENT_ID= # Application (client) ID from the Entra app registration
#AZURE_DEVOPS_CLIENT_SECRET= # Client secret value (Certificates & secrets tab)
#AZURE_DEVOPS_REDIRECT_URI=http://localhost:5173/vcs/azure-devops/callback
#AZURE_DEVOPS_TENANT_ID=common # "common" for multi-tenant, or your Entra tenant ID/domain
#AZURE_DEVOPS_WEBHOOK_SECRET= # required for ADO webhooks - the Basic-auth password each
# service-hook subscription sends; deliveries without it are rejected (401)
zitadel-defaults.yaml
# https://github.com/zitadel/zitadel/blob/main/cmd/defaults.yaml
# Change these values when using a custom domain with a reverse proxy.
ExternalDomain: localhost
ExternalPort: 8080
ExternalSecure: false
TLS:
Enabled: false
Database:
Postgres:
Host: localhost
Port: 5432
Database: zitadel
Admin:
Username: iac
Password: iac_password
SSL:
Mode: disable
User:
Username: iac
Password: iac_password
SSL:
Mode: disable
DefaultInstance:
LoginPolicy:
AllowUsernamePassword: true
AllowExternalIDPs: true
# A new instance starts with registration closed; zitadel-init opens it only when mail can
# be delivered and STACKWEAVER_SELF_REGISTRATION=true. Applies at instance creation only.
AllowRegister: false
Org:
LoginPolicy:
AllowUsernamePassword: true
Features:
LoginDefaultOrg: true
LoginV2:
Required: true
BaseURI: http://localhost:5173/login # point to Stackweaver SPA login
# OIDC Settings - Configure token lifetimes and format
# Note: Token format (JWT vs opaque) is typically controlled at the application level
# but these settings apply to all OIDC applications in the instance
OIDCSettings:
AccessTokenLifetime: 1h
IdTokenLifetime: 12h
# 720h are 30 days
RefreshTokenIdleExpiration: 720h
# 2160h are 90 days
RefreshTokenExpiration: 2160h
InternalAuthZ:
# Configure the RolePermissionMappings by environment variable using JSON notation:
# ZITADEL_INTERNALAUTHZ_ROLEPERMISSIONMAPPINGS='[{"role": "IAM_OWNER", "permissions": ["iam.write"]}, {"role": "ORG_OWNER", "permissions": ["org.write"]}]'
# Beware that if you configure the RolePermissionMappings by environment variable, all the default RolePermissionMappings are lost.
#
# Warning: RolePermissionMappings are synchronized to the database.
# Changes here will only be applied after running `zitadel setup` or `zitadel start-from-setup`.
RolePermissionMappings:
- Role: "SYSTEM_OWNER"
Permissions:
- "system.instance.read"
- "system.instance.write"
- "system.instance.delete"
- "system.domain.read"
- "system.domain.write"
- "system.domain.delete"
- "system.debug.read"
- "system.debug.write"
- "system.debug.delete"
- "system.feature.read"
- "system.feature.write"
- "system.feature.delete"
- "system.limits.write"
- "system.limits.delete"
- "system.quota.write"
- "system.quota.delete"
- "system.iam.member.read"
- Role: "SYSTEM_OWNER_VIEWER"
Permissions:
- "system.instance.read"
- "system.domain.read"
- "system.debug.read"
- "system.feature.read"
- "system.iam.member.read"
- Role: "IAM_OWNER"
Permissions:
- "iam.read"
- "iam.write"
- "iam.policy.read"
- "iam.policy.write"
- "iam.policy.delete"
- "iam.member.read"
- "iam.member.write"
- "iam.member.delete"
- "iam.idp.read"
- "iam.idp.write"
- "iam.idp.delete"
- "iam.action.read"
- "iam.action.write"
- "iam.action.delete"
- "iam.flow.read"
- "iam.flow.write"
- "iam.flow.delete"
- "iam.feature.read"
- "iam.feature.write"
- "iam.feature.delete"
- "iam.restrictions.read"
- "iam.restrictions.write"
- "iam.web_key.write"
- "iam.web_key.delete"
- "iam.web_key.read"
- "iam.debug.write"
- "iam.debug.read"
- "org.read"
- "org.global.read"
- "org.create"
- "org.write"
- "org.delete"
- "org.member.read"
- "org.member.write"
- "org.member.delete"
- "org.idp.read"
- "org.idp.write"
- "org.idp.delete"
- "org.action.read"
- "org.action.write"
- "org.action.delete"
- "org.flow.read"
- "org.flow.write"
- "org.flow.delete"
- "org.feature.read"
- "org.feature.write"
- "org.feature.delete"
- "user.read"
- "user.global.read"
- "user.write"
- "user.delete"
- "user.grant.read"
- "user.grant.write"
- "user.grant.delete"
- "user.membership.read"
- "user.credential.write"
- "user.passkey.write"
- "user.feature.read"
- "user.feature.write"
- "user.feature.delete"
- "policy.read"
- "policy.write"
- "policy.delete"
- "project.read"
- "project.create"
- "project.write"
- "project.delete"
- "project.member.read"
- "project.member.write"
- "project.member.delete"
- "project.role.read"
- "project.role.write"
- "project.role.delete"
- "project.app.read"
- "project.app.write"
- "project.app.delete"
- "project.grant.read"
- "project.grant.write"
- "project.grant.delete"
- "project.grant.member.read"
- "project.grant.member.write"
- "project.grant.member.delete"
- "events.read"
- "milestones.read"
- "session.read"
- "session.write"
- "session.delete"
- "action.target.read"
- "action.target.write"
- "action.target.delete"
- "action.execution.read"
- "action.execution.write"
- "userschema.read"
- "userschema.write"
- "userschema.delete"
- "group.create"
- "group.write"
- "group.read"
- "group.delete"
- "group.user.write"
- "group.user.read"
- "group.user.delete"
- Role: "IAM_OWNER_VIEWER"
Permissions:
- "iam.read"
- "iam.policy.read"
- "iam.member.read"
- "iam.idp.read"
- "iam.action.read"
- "iam.flow.read"
- "iam.restrictions.read"
- "iam.feature.read"
- "iam.web_key.read"
- "iam.debug.read"
- "org.read"
- "org.member.read"
- "org.idp.read"
- "org.action.read"
- "org.flow.read"
- "org.feature.read"
- "user.read"
- "user.global.read"
- "user.grant.read"
- "user.membership.read"
- "user.feature.read"
- "policy.read"
- "project.read"
- "project.member.read"
- "project.role.read"
- "project.app.read"
- "project.grant.read"
- "project.grant.member.read"
- "events.read"
- "milestones.read"
- "action.target.read"
- "action.execution.read"
- "userschema.read"
- "session.read"
- "group.read"
- "group.user.read"
- Role: "IAM_ORG_MANAGER"
Permissions:
- "org.read"
- "org.global.read"
- "org.create"
- "org.write"
- "org.delete"
- "org.member.read"
- "org.member.write"
- "org.member.delete"
- "org.idp.read"
- "org.idp.write"
- "org.idp.delete"
- "org.action.read"
- "org.action.write"
- "org.action.delete"
- "org.flow.read"
- "org.flow.write"
- "org.flow.delete"
- "org.feature.read"
- "org.feature.write"
- "org.feature.delete"
- "user.read"
- "user.global.read"
- "user.write"
- "user.delete"
- "user.grant.read"
- "user.grant.write"
- "user.grant.delete"
- "user.membership.read"
- "user.credential.write"
- "user.passkey.write"
- "user.feature.read"
- "user.feature.write"
- "user.feature.delete"
- "policy.read"
- "policy.write"
- "policy.delete"
- "project.read"
- "project.create"
- "project.write"
- "project.delete"
- "project.member.read"
- "project.member.write"
- "project.member.delete"
- "project.role.read"
- "project.role.write"
- "project.role.delete"
- "project.app.read"
- "project.app.write"
- "project.app.delete"
- "project.grant.read"
- "project.grant.write"
- "project.grant.delete"
- "project.grant.member.read"
- "project.grant.member.write"
- "project.grant.member.delete"
- "session.read"
- "session.delete"
- "group.create"
- "group.write"
- "group.read"
- "group.delete"
- "group.user.write"
- "group.user.read"
- "group.user.delete"
- Role: "IAM_USER_MANAGER"
Permissions:
- "org.read"
- "org.global.read"
- "org.member.read"
- "org.member.delete"
- "user.read"
- "user.global.read"
- "user.write"
- "user.delete"
- "user.grant.read"
- "user.grant.write"
- "user.grant.delete"
- "user.membership.read"
- "user.passkey.write"
- "user.feature.read"
- "user.feature.write"
- "user.feature.delete"
- "project.read"
- "project.member.read"
- "project.role.read"
- "project.app.read"
- "project.grant.read"
- "project.grant.write"
- "project.grant.delete"
- "project.grant.member.read"
- "session.read"
- "session.delete"
- "group.read"
- "group.user.read"
- Role: "IAM_ADMIN_IMPERSONATOR"
Permissions:
- "admin.impersonation"
- "impersonation"
- Role: "IAM_END_USER_IMPERSONATOR"
Permissions:
- "impersonation"
- Role: "ORG_OWNER"
Permissions:
- "org.read"
- "org.global.read"
- "org.write"
- "org.delete"
- "org.member.read"
- "org.member.write"
- "org.member.delete"
- "org.idp.read"
- "org.idp.write"
- "org.idp.delete"
- "org.action.read"
- "org.action.write"
- "org.action.delete"
- "org.flow.read"
- "org.flow.write"
- "org.flow.delete"
- "org.feature.read"
- "org.feature.write"
- "org.feature.delete"
- "user.read"
- "user.global.read"
- "user.write"
- "user.delete"
- "user.grant.read"
- "user.grant.write"
- "user.grant.delete"
- "user.membership.read"
- "user.credential.write"
- "user.passkey.write"
- "user.feature.read"
- "user.feature.write"
- "user.feature.delete"
- "policy.read"
- "policy.write"
- "policy.delete"
- "project.read"
- "project.create"
- "project.write"
- "project.delete"
- "project.member.read"
- "project.member.write"
- "project.member.delete"
- "project.role.read"
- "project.role.write"
- "project.role.delete"
- "project.app.read"
- "project.app.write"
- "project.grant.read"
- "project.grant.write"
- "project.grant.delete"
- "project.grant.member.read"
- "project.grant.member.write"
- "project.grant.member.delete"
- "session.read"
- "session.delete"
- "group.create"
- "group.write"
- "group.read"
- "group.delete"
- "group.user.write"
- "group.user.read"
- "group.user.delete"
- Role: "IAM_LOGIN_CLIENT"
Permissions:
- "iam.read"
- "iam.policy.read"
- "iam.member.read"
- "iam.member.write"
- "iam.idp.read"
- "iam.feature.read"
- "iam.restrictions.read"
- "org.read"
- "org.member.read"
- "org.member.write"
- "org.idp.read"
- "org.feature.read"
- "user.read"
- "user.write"
- "user.grant.read"
- "user.grant.write"
- "user.membership.read"
- "user.credential.write"
- "user.passkey.write"
- "user.feature.read"
- "policy.read"
- "project.read"
- "project.member.read"
- "project.member.write"
- "project.role.read"
- "project.app.read"
- "project.member.read"
- "project.member.write"
- "project.grant.read"
- "project.grant.member.read"
- "project.grant.member.write"
- "session.read"
- "session.write"
- "session.link"
- "session.delete"
- "userschema.read"
- "group.read"
- "group.user.read"
- Role: "ORG_USER_MANAGER"
Permissions:
- "org.read"
- "user.read"
- "user.global.read"
- "user.write"
- "user.delete"
- "user.grant.read"
- "user.grant.write"
- "user.grant.delete"
- "user.membership.read"
- "user.feature.read"
- "user.feature.write"
- "user.feature.delete"
- "policy.read"
- "project.read"
- "project.role.read"
- "session.read"
- "session.delete"
- "group.read"
- "group.user.read"
- Role: "ORG_OWNER_VIEWER"
Permissions:
- "org.read"
- "org.member.read"
- "org.idp.read"
- "org.action.read"
- "org.flow.read"
- "org.feature.read"
- "user.read"
- "user.global.read"
- "user.grant.read"
- "user.membership.read"
- "user.feature.read"
- "policy.read"
- "project.read"
- "project.member.read"
- "project.role.read"
- "project.app.read"
- "project.grant.read"
- "project.grant.member.read"
- "project.grant.user.grant.read"
- "group.read"
- "group.user.read"
- Role: "ORG_SETTINGS_MANAGER"
Permissions:
- "org.read"
- "org.write"
- "org.member.read"
- "org.idp.read"
- "org.idp.write"
- "org.idp.delete"
- "org.feature.read"
- "org.feature.write"
- "org.feature.delete"
- "policy.read"
- "policy.write"
- "policy.delete"
- Role: "ORG_USER_PERMISSION_EDITOR"
Permissions:
- "org.read"
- "org.member.read"
- "user.read"
- "user.global.read"
- "user.grant.read"
- "user.grant.write"
- "user.grant.delete"
- "policy.read"
- "project.read"
- "project.member.read"
- "project.role.read"
- "project.app.read"
- "project.grant.read"
- "project.grant.member.read"
- "group.read"
- "group.user.read"
- Role: "ORG_PROJECT_PERMISSION_EDITOR"
Permissions:
- "org.read"
- "org.member.read"
- "user.read"
- "user.global.read"
- "user.grant.read"
- "user.grant.write"
- "user.grant.delete"
- "policy.read"
- "project.read"
- "project.member.read"
- "project.role.read"
- "project.app.read"
- "project.grant.read"
- "project.grant.write"
- "project.grant.delete"
- "project.grant.member.read"
- Role: "ORG_PROJECT_CREATOR"
Permissions:
- "user.global.read"
- "policy.read"
- "project.read:self"
- "project.create"
- Role: "ORG_ADMIN_IMPERSONATOR"
Permissions:
- "admin.impersonation"
- "impersonation"
- Role: "ORG_END_USER_IMPERSONATOR"
Permissions:
- "impersonation"
- Role: "PROJECT_OWNER"
Permissions:
- "org.global.read"
- "policy.read"
- "project.read"
- "project.write"
- "project.delete"
- "project.member.read"
- "project.member.write"
- "project.member.delete"
- "project.role.read"
- "project.role.write"
- "project.role.delete"
- "project.app.read"
- "project.app.write"
- "project.app.delete"
- "project.grant.read"
- "project.grant.write"
- "project.grant.delete"
- "project.grant.member.read"
- "project.grant.member.write"
- "project.grant.member.delete"
- "user.read"
- "user.global.read"
- "user.grant.read"
- "user.grant.write"
- "user.grant.delete"
- "user.membership.read"
- Role: "PROJECT_OWNER_VIEWER"
Permissions:
- "policy.read"
- "project.read"
- "project.member.read"
- "project.role.read"
- "project.app.read"
- "project.grant.read"
- "project.grant.member.read"
- "user.read"
- "user.global.read"
- "user.grant.read"
- "user.membership.read"
- Role: "SELF_MANAGEMENT_GLOBAL"
Permissions:
- "org.create"
- "policy.read"
- "user.self.delete"
- Role: "ORG_USER_SELF_MANAGER"
Permissions:
- "policy.read"
- "user.self.delete"
- Role: "PROJECT_OWNER_GLOBAL"
Permissions:
- "org.global.read"
- "policy.read"
- "project.read"
- "project.write"
- "project.delete"
- "project.member.read"
- "project.member.write"
- "project.member.delete"
- "project.role.read"
- "project.role.write"
- "project.role.delete"
- "project.app.read"
- "project.app.write"
- "project.app.delete"
- "user.global.read"
- "user.grant.read"
- "user.grant.write"
- "user.grant.delete"
- "user.membership.read"
- Role: "PROJECT_OWNER_VIEWER_GLOBAL"
Permissions:
- "policy.read"
- "project.read"
- "project.member.read"
- "project.role.read"
- "project.app.read"
- "project.grant.read"
- "project.grant.member.read"
- "user.global.read"
- "user.grant.read"
- "user.membership.read"
- Role: "PROJECT_GRANT_OWNER"
Permissions:
- "policy.read"
- "org.global.read"
- "project.read"
- "project.grant.read"
- "project.grant.member.read"
- "project.grant.member.write"
- "project.grant.member.delete"
- "user.read"
- "user.global.read"
- "user.grant.read"
- "user.grant.write"
- "user.grant.delete"
- "user.membership.read"
- Role: "PROJECT_GRANT_OWNER_VIEWER"
Permissions:
- "policy.read"
- "project.read"
- "project.grant.read"
- "project.grant.member.read"
- "user.read"
- "user.global.read"
- "user.grant.read"
- "user.membership.read"
SystemAuthZ:
RolePermissionMappings:
- Role: "SYSTEM_OWNER"
Permissions:
- "system.instance.read"
- "system.instance.write"
- "system.instance.delete"
- "system.domain.read"
- "system.domain.write"
- "system.domain.delete"
- "system.debug.read"
- "system.debug.write"
- "system.debug.delete"
- "system.feature.read"
- "system.feature.write"
- "system.feature.delete"
- "system.limits.write"
- "system.limits.delete"
- "system.quota.write"
- "system.quota.delete"
- "system.iam.member.read"
- Role: "SYSTEM_OWNER_VIEWER"
Permissions:
- "system.instance.read"
- "system.domain.read"
- "system.debug.read"
- "system.feature.read"
- "system.iam.member.read"
- Role: "IAM_OWNER"
Permissions:
- "iam.read"
- "iam.write"
- "iam.policy.read"
- "iam.policy.write"
- "iam.policy.delete"
- "iam.member.read"
- "iam.member.write"
- "iam.member.delete"
- "iam.idp.read"
- "iam.idp.write"
- "iam.idp.delete"
- "iam.action.read"
- "iam.action.write"
- "iam.action.delete"
- "iam.flow.read"
- "iam.flow.write"
- "iam.flow.delete"
- "iam.feature.read"
- "iam.feature.write"
- "iam.feature.delete"
- "iam.restrictions.read"
- "iam.restrictions.write"
- "iam.web_key.write"
- "iam.web_key.delete"
- "iam.web_key.read"
- "iam.debug.write"
- "iam.debug.read"
- "org.read"
- "org.global.read"
- "org.create"
- "org.write"
- "org.delete"
- "org.member.read"
- "org.member.write"
- "org.member.delete"
- "org.idp.read"
- "org.idp.write"
- "org.idp.delete"
- "org.action.read"
- "org.action.write"
- "org.action.delete"
- "org.flow.read"
- "org.flow.write"
- "org.flow.delete"
- "org.feature.read"
- "org.feature.write"
- "org.feature.delete"
- "user.read"
- "user.global.read"
- "user.write"
- "user.delete"
- "user.grant.read"
- "user.grant.write"
- "user.grant.delete"
- "user.membership.read"
- "user.credential.write"
- "user.passkey.write"
- "user.feature.read"
- "user.feature.write"
- "user.feature.delete"
- "policy.read"
- "policy.write"
- "policy.delete"
- "project.read"
- "project.create"
- "project.write"
- "project.delete"
- "project.member.read"
- "project.member.write"
- "project.member.delete"
- "project.role.read"
- "project.role.write"
- "project.role.delete"
- "project.app.read"
- "project.app.write"
- "project.app.delete"
- "project.grant.read"
- "project.grant.write"
- "project.grant.delete"
- "project.grant.member.read"
- "project.grant.member.write"
- "project.grant.member.delete"
- "events.read"
- "milestones.read"
- "session.read"
- "session.write"
- "session.delete"
- "action.target.read"
- "action.target.write"
- "action.target.delete"
- "action.execution.read"
- "action.execution.write"
- "userschema.read"
- "userschema.write"
- "userschema.delete"
- "group.create"
- "group.write"
- "group.read"
- "group.delete"
- "group.user.write"
- "group.user.read"
- "group.user.delete"
- Role: "IAM_OWNER_VIEWER"
Permissions:
- "iam.read"
- "iam.policy.read"
- "iam.member.read"
- "iam.idp.read"
- "iam.action.read"
- "iam.flow.read"
- "iam.restrictions.read"
- "iam.feature.read"
- "iam.web_key.read"
- "iam.debug.read"
- "org.read"
- "org.member.read"
- "org.idp.read"
- "org.action.read"
- "org.flow.read"
- "org.feature.read"
- "user.read"
- "user.global.read"
- "user.grant.read"
- "user.membership.read"
- "user.feature.read"
- "policy.read"
- "project.read"
- "project.member.read"
- "project.role.read"
- "project.app.read"
- "project.grant.read"
- "project.grant.member.read"
- "events.read"
- "milestones.read"
- "action.target.read"
- "action.execution.read"
- "userschema.read"
- "session.read"
- "group.read"
- "group.user.read"
- Role: "IAM_ORG_MANAGER"
Permissions:
- "org.read"
- "org.global.read"
- "org.create"
- "org.write"
- "org.delete"
- "org.member.read"
- "org.member.write"
- "org.member.delete"
- "org.idp.read"
- "org.idp.write"
- "org.idp.delete"
- "org.action.read"
- "org.action.write"
- "org.action.delete"
- "org.flow.read"
- "org.flow.write"
- "org.flow.delete"
- "org.feature.read"
- "org.feature.write"
- "org.feature.delete"
- "user.read"
- "user.global.read"
- "user.write"
- "user.delete"
- "user.grant.read"
- "user.grant.write"
- "user.grant.delete"
- "user.membership.read"
- "user.credential.write"
- "user.passkey.write"
- "user.feature.read"
- "user.feature.write"
- "user.feature.delete"
- "policy.read"
- "policy.write"
- "policy.delete"
- "project.read"
- "project.create"
- "project.write"
- "project.delete"
- "project.member.read"
- "project.member.write"
- "project.member.delete"
- "project.role.read"
- "project.role.write"
- "project.role.delete"
- "project.app.read"
- "project.app.write"
- "project.app.delete"
- "project.grant.read"
- "project.grant.write"
- "project.grant.delete"
- "project.grant.member.read"
- "project.grant.member.write"
- "project.grant.member.delete"
- "session.read"
- "session.delete"
- "group.create"
- "group.write"
- "group.read"
- "group.delete"
- "group.user.write"
- "group.user.read"
- "group.user.delete"
- Role: "IAM_USER_MANAGER"
Permissions:
- "org.read"
- "org.global.read"
- "org.member.read"
- "org.member.delete"
- "user.read"
- "user.global.read"
- "user.write"
- "user.delete"
- "user.grant.read"
- "user.grant.write"
- "user.grant.delete"
- "user.membership.read"
- "user.passkey.write"
- "user.feature.read"
- "user.feature.write"
- "user.feature.delete"
- "project.read"
- "project.member.read"
- "project.role.read"
- "project.app.read"
- "project.grant.read"
- "project.grant.write"
- "project.grant.delete"
- "project.grant.member.read"
- "session.read"
- "session.delete"
- "group.read"
- "group.user.read"
- Role: "IAM_ADMIN_IMPERSONATOR"
Permissions:
- "admin.impersonation"
- "impersonation"
- Role: "IAM_END_USER_IMPERSONATOR"
Permissions:
- "impersonation"
- Role: "IAM_LOGIN_CLIENT"
Permissions:
- "iam.read"
- "iam.policy.read"
- "iam.member.read"
- "iam.member.write"
- "iam.idp.read"
- "iam.feature.read"
- "iam.restrictions.read"
- "org.read"
- "org.member.read"
- "org.member.write"
- "org.idp.read"
- "org.feature.read"
- "user.read"
- "user.write"
- "user.grant.read"
- "user.grant.write"
- "user.membership.read"
- "user.credential.write"
- "user.passkey.write"
- "user.feature.read"
- "policy.read"
- "project.read"
- "project.member.read"
- "project.member.write"
- "project.role.read"
- "project.app.read"
- "project.member.read"
- "project.member.write"
- "project.grant.read"
- "project.grant.member.read"
- "project.grant.member.write"
- "session.read"
- "session.write"
- "session.link"
- "session.delete"
- "userschema.read"
- "group.read"
- "group.user.read"zitadel-init-steps.yaml
FirstInstance:
Skip: false
PatPath: /pat/admin.pat
InstanceName: "StackWeaver"
DefaultLanguage: en
Org:
Name: ZITADEL
Human:
UserName: admin
FirstName: Admin
LastName: User
Email:
Address: [email protected]
Verified: true
Password: Password1!
PasswordChangeRequired: false
Machine:
Machine:
Username: admin
Name: "Automatically Initialized IAM_OWNER"
Pat:
ExpirationDate: "2029-01-01T00:00:00Z"
zitadel-init.yaml
# Zitadel-init runtime configuration
# Custom domains and redirect URIs are configured here.
# See docs/user-guides/authentication/zitadel-setup.md for details.
custom_domains: []
frontend_redirect_uris: []
frontend_post_logout_redirect_uris: []
2. Generate keys and start
This is a first-install sequence. Do not copy over existing configuration or regenerate an existing installation's encryption/signing keys.
cp .env.example .env
# The api, zitadel-init and runner services read these three files directly, so
# they must exist before Compose starts even when you configure nothing in them.
# Leave sso.env and vcs.env as-is for a plain installation; fill them in when you
# add SSO, email (SMTP) or a VCS provider.
cp sso.env.example sso.env
cp vcs.env.example vcs.env
cp oidc.env.example oidc.env
# Generate the workload identity signing key. This is required: the API refuses
# to start without OIDC_SIGNING_KEY, even when you do not use workload identity.
sed -i.bak "s|^OIDC_SIGNING_KEY=.*|OIDC_SIGNING_KEY=$(openssl genrsa 2048 | openssl base64 -A)|" oidc.env
# Generate a unique 32-byte encryption key. This is required: the Ansible runner
# refuses to start with a missing or all-zero key, and Compose will not bring the
# stack up until it is set. The key encrypts stored credentials at rest.
sed -i.bak "s/^ENCRYPTION_KEY=.*/ENCRYPTION_KEY=$(openssl rand -hex 32)/" .env
rm -f oidc.env.bak .env.bak
docker compose up -d
The three integration env files must exist even when you leave their optional settings empty. The API requires the OIDC signing key; the platform services require the encryption key.
The initializer writes generated identity credentials into the bundle's .env. The API reads
that output at startup, and frontend-config writes the browser's env.js. Do not edit the
generated identity values or use that output for persistent SSO/VCS/OIDC configuration.
docker compose ps
docker compose logs -f zitadel-init
Wait for initialization to complete, then open http://localhost:5173. The example's initial
Zitadel administrator is [email protected] / Password1! unless you changed its bootstrap
password before the first start. Change that password before sharing the installation.
Without SMTP, public sign-up and email recovery are unavailable. Use SSO or an administrator-created account with a verified address; see Sign-in email and sign-up.
3. Check the network path
Containers use Docker service names internally. PostgreSQL, Redis and Garage are not published to the host; API, frontend and Zitadel are. Runners and orchestrator publish no ports.
| Address on the host | Purpose |
|---|---|
http://localhost:5173 | Browser application, login pages and same-origin API proxy. |
http://localhost:8022 | Direct API/CLI and webhook access. |
http://localhost:8080 | Zitadel identity service. |
For a reverse proxy, the simplest app route sends the entire app hostname to port 5173; the
bundle's frontend-proxy.conf handles API paths. You can also route API paths directly:
flowchart LR
Client([Browser / Terraform CLI])
Client --> AppDomain["app.example.com"]
Client --> AuthDomain["auth.example.com"]
AppDomain -->|"/api/v2/*\n/auth/*\n/.well-known/*\n/v1/modules/*\n/health"| API["API :8022"]
AppDomain -->|"/auth/callback, /auth/login\nEverything else (incl. /login/*)"| Frontend["Frontend :5173"]
AuthDomain --> Zitadel["Zitadel :8080"]Match SPA /auth/callback and /auth/login before a broad /auth/* API rule. Other API paths
include /api/v2/*, the discovery documents, /v1/modules/* and /health. Serve /login/*
from the frontend. Keep browser API requests on the app origin.
For public domains, follow Configure a Zitadel domain and reconcile persistent identity settings and registered redirects. A reverse proxy or Cloudflare Tunnel does not change those registrations for you.
Public pages and indexing
Public product pages and documentation share the app hostname. The frontend requests
exclusion from search by default. To publish those pages, use the bundle's optional
docker-compose.public-site.yml override and configure your own HTTPS address with
Public pages and search indexing.
HTTPS and browser sessions
Browser sign-in needs HTTPS. After sign-in the browser's only credential is a session cookie, __Host-sw_session, which is HttpOnly, Secure and SameSite=Strict, and browsers drop a Secure cookie received over plain HTTP on any host other than localhost. The default http://localhost:5173 works as it is; any other address needs TLS in front of Stackweaver. The API logs a warning at startup when STACKWEAVER_APP_URL is plain http on a host other than localhost.
A reverse proxy in front of the app domain must do the following:
- Pass the
Cookierequest header and theSet-Cookieresponse header through unmodified. Do not rewrite cookie domains or paths (for example with nginxproxy_cookie_domainorproxy_cookie_path): a__Host-cookie must have noDomainandPath=/, and the browser rejects it otherwise. - Forward the
X-Stackweaver-CSRFrequest header. The browser sends it on every request, and the API refuses a cookie-authenticated request without it with403. - Set
X-Forwarded-Protowhen it terminates TLS, and list the proxy inTRUSTED_PROXIESso the client IP recorded for a session and used for rate limiting is the browser's.
The API trusts the origin of STACKWEAVER_APP_URL for browser requests. Set CORS_EXTRA_ORIGINS only when the SPA is served from another origin than that, because every state-changing browser request from an origin that is not trusted is refused.
How long a sign-in lasts is set in .env with STACKWEAVER_SESSION_IDLE_TIMEOUT (default 1h without activity), STACKWEAVER_SESSION_MAX_LIFETIME (default 12h after sign-in) and STACKWEAVER_SESSION_LIVENESS_INTERVAL (default 5m). A sign-in survives a browser restart until one of these limits ends it. See Browser Sessions.
Change settings in the right place
| File | Responsibility |
|---|---|
.env.example and first-install bootstrap inputs | Operator-chosen initial values for this standalone bundle. Preserve your configured installation's files after bootstrap. |
.env | Generated identity output alongside the bundle's operator values. Do not hand-edit generated identity entries. |
sso.env | Persistent SSO and SMTP settings. |
vcs.env | Persistent GitHub/Azure DevOps credentials. |
oidc.env | Persistent workload-identity signing key and issuer. |
zitadel-defaults.yaml, zitadel-init.yaml, zitadel-init-steps.yaml | Persistent identity setup; follow the domain guide for changes. |
After changing initializer inputs, run it again without restarting its dependencies, then apply the generated consumer settings:
docker compose run --rm --no-deps zitadel-init
docker compose up -d
A plain container restart does not reload Compose's env_file values. The API also reads its
configured env output file with precedence over container environment; a one-command shell
override is not a reliable way to replace a generated value.
For GitHub, use the complete GitHub App guide, including the private-key mount for API, orchestrator and Ansible runner. See the environment reference for service settings.
Give a variable to runs
Runners do not forward their whole environment to user code. Configure a variable on the
runner service and name it in STACKWEAVER_RUN_ENV_PASSTHROUGH when it is outside the built-in
allowlist. Never pass platform secrets this way. See
Runner variables that reach runs.
STACKWEAVER_RUN_ENV_PASSTHROUGH=PROXY_USER,PROXY_PASSWORD
Preserve data and upgrade
Use Back up and restore for a complete recovery set and restore rehearsal, and Plan and verify an upgrade to select component versions and check the result. The commands below provide deployment-specific details.
| Volume | Data | Critical? |
|---|---|---|
postgres_data | Database (all application state) | Yes; back up regularly |
garage_data | Object storage (OpenTofu state, configs, registry) | Yes; back up regularly |
garage_meta | Garage metadata (the index of the objects in garage_data) | Yes; back up together with garage_data |
runner-workspaces | Temporary workspace files of the OpenTofu runner | No; recreated on runs |
ansible-workspaces | Temporary per-job files and the Galaxy cache of the Ansible runner | No; recreated on jobs |
zitadel-pat | Zitadel admin PAT (shared with init job) | Preserve with the configured identity and generated .env |
Back up the database, object storage and the configuration/key files together. A database backup without the encryption or Zitadel master key is insufficient to recover encrypted data. The application and identity data are separate databases. Export both, using the configured users/database names if you changed them:
docker compose exec -T postgres pg_dump -U iac iac_platform > application-backup.sql
docker compose exec -T postgres pg_dump -U iac zitadel > identity-backup.sql
Protect the backup as sensitive data. These dumps do not include object storage, volumes or key/configuration files. The Garage data and metadata volumes must be backed up consistently together.
Prepare the browser-session upgrade
For an installation using the bundled initializer, the next docker compose up -d after pulling switches it to server-side browser sessions with nothing else to run: zitadel-init creates the confidential browser sign-in app, writes ZITADEL_BFF_CLIENT_ID and ZITADEL_BFF_CLIENT_SECRET to .env, deactivates the public frontend app, and the API reads the new values when it starts.
Before you upgrade, check two settings in .env if you run Stackweaver on an address other than http://localhost:5173:
- Set
STACKWEAVER_APP_URLto the app's public URL. The API builds the sign-in redirect from it, so it must match the/auth/callbackURL registered inzitadel-init.yaml. If you had setVITE_ZITADEL_REDIRECT_URI, setSTACKWEAVER_APP_URLto the same origin;VITE_ZITADEL_REDIRECT_URIis not read and can be removed. - Serve the app over HTTPS, as described in HTTPS and Browser Sessions.
After the upgrade, each browser signs in once and returns to the page it had open. API tokens, the Terraform CLI and runners keep working without any change. A script that called the API with a Zitadel access token must use an API token instead, created under Settings > API Tokens.
If you go back to a release from before server-side browser sessions by setting the StackWeaver image tags in docker-compose.yml to an older version, browsers cannot sign in until you reactivate the public frontend app, because the older zitadel-init signs browsers in with it and never reactivates it. Sign in to the Zitadel console as the administrator, open the IAC Platform Project project in the IAC Platform organization, select the IAC Platform Frontend application, and reactivate it. A later upgrade deactivates it again.
If you operate an external Zitadel without this initializer, create Stackweaver BFF and provide its BFF credentials before upgrading. Follow the confidential-client setup.
Apply the selected images
Before upgrading, read the relevant release notes and save a recovery copy. Pull the images selected in your Compose file, then recreate changed services:
docker compose pull
docker compose up -d
docker compose ps
The API migrates the schema at startup. Image rollback alone may not reverse a schema change.
Mutable latest tags select whatever is published at pull time; pin the versions or digests
you have reviewed for repeatable production deployments.
Use docker compose down to stop the stack while retaining named volumes.
docker compose down -v deletes the named volumes and their data. It is not an upgrade or
troubleshooting step. Keep configuration, keys and persistent data together during recovery.
Diagnose startup problems
| Symptom | Read/check |
|---|---|
| Image pull denied | Package access, GHCR login and the exact image/tag. |
| A published port is unavailable | docker compose ps and the host's listening ports. |
| Identity initialization failed | docker compose logs zitadel-init and docker compose logs zitadel; reconcile after correcting inputs. |
| API cannot reach PostgreSQL | Database health and matching credentials; docker compose ps postgres should report healthy. |
| Blank page or wrong API address | Browser console, frontend logs and same-origin VITE_API_URL; reapply configuration and reload. |
| A run stays queued | Runner/orchestrator logs and matching database/Redis settings. |
Use docker compose logs api, docker compose logs orchestrator, docker compose logs runner
or docker compose logs ansible-runner to narrow the failing service. Do not share initializer
logs without checking them for credentials. To inspect PostgreSQL interactively:
docker compose exec postgres psql -U iac -d iac_platform
For source access or contribution enquiries, contact [email protected].