Self-Hosting¶
Deploy WeftID on your own infrastructure with Docker Compose and Caddy.
Prerequisites¶
- Docker Engine 24+ and Docker Compose v2
- A domain name with DNS access (you will create an A record and a wildcard record)
- A server with ports 80 and 443 open (for HTTPS via Let's Encrypt)
- An SMTP server or email API service (Resend, SendGrid) for invitations and verification codes
1. Set up DNS¶
Before installing, point two DNS records at your server. Each tenant gets its own subdomain
(for example, acme.id.example.com), and the wildcard record ensures all subdomains resolve.
| Record | Name | Value |
|---|---|---|
| A | id.example.com |
your server IP |
| A (wildcard) | *.id.example.com |
your server IP |
Replace id.example.com with your chosen domain. Set this up first so DNS has time to
propagate while you configure the rest.
Tip
TLS certificates are separate from DNS. Caddy obtains a per-subdomain certificate automatically via Let's Encrypt (see TLS and reverse proxy). You do not need a wildcard certificate.
2. Install¶
Choose a directory for your WeftID installation. All configuration files live here, and you
will run docker compose commands from this directory.
mkdir -p /opt/weftid && cd /opt/weftid
Then run the install script, which downloads the production files, generates secrets, and walks you through initial configuration:
curl -sSL https://raw.githubusercontent.com/pageloom/weft-id/main/deploy/install.sh | bash
This creates three files in the current directory:
docker-compose.yml-- service definitions (downloaded fromdeploy/docker-compose.ymlin the repo)Caddyfile-- reverse proxy with automatic HTTPS.env-- your configuration (secrets, domain, SMTP)
The script asks for your domain and SMTP settings interactively. If you use SendGrid or Resend
instead of SMTP, press Enter to skip the SMTP prompts. Then edit .env to configure your
email backend (see Email configuration).
Manual install
If you prefer not to pipe a script, download the files yourself:
# Download production compose file and rename so docker compose finds it by default
curl -fsSL https://raw.githubusercontent.com/pageloom/weft-id/main/deploy/docker-compose.yml \
-o docker-compose.yml
curl -fsSL https://raw.githubusercontent.com/pageloom/weft-id/main/deploy/Caddyfile -o Caddyfile
# Copy and edit .env
curl -fsSL https://raw.githubusercontent.com/pageloom/weft-id/main/deploy/.env.example -o .env
Then edit .env to fill in all required values. Generate secrets with openssl rand -base64 32.
3. Configure email¶
Email delivery is required for user invitations, verification codes, and lifecycle
notifications. The install script writes SMTP settings by default. If you use a different
provider, edit .env before starting the services.
EMAIL_BACKEND=smtp
SMTP_HOST=smtp.example.com
SMTP_PORT=587
SMTP_USER=apikey
SMTP_PASS=your-password
SMTP_TLS=true
FROM_EMAIL=no-reply@example.com
Works with any SMTP provider (Mailgun, Amazon SES, Postfix, etc.).
EMAIL_BACKEND=sendgrid
SENDGRID_API_KEY=SG.xxxxx
FROM_EMAIL=no-reply@example.com
Uses the SendGrid HTTP API. No SMTP configuration needed. Useful on cloud platforms that block outbound SMTP ports.
EMAIL_BACKEND=resend
RESEND_API_KEY=re_xxxxx
FROM_EMAIL=no-reply@example.com
Uses the Resend HTTP API. No SMTP configuration needed.
4. Start the services¶
docker compose up -d
On first start, the migrate service applies the database schema, then the app starts. Caddy
obtains a TLS certificate for each subdomain automatically as tenants are first accessed.
Check that everything is running:
docker compose ps
All services should show as healthy. If the migrate service failed, check its logs:
docker compose logs migrate
5. Verify email delivery¶
Before provisioning a tenant, verify that email delivery is working. The founding super admin receives an invitation email, so broken email configuration means they cannot complete setup.
docker compose exec app python -m app.cli.verify_email --to you@example.com
Replace you@example.com with an address you can check. The command:
- Sends a test email through the configured backend (SMTP, SendGrid, or Resend)
- Checks DNS records (SPF, DKIM, DMARC) for the
FROM_EMAILdomain and reports any issues
If the email arrives and DNS checks look good, proceed to tenant provisioning. DNS warnings are informational and do not block the command. Fix any issues flagged before going to production to improve deliverability.
6. Provision your first tenant¶
Once the services are running, create a tenant and its founding super admin:
docker compose exec app python -m app.cli.provision_tenant \
--subdomain acme \
--tenant-name "Acme Corp" \
--email admin@acme.com \
--first-name Jane \
--last-name Smith
Replace the values with your actual subdomain, tenant name, and super admin details. The command provisions the tenant and sends an invitation email.
The super admin receives an invitation email with a link to verify their email address and set a password. This also validates that email delivery is working.
Note
If email delivery fails, the command prints a warning and the verification URL as a
fallback. Fix your email settings in .env, run docker compose restart, and visit the
printed URL to continue setup.
To add more tenants later, run the same command with a different subdomain and tenant name. If the subdomain already exists, the command reuses the existing tenant and adds a new super admin.
Upgrading¶
Before you upgrade¶
Always back up before upgrading. Migrations change the database schema and cannot be reversed automatically. Follow the steps in Backups to create a full backup (database roles, data, and file storage).
Check available versions at github.com/pageloom/weft-id/releases before proceeding. See the versioning policy for what to expect from patch, minor, and major releases.
Upgrade procedure¶
-
Edit
.envand setWEFT_VERSIONto the target version (e.g.,1.6.0). -
Pull the new image and restart:
docker compose pull docker compose up -dThe
migrateservice runs automatically and applies any pending schema migrations before the app starts. If a migration fails, the migrate service exits non-zero and the app will not start. Check migration logs withdocker compose logs migrate. -
Verify the services are healthy:
docker compose ps
Rolling back¶
Migrations are forward-only. If an upgrade causes problems, restore from the backup you created before upgrading.
Warning
Rollback is a destructive operation. The current database is deleted and replaced with the backup. Any data created or changed since the backup (users, settings, audit logs) will be lost. This cannot be undone.
-
Stop all services:
docker compose down -
Remove the database volume:
docker volume rm "$(docker volume ls -q --filter name=_dbdata | head -1)" -
Edit
.envand setWEFT_VERSIONback to the previous version (e.g.,1.5.0). -
Start the database and wait for it to be ready:
docker compose up -d db # Wait for the database to accept connections until docker compose exec -T db pg_isready -U postgres; do sleep 1; done -
Restore roles and data:
docker compose exec -T db psql -U postgres < roles-backup.sql docker compose exec -T db psql -U postgres appdb < data-backup.sql -
If you backed up file storage, restore it:
docker run --rm \ -v "$(docker volume ls -q --filter name=_storage | head -1):/data" \ -v "$(pwd):/backup" \ alpine sh -c "rm -rf /data/* && tar xzf /backup/storage-backup.tar.gz -C /data" -
Start all services:
docker compose up -d
Backups¶
Back up regularly. At a minimum, back up before every upgrade. The commands below create backup files in the current directory.
Database¶
# Roles (required for restoring onto a fresh database)
docker compose exec -T db \
pg_dumpall -U postgres --roles-only > roles-backup.sql
# Full database dump
docker compose exec -T db \
pg_dump -U postgres appdb > data-backup.sql
# Restrict permissions (backups contain password hashes, PII)
chmod 600 roles-backup.sql data-backup.sql
File storage¶
If you use local file storage (the default), back up the storage volume. The command below finds the compose project's storage volume by name and archives its contents:
docker run --rm \
-v "$(docker volume ls -q --filter name=_storage | head -1):/data" \
-v "$(pwd):/backup" \
alpine tar czf /backup/storage-backup.tar.gz -C /data .
Restoring¶
To restore onto a fresh database, apply roles first, then data:
docker compose exec -T db \
psql -U postgres < roles-backup.sql
docker compose exec -T db \
psql -U postgres appdb < data-backup.sql
Configuration¶
Your .env file contains SECRET_KEY (the master encryption key), POSTGRES_PASSWORD, and
APPUSER_PASSWORD. These cannot be recovered or regenerated. Losing SECRET_KEY invalidates
all active sessions, two-step verification secrets, and SAML signing keys. Losing either
database password locks you out of the database.
Store a copy of .env somewhere secure outside the server (for example, in a password manager
or an encrypted vault). Do not commit it to version control.
Monitoring¶
Health check¶
The app exposes a health endpoint at /healthz that returns:
- 200 -- app is healthy and the database is reachable
- 503 -- database is unreachable
This endpoint bypasses tenant resolution (no subdomain required) and needs no authentication. Use it for load balancer probes or uptime monitoring:
curl -s -o /dev/null -w "%{http_code}" https://id.example.com/healthz
Logs¶
View logs for all services or a specific one:
# All services
docker compose logs -f
# App only
docker compose logs -f app
# Migration output
docker compose logs migrate
Migration status¶
Check which migrations have been applied:
docker compose exec -T db \
psql -U postgres -d appdb \
-c "SELECT version, status, started_at, completed_at FROM schema_migration_log ORDER BY id"
Reference¶
Architecture¶
The production stack has six services:
| Service | Image | Purpose |
|---|---|---|
| caddy | caddy:2-alpine |
Reverse proxy with automatic HTTPS (Let's Encrypt) |
| app | ghcr.io/pageloom/weft-id |
Web application (FastAPI) |
| worker | ghcr.io/pageloom/weft-id |
Background job processor |
| db | postgres:18-alpine |
PostgreSQL database |
| memcached | memcached:1.6-alpine |
Activity cache |
| migrate | ghcr.io/pageloom/weft-id |
One-shot schema migration runner |
The migrate service runs before app starts and exits when done. The app service has a
health check that Caddy waits for before routing traffic. The app and worker containers
run as a non-root user for defense in depth.
Docker image¶
Production images are published to GitHub Container Registry:
ghcr.io/pageloom/weft-id
Available tags:
1.6.0-- exact version (recommended for production)1.6-- latest patch for a minor version1-- latest minor for a major versionlatest-- newest stable release
Configuration reference¶
All configuration is in .env. The install script generates this file interactively, or you
can copy deploy/.env.example and edit it manually.
Required variables¶
| Variable | Description |
|---|---|
WEFT_VERSION |
Image tag to run (e.g., 1.6.0). Pin to a specific version for stability. |
BASE_DOMAIN |
Root domain for tenant subdomains (e.g., id.example.com) |
SECRET_KEY |
Master encryption key. Session signing, two-step verification secrets, SAML key encryption, and email verification tokens are all derived from this value via HKDF. Generate with openssl rand -base64 32. |
POSTGRES_PASSWORD |
Password for the PostgreSQL superuser. Generate with openssl rand -base64 32. |
APPUSER_PASSWORD |
Password for the appuser database role (used by the app at runtime). The install script generates this automatically. |
Optional variables¶
| Variable | Description | Default |
|---|---|---|
ENABLE_OPENAPI_DOCS |
Show Swagger UI at /api/docs |
false |
STORAGE_BACKEND |
File storage: local or spaces (DigitalOcean Spaces) |
local |
Security defaults¶
The following settings default to secure values when not set. They are intentionally omitted
from the production compose file and .env:
IS_DEVdefaults toFalse(enforces production security validation)BYPASS_OTPdefaults tofalse(ensures verification codes are always checked)
If either is set to true with IS_DEV=False, the app refuses to start.
The install script sets .env file permissions to 600 (owner read/write only) to protect
secrets from other users on the system.
Database¶
Schema management¶
The migrate service runs automatically on every docker compose up. It applies the baseline
schema on a fresh database and any pending migrations on an existing one. Migrations are
forward-only and logged in the schema_migration_log table.
The migrate service connects as the PostgreSQL superuser (postgres). The app connects as
appuser, a restricted role created by the baseline schema that enforces row-level security.
TLS and reverse proxy¶
Caddy handles TLS automatically using Let's Encrypt HTTP-01 challenges. It uses on-demand TLS, which means it obtains a separate certificate for each tenant subdomain on first access. This is not a wildcard certificate. The wildcard DNS record (see Set up DNS) handles routing only. No DNS provider API integration is needed.
Before issuing a certificate, Caddy validates the requested subdomain against WeftID's tenant registry. Only direct subdomains of the base domain that correspond to an existing tenant are allowed. This prevents abuse of the on-demand TLS feature (e.g., an attacker triggering certificate requests for arbitrary subdomains).
Requirements for automatic HTTPS:
- Ports 80 and 443 must be reachable from the internet
- DNS must resolve both the apex domain and wildcard subdomains to your server
- No other process can be listening on ports 80 or 443
The Caddyfile is downloaded by the install script. You should not need to modify it for
standard deployments.