Environments and deployment

The four environments — local on Docker Desktop, development, homologation and production on one VPS through yggdrasil — and, step by step, what the PostgreSQL behind each has to allow.

The API runs in four environments. All of them use the same image and the same docker-compose.yml ; what differs between them is the env file, who deploys it and, mostly, what the host’s PostgreSQL has to be told to allow.

EnvironmentWhereDeployed byASPNETCORE_ENVIRONMENTAPI atDatabase
localThe developer’s Windows machine, Docker DesktopBy hand, docker compose --env-file docker/local.env up -d --buildDevelopmenthttp://localhost:8080heimdall_local, PostgreSQL on Windows
developmentThe VPS, on demandJenkins, on every push to developDevelopmenthttps://heimdall-api-dev.example.comheimdall_development, PostgreSQL on the VPS
homologationThe VPS, on demandJenkins, on every push of a release/x.y.z branchStaginghttps://heimdall-api-hml.example.comheimdall_homologation, PostgreSQL on the VPS
productionThe VPS, always onJenkins, on a green release/x.y.z → main pull request — it then merges and tagsProductionhttps://heimdall-api.example.comheimdall, PostgreSQL on the VPS

example.com stands for the real domain throughout this page. In every VPS environment the API is also served under the web UI’s own host at /api/ (https://heimdall-dev.example.com/api/…, -hml, and https://heimdall.example.com/api/…), which is how heimdall-ui’s browser build calls it without CORS.

What the ASP.NET environment changes:

Development (local, development)Staging (homologation)Production
Swagger UIServedNot servedNot served
Developer exception pageOnOffOff
EF Core parameter loggingOnOnOff
Verification and reset e-mails without MailgunLogged instead of sentLogged instead of sentStart-up fails

Local is deployed by hand on Docker Desktop. Development, homologation and production share one Ubuntu VPS and are deployed by yggdrasil , whose Jenkins runs this repository’s Jenkinsfile . Development and homologation are started only while somebody uses them.

What is being deployed

One service, api, built from the repository’s Dockerfile :

  • A multi-stage build that publishes the Web API and an EF Core migrations bundle. The bundle is a plain executable, so the runtime image never needs the SDK or dotnet-ef.
  • The entrypoint applies pending migrations and only then starts the API — which is what lets a container be pointed at an empty database. Set HEIMDALL_RUN_MIGRATIONS=false to apply them out of band instead; see Operations . With HEIMDALL_MIGRATIONS_CONNECTIONSTRING set, the bundle migrates as that connection’s role — the schema’s owner — and the API runs as a role that owns nothing; see Database roles .
  • The container drops to a non-root user, writes logs to a named volume, and answers a health check on /healthcheck — Compose reports the container healthy only once the API answers.

PostgreSQL is deliberately not a service in the Compose file. Every host already runs one instance shared by several services and environments, each owning a database of its own. So the first half of each walkthrough below is about that instance, not about Docker.

Windows — the local environment

Docker Desktop on the developer’s machine, against the PostgreSQL installed on Windows. Nothing deploys here but the developer: no Jenkins, no Traefik, the port published on the host.

1. Check the engine

In PowerShell, from the repository root:

docker version --format '{{.Server.Version}}'
docker context ls

The context marked * should be desktop-linux. If the command fails, start Docker Desktop and wait for the whale icon to stop animating.

2. Create the database and its login

Each service on the shared instance gets its own database and its own role. With psql on PATH (C:\Program Files\PostgreSQL\18\bin), as the postgres superuser:

psql -U postgres -c "CREATE ROLE heimdall_svc LOGIN PASSWORD '<pick-a-password>';"
psql -U postgres -c "CREATE DATABASE heimdall_local OWNER heimdall_svc;"

3. Confirm the container can reach it

Docker Desktop forwards a container’s connection to host.docker.internal through its own VM, and the Windows PostgreSQL sees it arrive from 127.0.0.1 — so the stock pg_hba.conf, which allows host all all 127.0.0.1/32, already covers it. Nothing to change here, and no Windows Firewall rule to add either, because nothing crosses a real network interface.

Prove it before going further, rather than discovering it from a container that will not start:

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'

And that the credentials themselves work, end to end:

docker run --rm --add-host host.docker.internal:host-gateway postgres:18-alpine psql "postgresql://heimdall_svc:<password>@host.docker.internal:5432/heimdall_local" -c "select 1"

A row of 1 means the container has everything it needs. no pg_hba.conf entry for host "<address>" means the connection arrived from an address the rules do not cover — add host heimdall_local heimdall_svc <that-address>/32 scram-sha-256 to pg_hba.conf and reload the service.

4. Write the env file

Copy-Item docker/local.env.example docker/local.env -Confirm

-Confirm is not decoration: the template’s values are empty, so re-running this over a file already filled in silently un-fills it, and the next up fails with required variable ... is missing a value — which reads like the file was never written rather than like it was overwritten.

docker/local.env is gitignored — it holds the database password, the token signing secret and the master user’s credentials. Open it and fill in at least:

VariableValue
DB_USER / DB_PASSWORDThe login from step 2
DB_NAMEheimdall_local
HEIMDALL_AUTH_TOKEN_SECRETA long random value, unique to this environment: at least 32 bytes of ASCII, or the API will not start (openssl rand -base64 48)
HEIMDALL_APPLICATION_TOKEN_AUDIENCERequired, and different from HEIMDALL_AUTH_TOKEN_AUDIENCE and the audiences derived from it. local.env.example presets heimdall-applications; keep it unless a consumer pins another
HEIMDALL_MASTER_USER_NAME / _EMAIL / _PASSWORDThe first system administrator, seeded on the first run against an empty database

DB_HOST=host.docker.internal is already correct for Docker Desktop. To generate the secret:

[Convert]::ToBase64String((1..48 | ForEach-Object { Get-Random -Maximum 256 }))

Every other variable has a default that suits a local deployment: Swagger and the developer exception page are on, and verification and reset e-mails are written to the log instead of sent, because ASPNETCORE_ENVIRONMENT=Development and the Mailgun variables are empty. The e-mails’ links point at the local heimdall-ui, http://localhost:8081.

5. Build and start

docker compose --env-file docker/local.env up -d --build

The first build takes a few minutes — it restores, publishes, and builds the migrations bundle. Later builds reuse the layer cache, and a source-only change re-runs neither the tool restore nor the package restore.

6. Verify

docker compose --env-file docker/local.env ps

Wait for STATUS to read Up (healthy) — the health check has a 30 s start period, so starting is expected at first. Then:

curl.exe http://localhost:8080/healthcheck

The start-up log should show the migrations applied and then the API listening:

docker compose --env-file docker/local.env logs -f api

Swagger UI is at http://localhost:8080/swagger , and the master user from the env file can log in at POST /api/auth/login. On an empty database the entrypoint’s migration step is where a bad connection string surfaces — read the first twenty lines of the log before anything else.

7. Day-to-day

TaskCommand
Follow the logsdocker compose --env-file docker/local.env logs -f api
Restart after an env changedocker compose --env-file docker/local.env up -d
Rebuild after a code changedocker compose --env-file docker/local.env up -d --build
Stop, keeping the log volumedocker compose --env-file docker/local.env down
Stop and discard the logsdocker compose --env-file docker/local.env down -v
A shell in the containerdocker compose --env-file docker/local.env exec api sh

The env file has to be passed on every Compose command, not just up: it supplies COMPOSE_PROJECT_NAME, so without it Compose looks for a differently-named project and reports nothing running.

The VPS — development, homologation and production

One Ubuntu VPS runs all three, behind yggdrasil’s Traefik, against the PostgreSQL installed on the VPS itself. The VPS’s own setup — Docker, the platform stack, Jenkins, the wildcard certificate and the single *.example.com DNS record that covers heimdall-api-dev, heimdall-api-hml and heimdall-api alike — is yggdrasil’s, and described in its documentation. What follows is what this API needs on top of it.

How yggdrasil deploys it

Jenkins builds the image on the VPS and runs docker compose with this repository’s Compose file plus yggdrasil’s stacks/heimdall-api.proxy.yml, which:

  • removes the host port — Traefik, on the shared edge network, is the only way in, so API_PORT means nothing here;
  • names the project heimdall-api-<environment> (COMPOSE_PROJECT_NAME is ignored too) and gives the container the alias heimdall-api.<environment> on the edge and telemetry networks — Prometheus scrapes heimdall-api.<environment>:9464 there;
  • routes two hosts to it: PUBLIC_HOST (heimdall-api-dev.example.com) for the mobile and desktop clients, and UI_HOST + /api/ (heimdall-dev.example.com/api/) for heimdall-ui’s browser build.

The env file is /etc/yggdrasil/<environment>/heimdall-api.env on the VPS, never in a repository.

PushDeploys toAfterwards
developdevelopmentLeft stopped if it was stopped
release/x.y.zhomologationLeft stopped if it was stopped
Pull request release/x.y.z → main, every check greenproductionJenkins merges the pull request and tags vx.y.z

Development and homologation are on demand. A deploy still builds, starts and health-checks the new version — a broken build is caught and rolled back either way — but an environment that was not running before the deploy is stopped again afterwards. To use one, on the VPS:

scripts/ygg.sh env start development

and scripts/ygg.sh env stop development when done (homologation likewise). The yggdrasil console shows each environment’s state, a stopped on-demand environment as Stopped rather than as a problem.

1. Open PostgreSQL to the bridge network

This is the step that fails silently if skipped. The container connects to host.docker.internal, which Compose maps to the Docker bridge gateway (172.17.0.1) — a real interface, not a forwarded loopback. Ubuntu’s PostgreSQL ships listening on 127.0.0.1 only, so it is unreachable from a container until told otherwise.

Check what it listens on:

ss -ltn | grep 5432

127.0.0.1:5432 alone means the edit below is needed. In /etc/postgresql/<version>/main/postgresql.conf:

listen_addresses = 'localhost,172.17.0.1'

Naming the gateway rather than * keeps the instance off every other interface — including the VPS’s public address. listen_addresses requires a restart:

sudo systemctl restart postgresql

2. Create each environment’s database and login

One PostgreSQL instance, one database and its own logins per environment, so that a development or homologation container — or a leaked development password — cannot reach production’s data. Production keeps the database it has always had, heimdall.

The supported setup runs the migrations as an owner role and the API as a role that owns nothing (NFR-29), so each environment has two logins. For a new environment:

sudo -u postgres createuser --pwprompt heimdall_development_owner
sudo -u postgres createuser --pwprompt heimdall_development_svc
sudo -u postgres createdb --owner heimdall_development_owner heimdall_development

sudo -u postgres createuser --pwprompt heimdall_homologation_owner
sudo -u postgres createuser --pwprompt heimdall_homologation_svc
sudo -u postgres createdb --owner heimdall_homologation_owner heimdall_homologation

then apply the grants and the rest of the steps in Operations, “Database roles” (“A new deployment”). Production’s pair is heimdall_owner and heimdall_svc. An environment whose database belongs to the API’s own login is moved to this setup with the steps in the same section (“Switching an existing deployment over”). Skip what an environment already has.

Then, in /etc/postgresql/<version>/main/pg_hba.conf, one rule per login for the Docker range, below the loopback rules — each login reaches its own database and no other:

host    heimdall                 heimdall_svc                   172.16.0.0/12    scram-sha-256
host    heimdall                 heimdall_owner                 172.16.0.0/12    scram-sha-256
host    heimdall_development     heimdall_development_svc       172.16.0.0/12    scram-sha-256
host    heimdall_development     heimdall_development_owner     172.16.0.0/12    scram-sha-256
host    heimdall_homologation    heimdall_homologation_svc      172.16.0.0/12    scram-sha-256
host    heimdall_homologation    heimdall_homologation_owner    172.16.0.0/12    scram-sha-256

The owner rules are needed only while the container migrates; an environment that migrates out of band gives its owner a local rule instead.

172.16.0.0/12 rather than one network’s subnet: yggdrasil’s edge and telemetry networks, and any network Docker creates later, are all allocated from it. pg_hba.conf needs only a reload:

sudo systemctl reload postgresql

3. Confirm a container can reach each database

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'
docker run --rm --add-host host.docker.internal:host-gateway postgres:18-alpine psql "postgresql://heimdall_development_svc:<password>@host.docker.internal:5432/heimdall_development" -c "select 1"

Repeat the second line for homologation and production. unreachable points back at listen_addresses; no pg_hba.conf entry for host points at the pg_hba.conf rules. And the separation should hold — the development login against production’s database must be refused:

docker run --rm --add-host host.docker.internal:host-gateway postgres:18-alpine psql "postgresql://heimdall_development_svc:<password>@host.docker.internal:5432/heimdall" -c "select 1"

4. Write each environment’s env file

Each environment has a template in the repository — docker/development.env.example, docker/homologation.env.example, docker/production.env.example — already filled in with what that environment needs, example.com standing for the domain. On the VPS, as the user that owns yggdrasil’s secrets directory:

cp docker/development.env.example /etc/yggdrasil/development/heimdall-api.env

Replace example.com with the real domain, then fill in at least:

VariableValue
DB_USER / DB_PASSWORDThat environment’s runtime login (…_svc) from step 2
HEIMDALL_MIGRATIONS_CONNECTIONSTRINGOptional: the owner role’s whole connection string, when the environment has one (Database roles )
HEIMDALL_AUTH_TOKEN_SECRETA long random value of its own (openssl rand -base64 48), at least 32 bytes of ASCII or the API will not start — every service that validates Heimdall’s tokens with this key is given the same value
HEIMDALL_APPLICATION_TOKEN_AUDIENCERequired, and different from HEIMDALL_AUTH_TOKEN_AUDIENCE and the audiences derived from it: the API will not start otherwise. The templates preset heimdall-applications. HEIMDALL_APPLICATION_TOKEN_EXPIRATION_IN_SECONDS is optional and defaults to 600
HEIMDALL_MASTER_USER_NAME / _EMAIL / _PASSWORDThat environment’s first system administrator, seeded on the first run against its empty database
MAILGUN_API_KEY / MAILGUN_DOMAINProduction only — it refuses to start without them
MAILGUN_FROMThe sender, e.g. Heimdall <no-reply@example.com> on the Mailgun domain — without it mail goes out from a bare postmaster@ address

What the templates already set, and how the three differ:

Variabledevelopmenthomologationproduction
PUBLIC_HOSTheimdall-api-dev.example.comheimdall-api-hml.example.comheimdall-api.example.com
UI_HOSTheimdall-dev.example.comheimdall-hml.example.comheimdall.example.com
ASPNETCORE_ENVIRONMENTDevelopmentStagingProduction
HEIMDALL_ALLOW_SENSITIVE_DIAGNOSTICSemptyemptyempty
DB_NAMEheimdall_developmentheimdall_homologationheimdall
HEIMDALL_EMAIL_VERIFICATION_URLhttps://heimdall-dev.example.com/verify-emailhttps://heimdall-hml.example.com/verify-emailhttps://heimdall.example.com/verify-email
HEIMDALL_PASSWORD_RESET_URLhttps://heimdall-dev.example.com/password-resethttps://heimdall-hml.example.com/password-resethttps://heimdall.example.com/password-reset

All three set DB_HOST=host.docker.internal and HEIMDALL_TRUSTED_PROXIES=172.16.0.0/12 — Traefik reaches the container from yggdrasil’s edge network, so forwarded addresses are believed from the Docker range and from nobody else — and leave HEIMDALL_CORS_ALLOWED_ORIGINS empty, since heimdall-ui calls the API under its own origin.

scripts/ygg.sh config heimdall-api on the VPS opens the file of the environment it asks for and redeploys with it.

5. Deploy and verify

Push — develop for development, a release/x.y.z branch for homologation — and follow the build in Jenkins or in the yggdrasil console. The first deploy of an on-demand environment ends stopped; start it, then:

curl https://heimdall-api-dev.example.com/healthcheck
curl https://heimdall-dev.example.com/api/healthcheck

Both answer when Traefik routes both hosts. The start-up log should show the migrations applied and then the API listening:

docker compose -p heimdall-api-development logs -f api

6. Day-to-day

TaskCommand, on the VPS
Turn development or homologation on / offscripts/ygg.sh env start development / scripts/ygg.sh env stop development
What runs in each environmentscripts/ygg.sh env status
Follow the logsdocker compose -p heimdall-api-<environment> logs -f api
Change the env file and redeployscripts/ygg.sh config heimdall-api
A shell in the containerdocker compose -p heimdall-api-<environment> exec api sh

Redeploys come from Jenkins, not from docker compose up by hand: a hand-run up would skip yggdrasil’s overlay and publish the API’s port on the host.

Production

Production follows the steps above with docker/production.env.example, and differs in what matters:

  • ASPNETCORE_ENVIRONMENT=Production — no Swagger, no developer exception page, no EF parameter logging, and the API refuses to start without MAILGUN_API_KEY and MAILGUN_DOMAIN rather than silently swallowing every e-mail it owes a user. It never needs HEIMDALL_ALLOW_SENSITIVE_DIAGNOSTICS.
  • It is always on: scripts/ygg.sh env stop production refuses unless forced.
  • It is deployed only from a release pull request whose checks have all passed, and a failed deploy rolls back to the previous production image and leaves the pull request open.
  • HEIMDALL_RUN_MIGRATIONS=false if it ever runs more than one replica: two containers applying migrations at start-up will race each other. Apply them as their own deploy step with python scripts/migrations.py.

For a PostgreSQL on another machine, put its private address in DB_HOST and require TLS through DB_CONNECTION_EXTRA=SSL Mode=Require;Trust Server Certificate=true — dropping Trust Server Certificate once the server presents a certificate the container can verify.

Troubleshooting

SymptomCauseFix
set DB_USER in the env file at upCompose interpolation found the variable emptyFill it in — the :? markers in the Compose file fail loudly rather than starting a container that cannot connect
Container restarts; log ends at entrypoint: applying EF Core migrations...The bundle cannot reach PostgreSQLRe-run the two probes above; the address in the error names which side is wrong
Container restarts; log ends with Environment variable 'HEIMDALL_AUTH_TOKEN_SECRET…' is … bytes or … holds characters outside ASCIIA signing secret, current or previous, shorter than 32 bytes or with a character outside ASCII (NFR-31)Replace it with openssl rand -base64 48; the message names the variable, never the value. Leave the previous one empty when not rotating
Migrations apply, then the API logs permission denied for table …The API’s login was switched to a runtime role, but the grants were not applied, or a migration ran as someone other than the ownerRe-run the grants in Database roles
No developer exception page, and no EF parameter values in the logExpected when the instance serves a public host: NFR-28 keeps them off, and start-up logs one warning naming the hostsSet HEIMDALL_ALLOW_SENSITIVE_DIAGNOSTICS=true only if the environment holds made-up people and the diagnostics are wanted; see Operations
no pg_hba.conf entry for host "..."The connection arrives from an address no rule coversAdd a host rule for that address, then systemctl reload postgresql
relation "role" already exists on the second runThe login is named heimdall, so "$user" resolves to the entities’ schemaRename the login, or keep Search Path=public in every connection string
Browser front end gets a CORS errorHEIMDALL_CORS_ALLOWED_ORIGINS is emptyList the front end’s origin exactly as the browser sends it
Every request is logged from the same address, and callers hit 429 togetherHEIMDALL_TRUSTED_PROXIES does not cover the proxySet it to the address or network the proxy connects from (Operations → Client addresses behind a proxy)
docker compose ps shows nothing (local)--env-file omitted, so COMPOSE_PROJECT_NAME is unsetPass the env file on every Compose command
docker compose ps shows nothing (VPS)The project is named after the environmentdocker compose -p heimdall-api-<environment> ps
heimdall-api-dev.example.com answers 404Development is stopped, so Traefik has no router for itscripts/ygg.sh env start development
Health check never leaves startingThe API is up but /healthcheck is not answeringlogs -f api; the start period is 30 s, five retries at 15 s after that

Next

Last modified October 16, 2026: fix: leave the template secret empty (61ccd0d)