Skip to content

Self-hosting

masks ships as a container image. Point it at a Postgres server, and it migrates itself on boot.

Terminal window
docker pull ghcr.io/masksrb/masks:latest

masks is an OpenID Connect and OAuth 2.0 provider. It authenticates people, issues and verifies tokens, and publishes each tenant’s signing keys. It stores nothing about your application’s data.

It runs as one container, with Puma in front of Rails. It needs one Postgres server, which holds three databases named from POSTGRES_DATABASE: the primary, a cache, and a job queue. The only file it keeps on local disk is the master key.

Background jobs, such as mail and retries, run in the web process by default. MASKS_JOBS_IN_WEB_SERVER=false moves them to a second container running bin/jobs.

Tag Pushed on Platforms
:main every push to main amd64
:sha-<commit> every push to main amd64
:<version>, such as :0.3.0 a server release amd64, arm64
:latest a server release, moved to its version amd64, arm64

Pin a version, and read CHANGELOG.md before moving to a new one.

image: ghcr.io/masksrb/masks:0.3.0
services:
masks:
image: ghcr.io/masksrb/masks:latest
environment:
POSTGRES_HOST: postgres
POSTGRES_USER: masks
POSTGRES_PASSWORD: ...
POSTGRES_DATABASE: masks
MASKS_MIGRATION_USER: masks_owner
MASKS_MIGRATION_PASSWORD: ...
MASKS_TENANTS: acme
MASKS_PUBLIC_ORIGIN_TEMPLATE: https://%{subdomain}.auth.example.com
volumes:
- masks-storage:/rails/storage
depends_on:
- postgres
logging:
options:
max-size: 10m
max-file: "3"
postgres:
image: postgres:17-alpine
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: ...
volumes:
- postgres:/var/lib/postgresql/data
- ./initdb:/docker-entrypoint-initdb.d:ro
caddy:
image: caddy:2-alpine
ports:
- "443:443"
- "80:80"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy-data:/data
depends_on:
- masks
volumes:
postgres:
masks-storage:
caddy-data:
*.auth.example.com {
reverse_proxy masks:3000
}

initdb/01-roles.sql creates the two roles masks connects as when the Postgres volume is first initialized:

CREATE ROLE masks_owner WITH LOGIN PASSWORD '...' CREATEDB;
CREATE ROLE masks WITH LOGIN PASSWORD '...';

Every variable the server reads is in ENV vars.

/up answers up while the server can reach its database, job queue, and cache, and down with a 503 when it cannot reach one of them. A health check on /up therefore also catches a stopped job queue. The logging options cap each container’s log, so a repeating fault cannot fill the disk.

masks keeps tenants apart with row-level security in Postgres. A superuser bypasses it, and the role that owns a table can switch it off with one ALTER TABLE, so masks connects as two roles.

Role Variable Holds
masks_owner MASKS_MIGRATION_USER The databases and every table in them. CREATEDB, and nothing else.
masks POSTGRES_USER SELECT, INSERT, UPDATE, and DELETE on the tables, and the sequences behind them.

On every boot, the image’s entrypoint runs db:prepare and masks:grants as the migration role, then starts the server as the serving role. It refuses to start when MASKS_MIGRATION_USER is unset or names the serving role, and the server refuses to answer a request while its role owns a table that row-level security protects.

A server that migrated and served as one role owns everything it created. Before you upgrade, run this as a Postgres superuser in each of the three databases, such as masks, masks_cache, and masks_queue:

CREATE ROLE masks_owner WITH LOGIN PASSWORD '...' CREATEDB;
REASSIGN OWNED BY masks TO masks_owner;

CREATE ROLE only needs to run once, because roles belong to the whole server. Then set MASKS_MIGRATION_USER and MASKS_MIGRATION_PASSWORD and start the new image. Its first boot grants masks the access it serves with.

For each tenant, the database holds accounts, sign-in factors, clients, tokens, signing keys, and activity. Columns that hold secrets, such as signing keys, authenticator app secrets, provider and adapter credentials, and connected accounts’ tokens, are also encrypted with Active Record Encryption (AES-256-GCM). Reading them requires the encryption keys as well as the database.

Four secrets protect this data. None of them appears in the compose file.

Secret Protects
SECRET_KEY_BASE The session cookie. Anyone holding it can forge one.
ENCRYPTION_PRIMARY_KEY The encrypted columns.
ENCRYPTION_DETERMINISTIC_KEY Nothing yet. Rails requires it.
ENCRYPTION_KEY_DERIVATION_SALT The per-column keys derived from the two above.

On first boot, the image’s entrypoint generates a master key, writes it to /rails/storage/master.key, and derives all four from it on every boot. Mount that path to a volume, as above. A secret set as an environment variable takes precedence over the derived one. See ENV vars for MASKS_MASTER_KEY and MASKS_MASTER_KEY_FILE.

A tenant is a separate set of accounts, clients, and signing keys at its own subdomain. The compose file above declares Multi tenancy. Replace its MASKS_TENANTS line to change the mode.

Mode In masks:’s environment: An unseen hostname
Single MASKS_TENANT: acme Served by that one tenant.
Multi MASKS_TENANTS: acme,demo 404.
Dynamic Neither (remove the line) Claims a new tenant, since the template has %{subdomain}.

See declaring tenants for how each mode works. MASKS_SETUP_TOKEN sets one setup token for every tenant. See setting a tenant up.

A tenant can also serve sign-in from a host of its own, such as login.example.com.

  1. In /manage, open Settings → Domains, claim example.com, and publish the TXT record it shows. masks checks the record every hour.
  2. Point login.example.com at the server with a CNAME or an address record.
  3. Under Serve sign-in from your domain, enter the host and choose Serve. Only an owner can, and the host must be within a domain the tenant has proven.

The tenant answers at both its usual address and the host. Each is a separate issuer, so an app names the one it uses. A passkey works only on the address it was made on. When the proof lapses or the domain is released, masks stops serving the host.

Caddy can issue each host a certificate on demand. Before issuing one, it asks /tls/allowed?domain=, which answers 200 only for a host a tenant serves.

{
on_demand_tls {
ask http://masks:3000/tls/allowed
}
}
*.auth.example.com {
reverse_proxy masks:3000
}
https:// {
tls {
on_demand
}
reverse_proxy masks:3000
}

masks is a small Rails app with no fixed CPU or memory minimum. Two variables size it.

Variable Default Sizes
RAILS_MAX_THREADS 3 Puma’s threads. When set, also the size of each database connection pool.
JOB_CONCURRENCY 1 The job supervisor’s worker processes, with three threads each.

Raise either if requests or jobs queue. See Processes.

Terminal window
curl https://acme.auth.example.com/.well-known/openid-configuration

The discovery document confirms the tenant’s issuer, keys, and public origin. Then connect an app.