Self-Hosting
Run the entire Truss platform on your own infrastructure. Same features as Truss Cloud — database, auth, permissions, storage, realtime, webhooks, feature flags — without vendor lock-in.
What’s included
| Service | Purpose | Port |
|---|---|---|
| PostgreSQL 16 | Primary database (shared by Truss + Kratos + Keto) | 5432 |
| Ory Kratos | Authentication — email/password, MFA, passkeys, social login | 4433 |
| Ory Keto | Authorization — RBAC, ReBAC, relation tuples | internal |
| MinIO | S3-compatible object storage | 9000 / 9001 |
| flagd | Feature flag evaluation engine (CNCF/OpenFeature) | internal |
| Truss API | Express backend (all 160+ features) | 8787 |
| Truss Dashboard | Static SPA served by Nginx, proxies API requests | 3000 |
Optional services (can be added to the compose file):
- Ory Hydra — OAuth2 / OpenID Connect provider
- Ory Oathkeeper — API Gateway / zero-trust proxy
Quick Start
1. Create the environment file
Save this as .env.selfhosted:
# ─── Truss Self-Hosted Configuration ───# Usage: docker compose -f docker-compose.selfhosted.yml --env-file .env.selfhosted up -d
# ─── Database ───DB_PASSWORD=truss
# ─── Storage (MinIO) ───MINIO_ACCESS_KEY=minioadminMINIO_SECRET_KEY=minioadmin
# ─── Security ───# IMPORTANT: Generate real keys before production use.# Generate with: openssl rand -hex 32ENCRYPTION_KEY=change-me-to-a-random-64-char-hex-string
# Kratos secrets (generate with: openssl rand -hex 16)KRATOS_COOKIE_SECRET=change-me-cookie-secret-32chars!KRATOS_CIPHER_SECRET=change-me-cipher-secret-32chars!
# ─── Public URL ───# Change if running behind a reverse proxy or custom domain.TRUSS_PUBLIC_URL=http://localhost:3000
# ─── Optional: SMTP for email verification/recovery ───# SMTP_HOST=smtp.example.com# SMTP_PORT=465# SMTP_USER=apikey# SMTP_PASS=your-api-key# SMTP_FROM=noreply@yourdomain.comFor production, generate real secrets:
# Generate all secrets at onceecho "DB_PASSWORD=$(openssl rand -hex 16)"echo "MINIO_SECRET_KEY=$(openssl rand -hex 16)"echo "ENCRYPTION_KEY=$(openssl rand -hex 32)"echo "KRATOS_COOKIE_SECRET=$(openssl rand -hex 16)"echo "KRATOS_CIPHER_SECRET=$(openssl rand -hex 16)"2. Create the Docker Compose file
Save this as docker-compose.selfhosted.yml:
Click to expand the full compose file (~430 lines)
name: truss
services: # ── PostgreSQL — shared database for all services ── postgres: image: postgres:16-alpine restart: unless-stopped environment: POSTGRES_USER: truss POSTGRES_PASSWORD: ${DB_PASSWORD:-truss} POSTGRES_DB: truss healthcheck: test: ["CMD-SHELL", "pg_isready -U truss -d truss"] interval: 5s timeout: 5s retries: 20 volumes: - truss_postgres_data:/var/lib/postgresql/data
# ── Ory Kratos — Authentication ── kratos-migrate: image: oryd/kratos:v1.2.0 restart: "no" depends_on: postgres: condition: service_healthy environment: DSN: postgres://truss:${DB_PASSWORD:-truss}@postgres:5432/truss?sslmode=disable command: ["migrate", "sql", "-e", "--yes", "postgres://truss:${DB_PASSWORD:-truss}@postgres:5432/truss?sslmode=disable"]
kratos: image: oryd/kratos:v1.2.0 restart: unless-stopped depends_on: kratos-migrate: condition: service_completed_successfully entrypoint: ["/bin/sh", "-ec"] command: - | mkdir -p /tmp/kratos cat >/tmp/kratos/identity.schema.json <<'JSON' { "$$id": "https://schemas.ory.sh/presets/kratos/quickstart/email-password/identity.schema.json", "$$schema": "http://json-schema.org/draft-07/schema#", "title": "Person", "type": "object", "properties": { "traits": { "type": "object", "properties": { "email": { "type": "string", "format": "email", "ory.sh/kratos": { "credentials": { "password": { "identifier": true }, "code": { "identifier": true, "via": "email" }, "passkey": { "display_name": true }, "webauthn": { "identifier": true }, "totp": { "account_name": true } }, "verification": { "via": "email" }, "recovery": { "via": "email" } } } }, "required": ["email"], "additionalProperties": false } } } JSON cat >/tmp/kratos/kratos.yml <<'YAML' version: v0.13.0 dsn: postgres://truss:${DB_PASSWORD:-truss}@postgres:5432/truss?sslmode=disable serve: public: base_url: ${TRUSS_PUBLIC_URL:-http://localhost:3000}/.ory/kratos/public/ cors: enabled: true allowed_origins: ["${TRUSS_PUBLIC_URL:-http://localhost:3000}"] allowed_methods: ["POST","GET","PUT","PATCH","DELETE"] allowed_headers: ["Authorization","Cookie","Content-Type","X-CSRF-Token"] exposed_headers: ["Content-Type","Set-Cookie"] admin: base_url: http://kratos:4434/ selfservice: default_browser_return_url: ${TRUSS_PUBLIC_URL:-http://localhost:3000}/ methods: password: enabled: true code: enabled: true passkey: enabled: true config: rp: display_name: Truss id: localhost origins: ["${TRUSS_PUBLIC_URL:-http://localhost:3000}"] flows: login: ui_url: ${TRUSS_PUBLIC_URL:-http://localhost:3000}/login lifespan: 10m registration: ui_url: ${TRUSS_PUBLIC_URL:-http://localhost:3000}/register lifespan: 10m after: password: hooks: - hook: session verification: enabled: true ui_url: ${TRUSS_PUBLIC_URL:-http://localhost:3000}/verification use: code recovery: enabled: true ui_url: ${TRUSS_PUBLIC_URL:-http://localhost:3000}/recovery use: code session: lifespan: 24h cookie: name: ory_kratos_session same_site: Lax identity: default_schema_id: default schemas: - id: default url: file:///tmp/kratos/identity.schema.json secrets: cookie: ["${KRATOS_COOKIE_SECRET:-change-me-cookie-secret-32chars!}"] cipher: ["${KRATOS_CIPHER_SECRET:-change-me-cipher-secret-32chars!}"] log: level: info format: json YAML exec kratos serve all --config /tmp/kratos/kratos.yml ports: - "4433:4433"
# ── Ory Keto — Authorization ── postgres-init: image: postgres:16-alpine restart: "no" depends_on: postgres: condition: service_healthy entrypoint: ["/bin/sh", "-ec"] command: - | psql "postgres://truss:${DB_PASSWORD:-truss}@postgres:5432/truss?sslmode=disable" \ -c "CREATE SCHEMA IF NOT EXISTS keto;"
keto-migrate: image: oryd/keto:v0.12.0-alpha.0 restart: "no" depends_on: postgres-init: condition: service_completed_successfully entrypoint: ["/bin/sh", "-ec"] command: - | mkdir -p /tmp/keto cat >/tmp/keto/keto.yml <<'YAML' dsn: postgres://truss:${DB_PASSWORD:-truss}@postgres:5432/truss?sslmode=disable&search_path=keto namespaces: - id: 0 name: default YAML keto migrate up -c /tmp/keto/keto.yml --yes
keto: image: oryd/keto:v0.12.0-alpha.0 restart: unless-stopped depends_on: keto-migrate: condition: service_completed_successfully entrypoint: ["/bin/sh", "-ec"] command: - | mkdir -p /tmp/keto cat >/tmp/keto/keto.yml <<'YAML' dsn: postgres://truss:${DB_PASSWORD:-truss}@postgres:5432/truss?sslmode=disable&search_path=keto serve: read: host: 0.0.0.0 port: 4466 write: host: 0.0.0.0 port: 4467 namespaces: - id: 0 name: default log: level: info format: json YAML exec keto serve all --config /tmp/keto/keto.yml expose: - "4466" - "4467"
# ── MinIO — S3-compatible storage ── minio: image: minio/minio:latest restart: unless-stopped command: server /data --console-address :9001 environment: MINIO_ROOT_USER: ${MINIO_ACCESS_KEY:-minioadmin} MINIO_ROOT_PASSWORD: ${MINIO_SECRET_KEY:-minioadmin} healthcheck: test: ["CMD", "mc", "ready", "local"] interval: 10s timeout: 5s retries: 5 ports: - "9000:9000" - "9001:9001" volumes: - truss_minio_data:/data
# ── flagd — Feature flags ── flagd-init: image: alpine:3.20 restart: "no" entrypoint: ["/bin/sh", "-ec"] command: - | if [ ! -f /etc/flagd/flags.flagd.json ]; then mkdir -p /etc/flagd cat >/etc/flagd/flags.flagd.json <<'JSON' { "$$schema": "https://flagd.dev/schema/v0/flags.json", "flags": { "truss-healthcheck": { "state": "ENABLED", "variants": { "on": true, "off": false }, "defaultVariant": "on" } } } JSON fi volumes: - flagd_data:/etc/flagd
flagd: image: ghcr.io/open-feature/flagd:latest restart: unless-stopped depends_on: flagd-init: condition: service_completed_successfully command: ["start", "--uri", "file:/etc/flagd/flags.flagd.json", "--port", "8013", "--management-port", "8014", "--log-format", "json"] volumes: - flagd_data:/etc/flagd expose: - "8013" - "8014"
# ── Truss API ── # NOTE: Replace `build` with `image: ghcr.io/binarysquadd/truss-api:latest` # once published images are available. truss-api: build: context: . dockerfile: Dockerfile restart: unless-stopped depends_on: postgres: condition: service_healthy kratos: condition: service_started keto: condition: service_started minio: condition: service_healthy flagd: condition: service_started environment: TRUSS_SELF_HOSTED: "true" TRUSS_AUTH_REQUIRED: "true" NODE_ENV: production API_PORT: "8787" DATABASE_URL: postgres://truss:${DB_PASSWORD:-truss}@postgres:5432/truss?sslmode=disable ENCRYPTION_KEY: ${ENCRYPTION_KEY:-change-me-to-a-random-64-char-hex-string} KRATOS_PUBLIC_URL: http://kratos:4433 KRATOS_ADMIN_URL: http://kratos:4434 KETO_READ_URL: http://keto:4466 KETO_WRITE_URL: http://keto:4467 MINIO_S3_ENDPOINT: http://minio:9000 MINIO_ACCESS_KEY: ${MINIO_ACCESS_KEY:-minioadmin} MINIO_SECRET_KEY: ${MINIO_SECRET_KEY:-minioadmin} FLAGD_HOST: http://flagd FLAGD_PORT: "8013" expose: - "8787"
# ── Truss Dashboard (Nginx) ── # NOTE: Replace `build` with `image: ghcr.io/binarysquadd/truss-dashboard:latest` # once published images are available. truss-dashboard: build: context: . dockerfile: selfhosted/Dockerfile.dashboard restart: unless-stopped depends_on: - truss-api ports: - "3000:80"
volumes: truss_postgres_data: truss_minio_data: flagd_data:3. Start the stack
docker compose -f docker-compose.selfhosted.yml --env-file .env.selfhosted up -dFirst boot takes 1-2 minutes (Postgres init → Kratos/Keto migrations → API bootstrap).
4. Open the dashboard
- Dashboard → http://localhost:3000
- MinIO Console → http://localhost:9001
Register your admin account on the dashboard. Truss auto-bootstraps a default org, project, and API keys on first boot.
How self-hosted mode works
The API runs with TRUSS_SELF_HOSTED=true, which:
- Removes all usage quotas (unlimited everything)
- Uses a single shared database (no per-tenant isolation)
- Disables billing routes and payment processing
- Auto-creates a default admin tenant on first boot
The dashboard is built with VITE_SELF_HOSTED=true, which:
- Hides billing tabs and invoice views
- Hides the org switcher (single-org mode)
- All core panels work identically to the hosted version
Common operations
Stop:
docker compose -f docker-compose.selfhosted.yml downLogs:
docker compose -f docker-compose.selfhosted.yml logs -f truss-apiBackup Postgres:
docker compose -f docker-compose.selfhosted.yml exec postgres \ pg_dump -U truss truss | gzip > backup-$(date +%Y%m%d).sql.gzUpgrade:
docker compose -f docker-compose.selfhosted.yml pulldocker compose -f docker-compose.selfhosted.yml --env-file .env.selfhosted up -dCustom domain
Set TRUSS_PUBLIC_URL in .env.selfhosted:
TRUSS_PUBLIC_URL=https://truss.yourdomain.comThis configures Kratos login/registration redirects and CORS.
Architecture
┌───────────────────────────────────────────────────────────┐│ docker-compose.selfhosted.yml ││ ││ ┌──────────┐ ┌────────┐ ┌──────┐ ┌───────┐ ┌────────┐ ││ │ Postgres │ │ Kratos │ │ Keto │ │ MinIO │ │ flagd │ ││ │ :5432 │ │ :4433 │ │:4466 │ │:9000 │ │ :8013 │ ││ └────┬─────┘ └───┬────┘ └──┬───┘ └───┬───┘ └───┬────┘ ││ │ │ │ │ │ ││ └───────────┴─────────┴─────────┴─────────┘ ││ │ ││ ┌──────┴───────┐ ││ │ Truss API │ ││ │ :8787 │ ││ └──────┬───────┘ ││ │ ││ ┌──────┴───────┐ ││ │ Dashboard │ ││ │ Nginx :3000 │ ││ └──────────────┘ │└───────────────────────────────────────────────────────────┘All services communicate over an internal Docker network. Only ports 3000 (dashboard), 4433 (Kratos public), 9000/9001 (MinIO) are exposed to the host.