📖 Read the documentation website — overview, run and test instructions, architecture and class diagrams, sequence diagrams for the main flows, the API reference, and the full requirements specification.
A centralized identity management API built with ASP.NET Core (.NET 10). It provides person management, application (non-human identity) management, authentication and authorization, password recovery, email verification, and Google Sign-In for multiple client systems through scope-based multi-tenancy — each client system operates within its own isolated scope.
- Multi-tenant by scope. Every
Userbelongs to exactly one scope; aScopeAdminowns one or more scopes; aSystemAdmingoverns the whole system and belongs to no scope. - Persons & applications. Manages human identities (persons) and non-human identities (applications, each owned by a Scope Admin who owns the application's scope).
- Scope-specific permissions. Each scope can define its own permissions, and a permission
flagged
IncludeAsJwtClaimis folded into the JWT issued to identities acting within that scope. - Authentication. Password-based login (JWT), password recovery, email verification, optional Google Sign-In per scope, and optional two-factor authentication (authenticator app and/or email, with single-use recovery codes).
- Deletion strategies. Both logical (soft) and hard deletion, with well-defined cascade rules.
- Layered (DDD) architecture. Domain, Application (CQRS: Command/Query/Shared), Infrastructure (EF Core data layer), and Presentation (Web API).
For the full picture — vision, requirements, use cases, the technology stack, and testing standards — see Documentation.
The full documentation is published as a website: https://docs.artur-rios.tech/heimdall-docs/docs/
| Section | What it covers |
|---|---|
| Overview | The domain vocabulary — scopes, persons, roles, applications, permissions — and the rules that bind them |
| Getting started | Prerequisites, configuration, migrations, and running the API |
| Architecture | Layers, CQRS, the auditing decorator, and the request pipeline — with class diagrams |
| Domain model | Entities, relationships, and deletion cascades — with a class diagram |
| API reference | Every endpoint, who may call it, and the use case it implements |
| API explorer | Swagger UI over the generated OpenAPI document — parameters, schemas, and responses |
| Flows | Sequence diagrams for login, two-factor, Google Sign-In, onboarding, and audit logging |
| Operations | Migrations, health checks, logging, rate limiting, and the integrations |
| Environments and deployment | Local on Docker Desktop, and development, homologation and production on the VPS — env files and each PostgreSQL database |
| Changelog | Every release, and what is unreleased |
| Contributing | Building, testing, branching, commits and releasing |
The site lives in heimdall-docs, which shows this repository's requirements, OpenAPI document and coverage report as copies; how to change it is in CONTRIBUTING.md.
These are the specification, and they remain plain Markdown under
docs/requirements — the website shows copies of those files,
and this is where they are authoritative:
- Vision Document — product vision, stakeholders, and goals.
- System Requirements Document — functional and non-functional requirements, data model, endpoints, and authorization matrix.
- Use Case Specification Document — the use cases with flows and alternative flows.
- Operations & Infrastructure Document — hosting, the four environments, technical foundation and the health-check feature.
- Technology Stack Document — the technologies, libraries, and versions the project is built on.
- Data Retention Schedule Document — how long each category of personal data is kept and why (NFR-19).
- Data Protection Document — the record of processing activities, the controller/processor allocation, and the international transfers.
- Data Protection Impact Assessment — the risks the system creates for data subjects, and what is done about them.
- Privacy Notice — what is held and what a data subject can make the system do, with the Encarregado's published contact.
- Data Processing Agreement — the terms a tenant organisation executes, and the sub-processor list.
- Incident Response Document — what counts as a personal data breach, the notification clocks, and the register.
- Testing Specification Document — how each use case is tested (unit + functional standards).
- Development Workflow Document — how a use case goes from backlog to merged (branch, issue status, testing gate, PR).
Delivery tracker for the use cases in the Use Case Specification Document, plus the platform and compliance work that is not itself a use case. Each one ships on its own branch, issue, and pull request — see the Development Workflow Document.
Every item below is also on the project board, which is the live view; this table is the one you can read without leaving the repository.
Legend: ✅ done and merged · 🚧 in progress · ⬜ not started
| Milestone | State | Covers |
|---|---|---|
| MVP | ✅ 18 / 18 | Scopes, persons, roles, authentication, password recovery, email verification |
| Google Sign-In | ✅ 6 / 6 | Google-authenticated identities and their administration |
| LGPD & GDPR Compliance | 🚧 18 / 20 | Data subject rights, retention, records, breach response |
The compliance milestone is not complete, and the three items outstanding are marked
controller-action: they need a decision or a contract from the controller and cannot be closed by
writing code. Marking it complete while they stand would misrepresent the system's position — see
Data protection & compliance below.
| Use case | Status | Issue |
|---|---|---|
| UC-01: Create Scope | ✅ | #2 |
| UC-02: View Scope | ✅ | #3 |
| UC-03: Update Scope | ✅ | #4 |
| UC-04: Logical Delete Scope | ✅ | #5 |
| UC-05: Hard Delete Scope | ✅ | #6 |
| UC-21: Add Scope Owner | ✅ | #22 |
| UC-22: Remove Scope Owner | ✅ | #23 |
| UC-23: Promote User to Scope Owner | ✅ | #24 |
| UC-58: Withdraw a Scope Owner Invitation | ✅ | — |
| UC-59: List My Scope Invitations | ✅ | — |
| UC-60: Accept a Scope Owner Invitation | ✅ | — |
| UC-61: Decline a Scope Owner Invitation | ✅ | — |
| Use case | Status | Issue |
|---|---|---|
| UC-06: Create Person | ✅ | #7 |
| UC-07: View Person | ✅ | #8 |
| UC-08: Update Person | ✅ | #9 |
| UC-09: Logical Delete Person | ✅ | #10 |
| UC-10: Hard Delete Person | ✅ | #11 |
| Use case | Status | Issue |
|---|---|---|
| UC-11: Login (Authenticate) | ✅ | #12 |
| UC-12: Request Password Recovery | ✅ | #13 |
| UC-13: Reset Password | ✅ | #14 |
| UC-14: Verify Email | ✅ | #15 |
| UC-15: Resend Verification Email | ✅ | #16 |
| UC-51: Send a Verification Email on a Person's Behalf | ✅ | #177 |
| UC-52: Send a Password Reset on a Person's Behalf | ✅ | #177 |
| UC-36: Enable Two-Factor Authentication | ✅ | #68 |
| UC-37: Confirm Two-Factor Authentication Setup | ✅ | #69 |
| UC-38: Verify Second Factor | ✅ | #70 |
| UC-39: Disable Two-Factor Authentication | ✅ | #71 |
| UC-40: Regenerate Recovery Codes | ✅ | #72 |
| UC-46: Resend Second-Factor Email Code | ✅ | #133 |
| UC-47: Register a Password User | ✅ | #169 |
| UC-49: List Pending Registrations | ✅ | #175 |
| UC-50: Discard a Pending Registration | ✅ | #175 |
| Use case | Status | Issue |
|---|---|---|
| UC-16: Create Application | ✅ | #17 |
| UC-17: View Application | ✅ | #18 |
| UC-18: Update Application | ✅ | #19 |
| UC-19: Logical Delete Application | ✅ | #20 |
| UC-20: Hard Delete Application | ✅ | #21 |
| Use case | Status | Issue |
|---|---|---|
| UC-53: Issue an Application Credential | ✅ | #178 |
| UC-54: List an Application's Credentials | ✅ | #178 |
| UC-55: Revoke an Application Credential | ✅ | #178 |
| UC-56: Obtain an Application Token | ✅ | #178 |
| UC-57: Check an Application Token | ✅ | #178 |
| Use case | Status | Issue |
|---|---|---|
| UC-31: Create Scope Permission | ✅ | — |
| UC-32: View Scope Permission | ✅ | — |
| UC-33: Update Scope Permission | ✅ | — |
| UC-34: Logical Delete Scope Permission | ✅ | — |
| UC-35: Hard Delete Scope Permission | ✅ | — |
These five were delivered together on one branch rather than one branch, issue, and pull request each, so they carry no issue link. See the Development Workflow Document for the standard the rest of the tracker follows.
| Use case | Status | Issue |
|---|---|---|
| UC-24: Enable/Disable Google Sign-In | ✅ | #25 |
| UC-25: Sign Up / Sign In via Google | ✅ | #26 |
| UC-26: Sign Out via Google | ✅ | #27 |
| UC-27: View Google User | ✅ | #28 |
| UC-28: Logical Delete Google User | ✅ | #29 |
| UC-29: Hard Delete Google User | ✅ | #30 |
The rights LGPD Art. 18 and GDPR Chapter III give the people whose data this holds. All self-service: none requires an administrator's cooperation.
| Use case | Status | Issue |
|---|---|---|
| UC-41: Export Own Personal Data | ✅ | #90 |
| UC-42: Request Erasure of Own Identity | ✅ | #91 |
| UC-43: List Outstanding Erasure Requests | ✅ | #91 |
| UC-44: Restrict Processing of Own Identity | ✅ | #93 |
| UC-45: Lift a Restriction on Processing | ✅ | #93 |
UC-43 shipped with UC-42 and UC-45 with UC-44 — each pair is one right and its administrative counterpart, and splitting them would have left a request path with nowhere to be resolved.
The LGPD & GDPR Compliance milestone. Where an item added a requirement, the NFR is named; where it produced a document, the document is linked.
| Item | Status | Issue |
|---|---|---|
| Data retention schedule (NFR-19) | ✅ | #95 |
| Purge expired single-use tokens | ✅ | #96 |
| Erasure that actually erases — anonymisation (NFR-20) | ✅ | #92 |
| Audit attributions cleared once due (NFR-21) | ✅ | #97 |
| Log retention and address redaction (NFR-22) | ✅ | #98 |
| Lawful basis and notice version per identity (NFR-23) | ✅ | #94 |
| Restriction of processing (NFR-24) | ✅ | #93 |
| Encryption at rest, backups, restore reconciliation (NFR-25) | ✅ | #106 |
| Security signal detection (NFR-26) | ✅ | #105 |
| Data minimisation review | ✅ | #99 |
| Record of processing activities | ✅ | #100 |
| Privacy Notice and Encarregado | ✅ | #101 |
| Data Processing Agreement and sub-processors | ✅ | #102 |
| International transfer assessment | ✅ | #103 |
| Data Protection Impact Assessment | ✅ | #104 |
| Incident Response and breach register | ✅ | #105 |
| Data subject export (UC-41) | ✅ | #90 |
None of these three could be closed by code. Each needed a decision, a contract, or knowledge of infrastructure the repository cannot see, and each is now answered.
| Item | Status | Issue |
|---|---|---|
| Determine whether any scope serves EEA data subjects | ✅ | #124 |
| Confirm encryption at rest, backup regime, and alert routing | ✅ | #125 |
| Execute contractual clauses with Mailgun and Google | ✅ Not needed | #123 |
#124 was answered yes — nothing restricts access by geography, so GDPR applies under Art. 3(2). That turned out to be narrower than it sounds: a person in the EEA signing up directly is not a Chapter V transfer, because there is no exporter (EDPB Guidelines 05/2021). Only an EEA tenant creates one, and the DPA now incorporates the EU SCCs Module Two, which is the tenant's own document to sign rather than an agreement to negotiate.
#125 was answered in three parts. The database volume and the backups are encrypted; backups are
daily, kept 35 days, and held by MEGA — encrypted client-side, so MEGA holds no key that
decrypts them. That last fact is what grounds the transfer, since a backup is a full copy of
everything: LGPD Art. 33 IX with Art. 7 V, GDPR Art. 45 adequacy, and EDPB Recommendations 01/2020
Use Case 1 as the supplementary measure that survives an adequacy decision being withdrawn. The
alert-routing half is closed by scripts/security_signals.py, which collects SECURITY_SIGNAL
lines on a schedule and mails what is new — see
Security signals. Until it existed the detection built for
NFR-26 wrote warnings nobody read, which for Art. 33's purpose is not detection at all: both
notification clocks run from awareness.
#123 — executing contractual clauses with Mailgun and Google — is closed as not planned, and the transfer it covered is lawful anyway. Two things resolved it: the Google transfer turned out not to exist, because the ID token is validated offline against cached public certificates and is never sent to Google; and the email transfer rests on LGPD Art. 33 IX with Art. 7 V, a statutory ground needing no counterparty agreement. Nothing was signed. See §6.1 and §7.1 of the Data Protection Document.
| Use case | Status | Issue |
|---|---|---|
| UC-48: List the Audit Trail | ✅ | #174 |
Not use cases, tracked separately.
| Item | Status | Issue |
|---|---|---|
| Project scaffolding & initial infrastructure | ✅ | #31 |
| Health check (liveness + detailed dependency check, UC-30) | ✅ | #32 |
| Audit logging for write operations (NFR-09) | ✅ | #78 |
Real email delivery (Mailgun, via ArturRios.Messaging) |
✅ | — |
Email delivery closed with UC-12: both the verification and password reset emails now go through Mailgun (see Email delivery). The logging senders survive as the fallback for environments without credentials, which is what keeps the functional suite off the network.
-
.NET 10 SDK
-
PostgreSQL (the API's relational database; functional tests spin up their own via Testcontainers)
-
Python 3 (only to run the migration menu script)
-
Docker (only to deploy with Compose — see Deploy with Docker)
-
The pinned EF Core CLI tool — restore it once after cloning:
dotnet tool restore
Environments/.env is a tracked template listing every variable the API reads. Real values live in
per-environment files that are gitignored. Create your local one before the first run:
cp src/Presentation/ArturRios.Heimdall.WebApi/Environments/.env src/Presentation/ArturRios.Heimdall.WebApi/Environments/.env.localThen fill it in. These are required — the API fails to start without them:
| Variable | Value |
|---|---|
HEIMDALL_DATA_CONNECTIONSTRING |
A PostgreSQL connection string |
HEIMDALL_DATA_DATABASETYPE |
PostgreSql |
HEIMDALL_AUTH_SIGNING_KEY |
The ES256 private key every token is signed with: the one-line base64 of a P-256 PKCS#8 PEM (openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 -out signing.pem, then base64 -w0 signing.pem). Startup throws if it is unset or unusable, naming the variable and never the value |
HEIMDALL_APPLICATION_TOKEN_AUDIENCE |
The aud of application tokens (UC-56). Startup throws if it is unset, or equal to HEIMDALL_AUTH_TOKEN_AUDIENCE: a distinct audience is what stops a consumer taking an application token for a person's. docker-compose.yml defaults it to heimdall-applications and the docker/*.env.example templates preset that value; the Web API's Environments/.env holds only a placeholder |
HEIMDALL_MASTER_USER_NAME / _EMAIL / _PASSWORD |
Credentials for the first system administrator. Only consulted when the database holds no system admin yet, but the seeder refuses to start without them in that case |
The rest are optional and fall back to a default:
| Variable | Default |
|---|---|
HEIMDALL_AUTH_TOKEN_ISSUER / _AUDIENCE |
Empty |
HEIMDALL_AUTH_TOKEN_EXPIRATION_IN_SECONDS |
3600 (1 hour) |
HEIMDALL_AUTH_SIGNING_KEY_PREVIOUS |
Empty (set only while rotating the signing key, to the key being retired, in the same format: its public half stays published and its tokens stay valid, but it never signs) |
HEIMDALL_APPLICATION_TOKEN_EXPIRATION_IN_SECONDS |
600 (10 minutes) |
HEIMDALL_AUTH_MAX_CONCURRENT_PASSWORD_HASHES |
4 (~2.4 GB of Argon2id working set) |
HEIMDALL_INSECURE_TEST_PASSWORD_HASHING |
Unset: the production Argon2id cost (600 MB, 16 lanes). true selects the cheapest cost Argon2id accepts, for the test suite only (tests/default.runsettings sets it). Startup refuses it in Production and Staging, and no hash stored under it verifies once it is removed, so never set it in a deployment |
HEIMDALL_EMAIL_VERIFICATION_TOKEN_EXPIRATION_IN_SECONDS |
86400 (24 hours) |
HEIMDALL_PASSWORD_RESET_TOKEN_EXPIRATION_IN_SECONDS |
3600 (1 hour) |
HEIMDALL_ADMIN_EMAIL_COOLDOWN_IN_SECONDS |
300 (5 minutes) — the per-person cooldown on account email: administrator-sent links, anonymous password recovery, and a person's own resends, setup codes and email-change notices (which are also capped at five per day-long window); 0 turns the cooldown off |
HEIMDALL_SCOPE_INVITATION_LIFETIME_IN_SECONDS |
604800 (7 days) — how long a Scope Admin's invitation to co-own a scope can be accepted; 3600 to 2592000, anything else falls back to the default |
HEIMDALL_REGISTRATION_MAX_PENDING_PER_SCOPE |
1000 — the most live pending self-registrations one scope may hold; a registration beyond it stores and mails nothing, is answered as any other, and is logged with REGISTRATION_CAP_REACHED. Any positive integer; anything else falls back to the default |
HEIMDALL_LOG_DIRECTORY |
logs |
HEIMDALL_METRICS_PORT |
9464 — the Prometheus scrape port; 0 switches metrics off (see Metrics) |
HEIMDALL_CORS_ALLOWED_ORIGINS |
Empty — every cross-origin request is refused. Set it to the front end's origin (comma separated for several), or a browser client cannot call the API |
HEIMDALL_TRUSTED_PROXIES |
Empty — X-Forwarded-For and X-Forwarded-Proto are ignored, and start-up warns. Behind a reverse proxy, set it to the proxy's addresses or CIDR networks (comma separated) so the rate limiter and the request log see the real caller |
HEIMDALL_TRUSTED_CALLER_KEYS |
Empty — no client system may name its users' addresses to the anonymous-auth rate limiter. Comma-separated secrets of at least 32 characters (shorter ones stop start-up); a request with one in X-Heimdall-Caller-Key is counted under the address in X-Heimdall-Client-Address (see Operations) |
The data retention, security monitoring and database TLS settings are listed with their defaults under Environment variables at a glance on the documentation site.
Verification (UC-06) and password reset (UC-12) emails go out through Mailgun, using
ArturRios.Messaging. Delivery is enabled only
when both credentials are present:
| Variable | Value |
|---|---|
MAILGUN_API_KEY |
Your Mailgun private API key |
MAILGUN_DOMAIN |
The Mailgun sending domain |
MAILGUN_API_VERSION |
Optional; defaults to v3 |
MAILGUN_FROM |
Optional; the sender, as Name <address> or a bare address, e.g. Heimdall <no-reply@example.com>. Unset, mail goes out from a bare postmaster@<MAILGUN_DOMAIN> |
Leave them unset — as local runs and the functional test suite do — and the API logs each token instead of emailing it, warning once at start-up. That is a supported mode, not a broken one: it keeps the API runnable without credentials and keeps the test suite off the network.
Both emails carry a link built from these, with the token appended as a token query parameter. If
a link is not configured the email carries the bare token instead, which still works — the link is
only a convenience wrapper around it.
| Variable | Value |
|---|---|
HEIMDALL_EMAIL_VERIFICATION_URL |
Front-end page that verifies an email address |
HEIMDALL_PASSWORD_RESET_URL |
Front-end page that sets a new password |
Each page finishes the job by posting its token back: the verification page to
POST /api/auth/verify-email (UC-14), the reset page to POST /api/auth/password-reset (UC-13),
along with the new password. Both endpoints are anonymous, since someone arriving from a link in
their mail client holds no token of any other kind.
Spending a token of either kind retires every other live token the person holds of that kind. A second "forgot password" click cannot be replayed after the first has already changed the password, and a verification link left in an inbox stops working once the address is verified.
A verification link that expired, was lost, or never arrived is replaced through
POST /api/auth/resend-verification (UC-15). That one is authenticated and takes no body — the
person is read from the bearer token, so a caller can only ever ask for their own link — and it
retires the outstanding ones before mailing a new one, so only the newest link works. An address that
is already verified is refused: a link mailed to it could do nothing when clicked.
A Mailgun failure is logged, never surfaced.
POST /api/auth/password-recoverymust answer identically whether or not the address belongs to anyone, so an outage cannot be allowed to turn into a 500 that tells an anonymous caller their guess was a real account.
POST /api/auth/google (UC-25) signs a Google account up or in against a scope: the caller sends the
ID token they obtained from Google plus the scope's PublicId, and gets back the same kind of bearer
token a password login yields. The first call for a given Google account in a given scope creates the
Google User; every later one authenticates it.
Two things must be true before it works. The scope must have Google Sign-In switched on through
PUT /api/scopes/{id}/google-signin (UC-24) — it is off by default — and the API must know which
OAuth client the tokens were minted for:
| Variable | Value |
|---|---|
HEIMDALL_GOOGLE_CLIENT_IDS |
Comma-separated Google OAuth client IDs accepted as an ID token's audience |
Leave it unset and Google sign-in refuses every token with a 401, warning once at start-up. Unlike the email fallback, this is not a convenience mode: verification needs an audience to check against (NFR-13), and a verifier with no configured client could only reject everything or trust everything — so it rejects. Every other endpoint keeps working; a scope that never enabled the feature is unaffected either way.
Google sign-in only ever produces a User-equivalent identity (FR-GO-04). It cannot create or
authenticate a Scope Admin or System Admin, whatever the Google account is.
POST /api/auth/google/sign-out (UC-26) ends a Google session. It revokes nothing — tokens here are
stateless signed JWTs with an expiry, the strategy UC-11 established — so the endpoint confirms the
caller still holds a live Google session and answers the success that tells the client to drop the
token. That check is the substance: authentication reads no database per request, so a token outlives
the account once it is deleted, and one naming a deleted or unknown Google User is refused.
The Google Users of a scope are administered through four endpoints, all nested under the scope they belong to (FR-GO-06):
| Endpoint | Use case | Who |
|---|---|---|
GET /api/scopes/{scopeId}/google-users |
UC-27 | System Admin, or an owner of the scope |
GET /api/scopes/{scopeId}/google-users/{id} |
UC-27 | The above, or the Google User themselves |
DELETE /api/scopes/{scopeId}/google-users/{id} |
UC-28 | System Admin, or an owner of the scope |
DELETE /api/scopes/{scopeId}/google-users/{id}/hard |
UC-29 | System Admin only |
The by-id read is the one that admits a Google User, and it is why that endpoint carries no role
attribute: a Google User's token is User-role, so anything strong enough to keep other Users out
would lock out the actor the use case grants. The listing admits none of them.
Both deletions accept a scope's PublicId in the path and refuse a Google User reached through the
wrong one. The logical delete is idempotent — repeating it answers 200 with alreadyDeleted: true
and writes nothing — and is honoured everywhere it matters: a deleted account cannot sign in
(UC-25), cannot sign out, and is absent from reads unless includeDeleted=true. The hard delete has
no idempotent path; a second call is a 404. Neither cascades, because a Google User owns nothing.
The schema is managed with EF Core migrations, applied explicitly — the API process never migrates (the
container entrypoint does by default unless HEIMDALL_RUN_MIGRATIONS is set to something other than true),
and refuses to start when migrations are pending. Apply them before the first run, and after
every update, with the interactive migration menu:
python scripts/migrations.pyIt asks which environment file to load (for the connection string), then offers the migration
actions. Requires dotnet tool restore (above) to have been run once. The Docker image applies them
itself — see What the container does at start-up.
dotnet run --project src/Presentation/ArturRios.Heimdall.WebApiServes on http://localhost:5177 (the http launch profile); the https profile adds
https://localhost:7235.
To call it, api-client/ holds ready-to-send requests for every operation in
docs/openapi/heimdall.json as both .http files and a Bruno collection, chained
so no identifier has to be copied by hand — see api-client/README.md.
Swagger UI is at http://localhost:5177/swagger, with JWT wired in — press Authorize, paste a
token, and call any endpoint. It is built from the same SwaggerConfiguration that
python scripts/openapi.py writes to docs/openapi/heimdall.json, so the
API explorer publishes byte-for-byte
what a running instance serves.
docker-compose.yml runs the API in a container against a PostgreSQL
instance that is already installed on the host — the same instance other services and
environments share, each owning a database of its own. Postgres is deliberately not a service in the
file.
Four environments, one Compose file, one env file each:
| Environment | Where | Deployed by | ASPNETCORE_ENVIRONMENT |
Public hosts | Env file template | Database |
|---|---|---|---|---|---|---|
local |
The developer's Windows machine, Docker Desktop | Hand: docker compose --env-file docker/local.env up -d --build |
Development |
http://localhost:8080 |
docker/local.env.example |
heimdall_local, PostgreSQL on Windows |
development |
The VPS, on demand | Jenkins, on every push to develop |
Development |
heimdall-api-dev.example.com, and /api/ under heimdall-dev.example.com |
docker/development.env.example |
heimdall_development, PostgreSQL on the VPS |
homologation |
The VPS, on demand | Jenkins, on every push of a release/x.y.z branch |
Staging |
heimdall-api-hml.example.com, and /api/ under heimdall-hml.example.com |
docker/homologation.env.example |
heimdall_homologation, PostgreSQL on the VPS |
production |
The VPS, always on | Jenkins, on a green release/x.y.z → main pull request, which it then merges and tags |
Production |
heimdall-api.example.com, and /api/ under heimdall.example.com |
docker/production.env.example |
heimdall, PostgreSQL on the VPS |
example.com stands for the real domain. Staging keeps homologation off Mailgun — verification and
reset e-mails are logged, as in Development — without serving Swagger or the developer exception page.
Local is deployed by hand. Copy the template and fill it in — it documents what each variable expects:
cp docker/local.env.example docker/local.envThen, from the repository root:
docker compose --env-file docker/local.env up -d --buildThe real env files are gitignored: they hold the database password, the token signing key and the master user's credentials.
Development, homologation and production share one Ubuntu VPS and are deployed by
yggdrasil: Jenkins applies yggdrasil's
stacks/heimdall-api.proxy.yml on top of this Compose file — no host port, Traefik in front with a
wildcard certificate, the project named heimdall-api-<environment> — and reads the env file from
/etc/yggdrasil/<environment>/heimdall-api.env on the VPS, filled in from the matching template.
Each environment has its own database, login, signing key and master user. Development and
homologation run only while they are used: a deploy leaves a stopped environment stopped, and
scripts/ygg.sh env start <environment> on the VPS turns one on (env stop turns it off again).
For the full walkthrough — every command, what each host's Postgres has to allow, and the errors each misconfiguration produces — see Environments and deployment.
The image carries an EF Core migrations bundle built from
src/Infrastructure/ArturRios.Heimdall.Data/Migrations. The entrypoint applies pending migrations
and only then starts the API, which is what lets a container be pointed at an empty database — the
API itself never migrates, and refuses to start while migrations are pending. Set
HEIMDALL_RUN_MIGRATIONS=false (any value other than true skips it) to apply them out of band instead (python scripts/migrations.py),
which is required if the environment ever runs more than one replica.
The bundle migrates with HEIMDALL_MIGRATIONS_CONNECTIONSTRING when it is set and with the API's
own connection string otherwise. Set it to the schema owner's, and the API can run as a database
role that owns nothing, which is what makes the audit trail's triggers hold against the API's own
credentials (Threat Model TH-18); scripts/sql sets the roles up, and the operations guide's
"Database roles" gives the order.
The container serves Prometheus metrics at /metrics on port 9464, and only there — on the
API's port 8080 the path is not served. Prometheus is meant to scrape the container directly over a
Docker network it shares with it (heimdall-api.<environment>:9464 under yggdrasil), so Compose
deliberately does not publish 9464: the endpoint is told apart by the port a connection arrives
on rather than by the Host header, which Traefik forwards from the client and an attacker
therefore controls. Publishing the port would undo that.
HEIMDALL_METRICS_PORT moves the endpoint (blank = 9464) or, set to 0, switches metrics off. A
port other than 9464 must also be added to ASPNETCORE_HTTP_PORTS (the image sets 8080;9464).
What is collected, and the rules for keeping the port private behind Traefik, are in
Operations → Metrics.
The container reaches Postgres over the bridge network, not over the loopback interface the host's
own processes use, so an installation that only listens on localhost is unreachable from it:
-
listen_addressesmust include the address the container connects to (*binds all interfaces — pair it with a firewall that does not expose 5432 publicly). -
pg_hba.confneeds a line for the Docker bridge range, e.g.host heimdall_development heimdall_development_svc 172.16.0.0/12 scram-sha-256. -
The database and its two roles must exist — an owner the migrations run as, and a login the API runs as, which owns nothing; each service and each environment on the shared instance gets its own, so that development and homologation cannot reach production's data. On the VPS, from a copy of
scripts/sql:sudo -u postgres psql -X -v owner=heimdall_owner -v runtime=heimdall_svc -v database=heimdall -f new-deployment.sql sudo -u postgres psql -X -v owner=heimdall_development_owner -v runtime=heimdall_development_svc -v database=heimdall_development -f new-deployment.sql sudo -u postgres psql -X -v owner=heimdall_homologation_owner -v runtime=heimdall_homologation_svc -v database=heimdall_homologation -f new-deployment.sql
then each login's password with
\password, and onepg_hba.confline per login, so each reaches its own database and no other. A database that belongs to the API's own login, as this README used to create it, is moved over withseparate-owner.sql; the operations guide's "Database roles" has both procedures, in order.
Docker Desktop on Windows is the exception: it forwards the connection through its own VM, so the
Windows Postgres sees it arrive from 127.0.0.1 and the stock pg_hba.conf already covers it —
neither an extra rule nor a firewall opening is needed there. The VPS's plain engine connects over
the bridge for real, and needs both of the above.
Check reachability before the first deploy:
docker run --rm --add-host host.docker.internal:host-gateway alpine sh -c 'nc -z -w 3 host.docker.internal 5432 && echo reachable || echo unreachable'Compose pins Search Path=public in the connection string, and the reason is worth knowing before
anyone changes it. EF names the migrations history table unqualified, so Postgres resolves it through
the search path — whose default, "$user", public, makes the login's name a schema lookup. The
entities live in a schema called heimdall, so a login also named heimdall sends the second run
looking for its history in that schema, where the first run wrote none: it concludes nothing was ever
applied and dies on relation "role" already exists. The pin makes every run read the table the
first one wrote, whatever the login is called.
The same trap applies when running from source — Environments/.env.local and
scripts/migrations.py share the connection string, not this file — so either keep Search Path=public in those connection strings too, or do not name the login heimdall.
Notable changes in each release are recorded in CHANGELOG.md.
Building from source, running the tests, authoring migrations, regenerating the OpenAPI document, the branching model and the release process are described in CONTRIBUTING.md.
The GitHub repository is a read-only mirror for the real, open-source code of heimdall-api. Its home is Forgejo, at https://git.techcraftsmen.net/artur-rios/heimdall-api. If you want to contribute, e-mail me at arturdev@duck.com.
This project is licensed under the Artur Rios Attribution License. It is free and open source: you may view, use, fork, modify, and extend it, provided you credit the author, Artur Rios, with a link to artur-rios.tech or github.com/artur-rios.
Copyright © 2026 Artur Rios.