Self-hosting
masks ships as a container image. Point it at a Postgres server, and it migrates itself on boot.
docker pull ghcr.io/masksrb/masks:latestWhat it runs
Section titled “What it runs”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.
Image tags
Section titled “Image tags”| 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.0Example compose.yml
Section titled “Example compose.yml”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.
Database roles
Section titled “Database roles”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.
Moving an existing database to two roles
Section titled “Moving an existing database to two roles”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.
Encryption & secrets
Section titled “Encryption & secrets”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.
Multi-tenancy
Section titled “Multi-tenancy”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.
Custom domains
Section titled “Custom domains”A tenant can also serve sign-in from a host of its own, such as login.example.com.
- In
/manage, open Settings → Domains, claimexample.com, and publish the TXT record it shows. masks checks the record every hour. - Point
login.example.comat the server with a CNAME or an address record. - 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}System requirements
Section titled “System requirements”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.
Next steps
Section titled “Next steps”curl https://acme.auth.example.com/.well-known/openid-configurationThe discovery document confirms the tenant’s issuer, keys, and public origin. Then connect an app.