Skip to content
Beta — Truss is in public beta. Documentation is actively updated but may not reflect the latest changes. Report issues on GitHub.

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

ServicePurposePort
PostgreSQL 16Primary database (shared by Truss + Kratos + Keto)5432
Ory KratosAuthentication — email/password, MFA, passkeys, social login4433
Ory KetoAuthorization — RBAC, ReBAC, relation tuplesinternal
MinIOS3-compatible object storage9000 / 9001
flagdFeature flag evaluation engine (CNCF/OpenFeature)internal
Truss APIExpress backend (all 160+ features)8787
Truss DashboardStatic SPA served by Nginx, proxies API requests3000

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:

Terminal window
# ─── 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=minioadmin
MINIO_SECRET_KEY=minioadmin
# ─── Security ───
# IMPORTANT: Generate real keys before production use.
# Generate with: openssl rand -hex 32
ENCRYPTION_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.com

For production, generate real secrets:

Terminal window
# Generate all secrets at once
echo "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:$&#123;DB_PASSWORD:-truss&#125;@postgres:5432/truss?sslmode=disable
serve:
public:
base_url: $&#123;TRUSS_PUBLIC_URL:-http://localhost:3000&#125;/.ory/kratos/public/
cors:
enabled: true
allowed_origins: ["$&#123;TRUSS_PUBLIC_URL:-http://localhost:3000&#125;"]
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: $&#123;TRUSS_PUBLIC_URL:-http://localhost:3000&#125;/
methods:
password:
enabled: true
code:
enabled: true
passkey:
enabled: true
config:
rp:
display_name: Truss
id: localhost
origins: ["$&#123;TRUSS_PUBLIC_URL:-http://localhost:3000&#125;"]
flows:
login:
ui_url: $&#123;TRUSS_PUBLIC_URL:-http://localhost:3000&#125;/login
lifespan: 10m
registration:
ui_url: $&#123;TRUSS_PUBLIC_URL:-http://localhost:3000&#125;/register
lifespan: 10m
after:
password:
hooks:
- hook: session
verification:
enabled: true
ui_url: $&#123;TRUSS_PUBLIC_URL:-http://localhost:3000&#125;/verification
use: code
recovery:
enabled: true
ui_url: $&#123;TRUSS_PUBLIC_URL:-http://localhost:3000&#125;/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: ["$&#123;KRATOS_COOKIE_SECRET:-change-me-cookie-secret-32chars!&#125;"]
cipher: ["$&#123;KRATOS_CIPHER_SECRET:-change-me-cipher-secret-32chars!&#125;"]
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:$&#123;DB_PASSWORD:-truss&#125;@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:$&#123;DB_PASSWORD:-truss&#125;@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:$&#123;DB_PASSWORD:-truss&#125;@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: $&#123;MINIO_ACCESS_KEY:-minioadmin&#125;
MINIO_ROOT_PASSWORD: $&#123;MINIO_SECRET_KEY:-minioadmin&#125;
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:$&#123;DB_PASSWORD:-truss&#125;@postgres:5432/truss?sslmode=disable
ENCRYPTION_KEY: $&#123;ENCRYPTION_KEY:-change-me-to-a-random-64-char-hex-string&#125;
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: $&#123;MINIO_ACCESS_KEY:-minioadmin&#125;
MINIO_SECRET_KEY: $&#123;MINIO_SECRET_KEY:-minioadmin&#125;
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

Terminal window
docker compose -f docker-compose.selfhosted.yml --env-file .env.selfhosted up -d

First boot takes 1-2 minutes (Postgres init → Kratos/Keto migrations → API bootstrap).

4. Open the dashboard

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:

Terminal window
docker compose -f docker-compose.selfhosted.yml down

Logs:

Terminal window
docker compose -f docker-compose.selfhosted.yml logs -f truss-api

Backup Postgres:

Terminal window
docker compose -f docker-compose.selfhosted.yml exec postgres \
pg_dump -U truss truss | gzip > backup-$(date +%Y%m%d).sql.gz

Upgrade:

Terminal window
docker compose -f docker-compose.selfhosted.yml pull
docker compose -f docker-compose.selfhosted.yml --env-file .env.selfhosted up -d

Custom domain

Set TRUSS_PUBLIC_URL in .env.selfhosted:

Terminal window
TRUSS_PUBLIC_URL=https://truss.yourdomain.com

This 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.