Skip to content

Latest commit

 

History

History
1029 lines (837 loc) · 69.1 KB

deployment.md

File metadata and controls

1029 lines (837 loc) · 69.1 KB

Deploying Odyssey to a single VPS

This describes the supported production deployment: a single Linux VPS running Docker, fronted by Caddy for automatic TLS, deploying pinned SemVer image tags manually.

There are two of them, and they share one overlay (docker-compose.prod.yml) and one Caddyfile — they differ only in the env file you point at:

Reachable from Env file Certificate
Public the internet .env.prod Let's Encrypt, for your domain
Private that machine only .env.localhost Caddy's internal CA, for odyssey.localhost

Read this document straight through for the public deployment; everything in it applies to the private one too, with the two-variable diff and the reasoning in Running Odyssey privately. Neither is the development stack — that is a bare docker compose up on the base file, with demo data and published credentials.

The app runs as four containers behind one origin:

            ┌──────────────────────────── VPS ────────────────────────────┐
 Internet ──┤  Caddy :443 (TLS) → client nginx :80 ─/api/→ api :8080       │
   (443)    │                                          │                   │
            │                                       mariadb :3306 (internal)│
            │                          migrations (runs once, then exits)  │
            └──────────────────────────────────────────────────────────────┘

Because the client's nginx proxies /api/* to the API, the browser only ever talks to one origin (https://your-domain). That keeps cookie auth working without CORS configuration.

Images are published to GHCR automatically on every release (…-api, …-client, …-migrations), tagged with an immutable SemVer. Production pulls these — the server never builds.

Files involved

File Purpose
docker-compose.yml Base service definitions (shared with dev).
docker-compose.prod.yml Production overlay: pull images, Production env, Caddy, no host ports, persisted keys. Serves both deployments below.
Caddyfile TLS termination + reverse proxy to the client.
.env.prod.example Template for the internet-facing deployment. Copy to .env.prod.
.env.localhost.example Template for a private, machine-local deployment. Copy to .env.localhost. See Running Odyssey privately.

One-time server setup

  1. Provision a VPS (Debian/Ubuntu recommended) and install Docker Engine + the Compose plugin. Open inbound TCP 80 and 443 in the firewall.

  2. Point DNS — an A/AAAA record for your domain (e.g. odyssey.example.com) at the server's public IP. Caddy needs this resolvable to issue a Let's Encrypt certificate.

  3. Get the deploy files onto the server. Either clone the repo, or copy just docker-compose.yml, docker-compose.prod.yml, and Caddyfile. (The images carry the app itself; these three files are all the server needs.)

    If you ran a bare docker compose up on this machine first — to try Odyssey out — destroy that stack's data before deploying for real:

    docker compose down -v          # base file only: removes the dev containers AND the volume

    The base docker-compose.yml is the development stack. It defaults to ASPNETCORE_ENVIRONMENT=Development with SEED_DEMO_DATA=true, so its mariadb_data volume holds the demo dataset — including an Administrator whose password is published in this repository's README. It also publishes MariaDB on host port 3307, along with the API on 5188 and the client on 5199 — all three bound to 127.0.0.1 only, so they are not reachable from the network. Keep those prefixes; removing one to "test from another machine" puts that Administrator account on a public IP.

    The volume is the part down alone does not fix. The production overlay resets all three port mappings (ports: !reset []), but it reuses the same mariadb_data volume name — so bringing the overlay up over a volume the dev stack initialised carries those accounts, and that database password, straight into production. down -v is the only thing that removes it.

    On an SELinux host (Fedora, RHEL, CentOS Stream, Rocky, Alma), the two bind mounts relabel the files they mount. Both carry the z option — ./Caddyfile and ./docker/mariadb/init — because a freshly checked-out file keeps the label of the directory you cloned into (typically user_home_t), which a container process is not permitted to read. Without it Caddy crash-loops on open /etc/caddy/Caddyfile: permission denied, and because the runtime drops the AVC denial silently, the permission bits look fine and nothing in any log names SELinux.

    z applies the shared container_file_t label (not the per-container Z), and it does so in place, permanently — after the first up, ls -Z Caddyfile on your deploy checkout reports system_u:object_r:container_file_t:s0 rather than what git left there. That is the mechanism working, not damage, but it does mutate the working tree, so expect it if you deploy from a clone you also use for other things. On hosts without SELinux the runtime ignores the option entirely, so there is nothing to configure either way.

  4. Create .env.prod from the template and fill in real values:

    cp .env.prod.example .env.prod
    # edit .env.prod: GHCR_OWNER, IMAGE_TAG, ODYSSEY_DOMAIN, DB passwords,
    #                 BOOTSTRAP_ADMIN_EMAIL, BOOTSTRAP_ADMIN_PASSWORD
    • Generate strong DB passwords (e.g. openssl rand -base64 24).
    • BOOTSTRAP_ADMIN_EMAIL / BOOTSTRAP_ADMIN_PASSWORD create the initial administrator on a fresh database and are required there — see First-run notes.
    • Nothing about mail goes in this file (issue #8). The relay host, port and STARTTLS mode and the public link origin joined the sender identity and the relay credential in runtime settings; all of it is entered at /settings → Email after your first sign-in. The API no longer refuses to start without a relay, so a fresh deployment starts with mail switched off — read Mail is configured after first sign-in before you deploy, because that window has one consequence you cannot recover from yourself.
    • No credential goes in this file. The relay username and password, the Claude API key and the two derivation keys were removed from configuration entirely in issue #445 — the properties no longer exist, so a value set here is not read and not fallen back to. They are entered once by an administrator at /settings → Credentials after first start; until then each behaves as documented in the release note below. .env.prod.example marks each retired variable in place.
  5. If the GHCR packages are private, log Docker into GHCR once with a PAT that has read:packages:

    echo <PAT> | docker login ghcr.io -u <your-github-user> --password-stdin

Deploying / upgrading

A deploy is always: set IMAGE_TAG, pull, up. Migrations run automatically (the API waits for migrations to complete successfully before starting).

# 1. Pick the release to deploy — edit IMAGE_TAG in .env.prod (e.g. 1.4.0).

# 2. Pull the pinned images.
docker compose -f docker-compose.yml -f docker-compose.prod.yml --env-file .env.prod pull

# 3. Apply. Recreates only changed containers; migrations run before the API comes up.
docker compose -f docker-compose.yml -f docker-compose.prod.yml --env-file .env.prod up -d

# 4. Verify.
docker compose -f docker-compose.yml -f docker-compose.prod.yml --env-file .env.prod ps
curl -s https://your-domain/api/healthz   # → {"status":"ok","version":"1.4.0"}

/api/healthz returns the running version — confirm it matches the IMAGE_TAG you deployed.

Tip: wrap steps 2–4 in a small deploy.sh IMAGE_TAG on the server so a release is a single command.

Running Odyssey privately (localhost only)

The second supported deployment is a private one on a single machine — reachable from that machine and nowhere else. It uses the same docker-compose.prod.yml, with a different env file:

cp .env.localhost.example .env.localhost
# edit: GHCR_OWNER, IMAGE_TAG, DB passwords, BOOTSTRAP_ADMIN_EMAIL, BOOTSTRAP_ADMIN_PASSWORD
docker compose -f docker-compose.yml -f docker-compose.prod.yml --env-file .env.localhost pull
docker compose -f docker-compose.yml -f docker-compose.prod.yml --env-file .env.localhost up -d

Then open https://odyssey.localhost.

Do not confuse this with the dev stack. A bare docker compose up on the base file is the development stack: Development environment, demo data, four seeded accounts sharing a password published in this repository's README. That is the thing to run if you want a dataset to click around in. This deployment is the real application with no demo data and no default accounts, which is why it still needs DB passwords and a bootstrap administrator.

Only two values differ from .env.prod

Variable .env.prod .env.localhost
CADDY_BIND unset → 0.0.0.0:80, 0.0.0.0:443 127.0.0.1: → loopback only
ODYSSEY_DOMAIN your public domain (Let's Encrypt) odyssey.localhost (Caddy's internal CA)

Everything else is identical, deliberately. Production, the runtime hardening, the unpublished api/mariadb/client ports, the persisted dataprotection_keys volume, the bootstrap administrator, the mail-off first run and the whole /settings inventory all behave exactly as described elsewhere in this document.

CADDY_BIND's trailing colon is part of the value — Compose's port syntax is [HOST_IP:][HOST_PORT:]CONTAINER_PORT, so the variable has to supply its own separator. Caddy is the only host-facing surface in the overlay (every other service resets ports to []), so that one line is enough to take the deployment off the network. The converse also holds: publishing client or api "just to check something" undoes it, and does so unsafely — the client's nginx honours an inbound X-Forwarded-Proto (Odyssey.Client/nginx.conf), which is only sound because Caddy is the sole path in and overwrites it.

Why keep Caddy at all, if nothing is exposed?

Because ASPNETCORE_ENVIRONMENT=Production sets CookieSecurePolicy.Always (Odyssey.Api/Program.cs), so the auth cookie is marked Secure and needs HTTPS.

Serving plain HTTP would appear to work: browsers treat localhost, 127.0.0.1/8 and ::1 as secure contexts and accept Secure cookies there. But that exemption is keyed to the host in the address bar, so it stops applying the moment anyone reaches the box as http://192.168.1.50 — at which point the browser silently discards the cookie and login fails with no error message anywhere. Terminating TLS removes that failure mode instead of depending on it, and keeps the private deployment a faithful rehearsal of the public one.

The alternative — running Development locally to dodge the cookie flag — is the wrong trade and worth naming explicitly: it also turns on Swagger, permits demo seeding, and substitutes LegalOptions.DevelopmentPseudonymizationSecret for the real pseudonymisation key (Odyssey.Api/Legal/LegalPseudonymizer.cs). Use Production on both deployments.

Use odyssey.localhost, never bare localhost

The Caddyfile sends Strict-Transport-Security: max-age=31536000; includeSubDomains, and browsers key HSTS by hostname and ignore the port. Setting ODYSSEY_DOMAIN=localhost would therefore pin every http://localhost:<port> URL in that browser to HTTPS for a year — breaking the dev stack on :5199 and Swagger on :5188, recoverable only via chrome://net-internals/#hsts.

Scoped to odyssey.localhost the pin is harmless, browsers resolve *.localhost to loopback with no hosts-file entry, and Caddy recognises the name as non-public and issues from its internal CA rather than attempting an ACME challenge it could never satisfy. This is why a private deployment needs no change to the Caddyfile.

Trust Caddy's local CA

Otherwise every visit shows a certificate warning:

docker compose -f docker-compose.yml -f docker-compose.prod.yml --env-file .env.localhost \
  cp caddy:/data/caddy/pki/authorities/local/root.crt /tmp/caddy-root.crt

sudo trust anchor /tmp/caddy-root.crt          # Fedora / RHEL
# sudo cp /tmp/caddy-root.crt /usr/local/share/ca-certificates/caddy-root.crt \
#   && sudo update-ca-certificates              # Debian / Ubuntu

Firefox uses its own trust store — either import the same file under Settings → Privacy & Security → Certificates → View Certificates → Authorities, or set security.enterprise_roots.enabled to true in about:config.

The CA is generated on Caddy's first start and lives in the caddy_data volume. docker compose down -v destroys it, and the next start generates a different one — so re-run the steps above if certificates start being rejected after a volume wipe.

What is not relaxed

  • Backups still matter. The dataprotection_keys volume is secret-bearing and is destroyed by docker compose down -v, permanently for anything derived from it. See Backups — "it's only local" is how the legal pseudonymisation salt gets lost.
  • The mail-off first run is sharper here, not milder. If BOOTSTRAP_ADMIN_PASSWORD is lost before SMTP is configured, the forgot-password flow is the very thing that cannot run, and a single-user install means you are the one locked out. See Mail is configured after first sign-in. If you do configure mail, set the client base URL to https://odyssey.localhost.
  • File analysis still sends documents to Anthropic if enabled at /settings. Being on loopback does not make that local. It ships off.

First-run notes

  • BOOTSTRAP_ADMIN_EMAIL and BOOTSTRAP_ADMIN_PASSWORD are mandatory on a fresh database. The initial administrator is created by the migrations container from those values — registration order grants nobody any privilege. If they are unset (or only one is set), the migrations job exits non-zero and the API never comes up:

    No enabled administrator exists after seeding. Set Bootstrap:Admin:Email and
    Bootstrap:Admin:Password (BOOTSTRAP_ADMIN_EMAIL / BOOTSTRAP_ADMIN_PASSWORD) on a fresh
    database and redeploy.
    
  • The password must satisfy the app's policy (16+ characters, upper + lower + digit + symbol) and is a one-time secret: the account is created with MustChangePassword set, so the first sign-in lands on /change-password-required and the API refuses everything else until a new password is set. Then come /accept-terms and /onboarding, as for any new account.

  • Seeding fires only when the user table is completely empty. On every later deploy the values are read and ignored, so redeploying can never revert a password changed in the app. It is therefore not a password-recovery mechanism — the seeded account is created with a confirmed email, so the self-service forgot-password flow covers a lost one-time password (SMTP must be configured).

  • Database schema is created/updated by the migrations container on every deploy — there is no manual migration step.

  • Do not interrupt the migrations container while it is applying a migration. MariaDB commits DDL implicitly, so a migration is not atomic: killing the job part-way leaves the tables it had already created behind with no __EFMigrationsHistory row, and every later deploy then fails on the same migration with the API held down behind it. The job detects this and stops with a message naming the repair rather than a bare Table 'X' already exists — the procedure is in migration-history-drift.md. A migration in flight is given up to 5 minutes to finish before a shutdown signal takes effect — stop_grace_period: 5m on the service plus a matching HostOptions.ShutdownTimeout, which is why docker compose down can pause during a deploy.

Client IP: two proxy hops

The API sees the real client address only because it is told how many proxies sit in front of it. In this overlay there are two — Caddy sets X-Forwarded-For to the client, the client container's nginx appends Caddy's address — so the API receives <client>, <caddy> and ForwardedHeaders:ForwardLimit defaults to 2 (issue #244). That address is what the sign-in and password-reset rate limits partition on and what audit lines record. With a limit of 1 every request would appear to come from Caddy: one shared rate-limit bucket that a single caller could exhaust for everyone.

Change it only if the topology changes: add one per extra proxy (a load balancer in front of Caddy), or use 1 if nginx is reached directly. Neither compose file passes the key through, so set it by adding ForwardedHeaders__ForwardLimit to the api service's environment: map in docker-compose.prod.yml (the same applies to ForwardedHeaders__KnownNetworks__0, __1, …). It must be a whole number of at least 1; anything else fails startup.

Entries are honoured only while the hop that added them is inside ForwardedHeaders:KnownNetworks (private ranges by default). In production a public client cannot get a spoofed entry past Caddy, which overwrites an inbound X-Forwarded-For. The dev compose stack is the exception and is accepted: nginx is published directly on 127.0.0.1:5199, Docker's NAT makes the peer a private gateway address, and so a local caller's own X-Forwarded-For entry is honoured. That is reachable from loopback only, and one spoofable entry was already honoured there before the limit was raised.

Upload size: three ceilings, in order

The maximum upload is enforced at three layers, and raising it means raising all three, outermost first. Raising only the inner one produces a 413 from a component that never sees the app's setting; raising only the outer ones changes nothing.

Layer Where Must be
Reverse proxy Caddyfile (request_body max_size) and Odyssey.Client/nginx.conf (client_max_body_size) ≥ transport ceiling + multipart envelope headroom
Transport ceiling FILE_UPLOAD_MAX_BYTES → FileStorage:MaxFileSizeBytes (Kestrel + multipart limits, fixed at startup) ≥ the admin-set cap
The cap users hit System Settings → Storage, in the database whatever policy wants, up to the ceiling

Only the third is runtime-editable. The other two are deploy-time by necessity: Kestrel's request-body limit is set once at startup and cannot be raised per request, and the proxy limits are static config — which is exactly why the setting is tighten-only. PUT /api/system-settings rejects a value above the transport ceiling with a 400 naming it, rather than accepting a number the transport would then silently refuse.

Both proxies currently sit at 65MB for a 67108864-byte (64 MB) ceiling plus 1 MB of envelope headroom. Lowering the cap needs no deploy-time change at all.

FileStorage:MaxFileSizeBytes is not retired — it stays as the transport ceiling above, read by the API at startup. The admin-editable cap is a separate value that seeds at 64 MB, so if you raise the ceiling here, raise the cap at /settings too or users still hit the old number.

A fourth, unrelated size limit: photo metadata reads

System Settings → Photos → Metadata read size is not part of the chain above — it bounds how much of an already-accepted image is read back out of the database to extract EXIF/IPTC/XMP, not what an upload may contain. It has its own ceiling, and that ceiling is a compiled assumption about MariaDB's default max_allowed_packet (16 MiB), which this repository pins nowhere: not in docker-compose.yml, not in docker/mariadb/init/01-init.sql.

Two consequences worth knowing before you touch either number:

  • Raising max_allowed_packet buys you nothing on its own. The setting still stops at 16 MB, because that is where the compiled bound is. Raising the bound is a code change.
  • Lowering max_allowed_packet below the setting is safe, not fatal. The prefix read fails, and metadata extraction is skipped — the photo is still stored, just without extracted metadata. That fail-soft path is what makes the 16 MB bound defensible rather than merely smaller than a larger one.

Note also that extraction materialises a full byte array of this size per photo, so it is a per-upload memory multiplier, not only a database concern. The row carries an advisory above its shipped 8 MB saying so.

Tunable policy lives in System Settings, not in configuration

A growing set of runtime policy — 59 values as of issue #439 — is admin-editable at /settings and backed by the database, so changing it needs no redeploy and no restart. That includes every remaining product-visible limit that can take effect at runtime: the file-analysis token and vocabulary caps, photo metadata read size and timeout, calendar window and event-duration bounds, the aggregate ICS export/import guards, the recurrence occurrence cap, the vCard repeated-field cap, import summary sample counts, the mail throttle's tracked-address table, and the per-account smart-tag cap.

Three of those move in one direction only, and the field says so:

Setting Direction Why the other way is unavailable
Maximum generated occurrences lower only One calendar row is written per occurrence, so raising it is a write multiplier available to every user who can create a calendar entry — and the cost survives lowering it back.
Max repeated fields per contact lower only Each field costs a sibling query and its own save, multiplied by an import entry cap that ships unlimited. Any number above the shipped 200 would be a guess about a product of three unbounded terms.
Tracked recipient addresses raise only The per-recipient mail throttle fails open once its table is full, so a smaller table weakens the control instead of tightening it.

Release note: three FileAnalysis keys retired from appsettings.json

FileAnalysis:MaxTokens, FileAnalysis:Match:MaxVocabulary and FileAnalysis:Match:TimeoutSeconds are database-backed as of issue #434 and no longer read from configuration.

Their environment variables (FILE_ANALYSIS_MAX_TOKENS, FILE_ANALYSIS_MATCH_MAX_VOCABULARY, FILE_ANALYSIS_MATCH_TIMEOUT_SECONDS, and the Aspire: keys) are gone, and nothing carries a configured value into the store: the settings start at their shipped defaults (8096 / 500 / 60) and an administrator sets them at /settings. A carry-over step existed briefly and was removed once it was established that no deployment had ever run a release it could upgrade from — see CLAUDE.md, which records what a future one would have to get right if that changes.

FileAnalysis:TimeoutSeconds is not affected — it stays in configuration, because it is consumed once at startup by the HTTP resilience handler and a runtime value could never reach a live pipeline.

Release note: the file-analysis switch, model and base URL are settings now

FileAnalysis:Enabled, FileAnalysis:Model and FileAnalysis:BaseUrl are database-backed as of issue #439, editable at /settings → File analysis by an administrator holding system-settings.security.update. Every change to any of the three is written to the audit log.

As above, their environment variables (FILE_ANALYSIS_ENABLED, FILE_ANALYSIS_MODEL, FILE_ANALYSIS_BASE_URL, and the Aspire: keys) are gone and nothing carries a configured value across. A deployment starts at the shipped defaults — analysis off, claude-sonnet-5, https://api.anthropic.com — and an administrator turns it on at /settings.

That default ordering is deliberate and worth keeping in mind if a carry-over step is ever reintroduced: a deployment that silently starts transferring documents to a third party would be far worse than one that silently stops, so the switch must fail to off.

Four operational notes:

  • FileAnalysis:ApiKey moved too, in issue #445 — into the encrypted secret store, not into these plaintext settings. See the release note below. The consequence stated on the settings row is unchanged: the stored key is attached to the outbound client, so repointing the base URL sends that key to the new host. Repoint it only to a host you control or trust. The base URL must be an absolute https:// address with no path — the provider appends /v1/messages itself — and no credentials, query or fragment; all of those are rejected on save, and a value planted by a restore is rejected on read as well, which makes analysis refuse rather than fall back to api.anthropic.com.
  • The switch is honoured on the next request, not up to 30 seconds later: it is read live on every file-analysis call rather than from the 30-second settings cache. An operator instructed to stop transfers can stop them.
  • Redirects are not followed. The outbound client runs with AllowAutoRedirect = false; a 3xx from the configured host is recorded as a provider error. .NET strips only Authorization across origins, so a custom x-api-key header would otherwise survive a redirect — along with the document on a 307/308. Configure a gateway at its final address.
  • The circuit breaker is per typed client, not per host. After repointing the base URL, a breaker opened by failures against the previous host stays open for the rest of its sampling window (2 x FileAnalysis:TimeoutSeconds, so 240s at the shipped 120s). Pre-existing behaviour, but now reachable by an admin action: wait it out, or restart the API.

Release note: five credentials moved into the encrypted secret store (issue #445)

FileAnalysis:ApiKey, Email:Username, Email:Password, Email:RecipientHashKey and Legal:PseudonymizationSecret are no longer read from configuration. They live in the encrypted SystemSettingSecrets table and are entered at /settings → Credentials by an administrator holding system-settings.security.update. Each write is audited; no value is ever shown again.

Every one of them must be entered by hand. A secret is deliberately not adopted from configuration — no mechanism reads one out of the environment and writes it to the store, and none should be added: that would require the plaintext to still be present in the environment, which is most of what moving them was for, and would leave the row owned by configuration with no visible owner. So there is a defined gap between deploying and an administrator entering each value.

Credential While it is unset, after the upgrade Kind
FileAnalysis:ApiKey Every analysis job fails and is recorded as a credential failure. Nothing is transferred and nothing is lost. rotatable
Email:Username + Email:Password Transactional mail is not sent. With both unset the relay is used unauthenticated (which is what an empty EMAIL_USERNAME did before); with only one stored, or either unreadable, the send is logged and skipped rather than attempted with half a credential. rotatable
Email:RecipientHashKey Unchanged and healthy: the send throttle generates a per-process key, so its recipient digests correlate within one process and not across a restart. derivation
Legal:PseudonymizationSecret Account deletion cannot pseudonymise consent records. Outside Production the fixed development value is substituted; in Production the deletion fails, inside its own transaction, leaving the acceptance rows intact. derivation

Read this before upgrading:

  • Copy LEGAL_PSEUDONYMIZATION_SECRET out of your environment first, and enter that same value. It is a derivation key: if consent rows were already pseudonymised under it, a different value leaves those rows permanently un-re-derivable — the property GDPR Art. 7(1) consent attribution depends on. There is no provider to re-issue it from and no way to recover it afterwards.
  • Production no longer fails to start without it. The ValidateOnStart gate is gone, deliberately: a credential an administrator enters through the UI cannot be a precondition for the UI coming up. The failure moved to the first account deletion, with the remedy in the message.
  • If you set any of the five by editing Odyssey.Api/appsettings.json directly, that value is simply gone — the keys are no longer bound at all. Note them down before upgrading.
  • An unreadable row never falls back to a configured value, for any of the five. There is nothing to fall back to: the configuration properties were deleted in the same change. An unreadable credential means the Data Protection key ring changed or was lost; restore it, or clear the row and enter the credential again. /settings shows such a row in coral, and the page header raises a Credentials signal naming what is broken.
  • The keys volume is now load-bearing for these credentials as well as for sessions. The database backup alone is no longer sufficient — see The Data Protection keys volume and back both up together.

Mail is configured after first sign-in

Email:SmtpHost, Email:SmtpPort, Email:UseStartTls and Email:ClientBaseUrl used to stay in configuration, and issue #8 moved all four into the settings store. They are edited at /settings → Email, gated on system-settings.security.update, and every change binds on the next send — no restart, no cache wait. Their environment variables and the whole Email configuration section were deleted, not deprecated: nothing reads Email: from configuration any more, and a value set there is not a fallback.

Startup validation is gone. The Production ValidateOnStart gate on Email:SmtpHost could not survive the move — a value entered through the settings UI cannot be a precondition for that UI coming up — so the failure moved from startup to the first send, which logs and skips. This is the identical trade issue #445 made when Legal:PseudonymizationSecret lost its own gate.

The consequence, and it is the part worth planning around:

A new deployment sends no mail at all until an administrator sets an SMTP host. You sign in with the one-time BOOTSTRAP_ADMIN_PASSWORD and then configure SMTP. If that password is lost during that window there is no self-service recovery — the forgot-password flow needs working mail — and the account must be repaired directly in the database.

So configure mail immediately after your first sign-in, before blanking BOOTSTRAP_ADMIN_PASSWORD, and then send yourself a password reset. Nothing short of a real send checks the whole path, relay credential included, and nothing checks it at startup any more.

While no host is set, the System settings page reports "Transactional mail is not configured" in its page header, at Information severity, with a Fix action jumping to the row. That covers an administrator who is looking at the page; it does not cover an operator who deploys and never visits /settings, which is why this section exists.

Changing the SMTP host or port, or turning STARTTLS off, clears the stored SMTP username and password — in the same database transaction as the change itself, so the two land together or neither does. The threat the old Non-Goal was protecting against is real: the sender connects and then authenticates, so whatever relay is set receives the stored credential, and a credential entered for an encrypted transport must not be replayed over a cleartext one where passive network position alone is enough to read it. The port counts as part of the relay for this purpose — a different port is a different listener, which receives the credential just as readily as a different host would. Clearing it closes both structurally rather than by detection. The UI confirms before saving and names what will be cleared; re-enter the credential at /settings → Credentials afterwards. Until you do, mail is sent unauthenticated and any relay requiring a login will reject it.

Email:ClientBaseUrl carries no such structural control, because no credential is involved. Whoever can change it receives a password-reset token for any address they know — including another administrator's. Accounts with two-factor authentication stay protected by their second factor, which makes 2FA enrolment a meaningful control for administrator accounts specifically. What guards the rest is the security claim, an audit line recording old and new host, https-only validation (with a loopback exemption so the dev stack keeps working over http://localhost), and a hint on the field when the saved origin differs from the one you are browsing from. That residual is stated and accepted; see issue #8 §10.2.

Release note: user profile pictures (issue #94)

A signed-in user can attach, replace and remove one picture of themselves from /account. It then stands in for their initials wherever the application already identifies them: the account page header, and the administrator's user list and detail panel. Four things an operator should know before upgrading.

A new permission claim, profile-images.read, and revoking it from Guest is the supported way to narrow who sees colleagues' pictures. It is granted to every role out of the box, so its effective reach is "any authenticated caller" — what it buys is a revocation lever that exists before release rather than narrower access today. It needs no migration: RoleClaimSeeder reconciles AspNetRoleClaims at runtime and applies removals as well as additions. A revocation lever nobody knows about is not a lever, which is why it is written down here.

Pictures stay blank for already-signed-in users until they sign out and back in. Claim values are baked into the auth cookie at sign-in, so a session that predates this deploy does not hold the new claim and sees the initials monogram. It is a one-time, self-healing degradation to a decorative surface. On /account the picture is the feature rather than decoration, so for that window the controls are disabled with "Sign out and back in to manage your profile picture" — note that the same disable covers Remove, so the self-service erasure control is briefly unavailable too. One sign-out clears it.

Disabling an account also hides its picture — from administrators as well. The read endpoint 404s an administratively disabled subject, which is what makes "disable the account" a proportionate remedy for an abusive upload (there is deliberately no administrator write path, so an admin can neither replace nor remove someone else's picture). The row survives, but only direct database access reaches it. So capture the evidence before disabling, not after. Note this applies to an administrative disable only — a user locked out by five mistyped passwords keeps their picture, deliberately: hiding it would turn the endpoint into a 200 → 404 → 200 confirmation channel for failed-login lockouts.

Down on the AddUserProfileImage migration destroys the stored images. The schema is reversible; the data is not. The migration is otherwise purely additive — two new tables, one unique index, two cascade foreign keys, no existing table altered and no rows seeded.

Three new RateLimiting:* sections are shipped with defaults and stay in deploy-time configuration (ProfileImageWrite, ProfileImageDelete, ProfileImageRead). The write and delete budgets are separate on purpose — sharing one would mean a user who had exhausted their upload budget could not remove their picture, which throttles a GDPR Art. 17 control. The read budget is deliberately generous: a rejected read is invisible to the user (the avatar silently falls back to the monogram), so a tight limit produces a silent defect rather than an error anyone reports.

Release note: contract signature dates are backfilled, and the values are not evidence (issue #145)

A contract now carries two signature stamps, Ready (marked ready for signature) and Signed (signed by all parties), and they fold into the derived contract status as two new states, Draft and Ready. An unsigned contract is on file but not in force: it is excluded from the contracts run rate, its by-type cost split and the upcoming charges. Three things an operator should know before upgrading.

The AddContractSignatureDates migration writes a FABRICATED signature timestamp onto every existing contract row, and no such value is a statement of fact. A field that looks like evidence and is not is a provenance defect, so it is recorded here rather than left to be discovered: if you need to know when an agreement was really signed, the backfilled value does not tell you. The value is derived from each row's own dates — LEAST(COALESCE(StartDate, CompletionDate, CreatedAtUtc), CreatedAtUtc), i.e. signed no later than the day its term began, and no later than the day its row was created — rather than one shared clock reading stamped across the table, so the residual is a per-row approximation instead of a table-wide assertion. The clamp is what keeps a contract whose term starts in the future from being stamped as signed in the future, which the write path refuses outright.

Leaving the columns null would not have been the safer alternative. The derivation reads a null Signed as unsigned, so an un-backfilled upgrade would flip every contract in the database to Draft and empty the run rate and the upcoming charges overnight. Backfilling is what makes the change invisible until someone deliberately records a draft: no existing contract changes status across the upgrade, and the summary body is identical except that countsByStatus gains draft: 0 and ready: 0.

No new permission claim and no new configuration. The two fields ride the existing contracts.read / .create / .update claims, so no sign-out/sign-in is required on deploy — nothing baked into an existing auth cookie is affected. Neither column is indexed (the status is derived in memory after projection, so neither is ever a SQL predicate), and neither carries a user-attribution column: a Ready/Signed transition is recorded as one structured Information log line naming the contract, which stamp changed, whether it was set or cleared, and the acting user. Down drops both columns; the backfilled values are not recoverable, which is correct — they were fabricated on the way up.

One behaviour change for anyone driving the API directly: GET /api/contracts?sortBy=Status now orders by a lifecycle rank (Draft → Ready → Upcoming → Active → Paused → Expired → Archived) rather than the enum ordinal, so Expired and Archived now sort after Active. The persisted and wire ordinals are unchanged — Draft and Ready are appended at 5 and 6 — and this affects display order only.

Release note: contract terms now carry a money direction (issue #159)

Each priced contract term now says which way the money moves, from the household's perspective — Outgoing or Incoming — so a landlord's rent is no longer summed into "what the file costs to run" and an employment contract's salary no longer reads as an expense. Three things an operator should know.

The AddTermDirection migration is additive and behaviour-preserving, with no backfill statement. It adds one NOT NULL DEFAULT 0 int column to Terms; the default writes Outgoing into every existing row, which is what every existing term already meant. On a file whose contracts only record costs, GET /api/contracts/summary returns a byte-identical body before and after, except for the new fields being null or empty. No index, no seeded row, no data migration. Down drops the column, and the directions recorded after the upgrade are not recoverable from it.

runRate.monthly / .yearly / .byType keep their names and their meaning — they are now explicitly the OUTGOING side. The incoming side arrives under new names (incomingMonthly, incomingYearly, incomingByType), the net under netMonthly / netYearly, and the incoming movements in a new upcomingReceipts list beside upcomingCharges. A consumer reading the existing fields today is correct tomorrow. Two rules worth knowing when reading the payload: no gross ever mixes directions, and the net is the only signed figure — it is (incoming ?? 0) − (outgoing ?? 0), computed from the unrounded sums, and null only when both sides are.

The contracts header now shows four run-rate tiles instead of two. "Monthly run rate" and "Yearly run rate" are renamed "Monthly out" / "Yearly out" and joined by "Monthly in" / "Yearly in"; the two by-type tiles become "Monthly net by type" / "Yearly net by type", each closing with a ruled Net row. The figures behind the two "out" tiles are the same numbers the old two showed — the rename is because the figure is now one side of two, and a name that stayed would read as a total. The signal panel gains a Next receipts group beside Next charges. Nothing here needs an operator action; it is noted so the change is not mistaken for a defect.

ContractMaxSummaryCharges changes meaning without changing its value or bound. It now caps the outgoing and incoming movement lists separately, so a file with many charges cannot starve the receipts — and the worst-case row count for that part of the payload doubles. The setting's own description on System settings → Contracts says so; nothing about it needs changing on upgrade.

No new permission claim and no new configuration. Direction rides the existing contracts.read / .update and accounts.terms.write claims, so no sign-out/sign-in is required on deploy. A new 400 exists on the write path: Incoming on an account-owned term — no account surface reads a direction yet, so accepting one there would record a fact the product then contradicts. And note that PUT …/terms/{termId} is a full replace: omitting direction resets the term to Outgoing, exactly as omitting label or anchorDate already clears those. Every read path returns the field, so a read-modify-write round trip carries it back unchanged.

Release note: rate term kinds folded into labelled terms, and TermKind removed

Two migrations run in order. FoldRateTermKindsIntoFee rewrites every InterestRate, ExpectedReturn and unrecognised term as an ordinary labelled term — labelled Interest rate, Expected return or Unspecified where it had no label — keeping its value, unit and dates. RemoveTermKind then drops the Terms.TermKind column; a term's series is now its label alone. If an owner already had a term labelled Interest rate or Expected return, the converted rate joins that history rather than forking a second one. No data is lost, and the down migrations are best-effort (they restore a kind only from the exact label the fold wrote).

On the wire, termKind is gone from every term request and response, from the data export, and as the ?kind= filter on both history endpoints; label is now required on every term. ExistingAccount.currentInterestRate / currentInterestRateKind are removed. The account row's headline rate, the rate step chart and the "Interest charged" tint are withdrawn for now — a former rate shows as a term like any other.

Release note: account terms moved onto contracts (issue #190)

Take a database backup before upgrading. The MoveAccountTermsToContracts migration moves data and then drops a column, it has no immutable record of what it did (a migration step cannot write to the application log, and its working table is dropped at the end), and its Down restores the schema only. The backup is the recovery path.

What the migration does. Every term recorded directly on an account is moved, row for row and with the same TermId, onto a contract the account takes part in. The expected contract type is Deposit for an asset account, Loan for a liability and Other for an unclassified one. If the account is a party (in any role) on exactly one contract of that type, and none of the moving terms shares a (label, effective date) with a term already on it, that contract is reused untouched. Otherwise a new contract is created from the account — name, description, account number as reference, opened and closed dates, archived state — unsigned (Draft), with the account as Object and its custodian, if any, as Custodian / Lender / Other. Accounts without terms are not touched.

What to review afterwards. Each moved account gets one system event on its contract, titled Terms migrated from account "…", listing the codes below that apply. It is an ordinary, editable event — a to-do list, not an audit record.

Code Meaning What to do
A1 The contract was created by the migration Review it; mark it Ready and Signed with the real dates. Until signed it is Draft and excluded from the run rate.
A2 Two or more matching contracts existed Move the terms to the right one and delete the created contract, or keep it.
A3 The account is a party on a contract of another type Check whether the terms belong there.
A4 An unclassified account became an Other contract Choose the real contract type (party roles may need changing first).
A5 The account had no custodian Add the institution as Custodian / Lender.
A6 The reference number was copied from the account number Replace it with the agreement's reference, if different.
A7 The account's closed date precedes its opened date Set the contract's end date.
A8 A percentage term on a Deposit was flipped to Incoming Verify — a negative rate or a percentage fee should stay Outgoing.
A9 The one matching contract already had a series with the same label and date, so a new contract was created Decide which contract the series belongs on; move or delete the duplicate.
A10 The reused contract now exceeds the per-contract term cap New terms are refused (422) until it is below the cap; edits still work.
A11 The reused contract is archived Its terms are hidden with it; unarchive or move them.
A12 An amount's currency was filled from the account, and that currency is inactive The term cannot be re-saved until the currency is active or changed.
A13 The account also has value estimates Informational — estimates stay on the account.
A14 Periodic amount terms now sit on a Deposit / Loan Once signed they count in the contracts run rate; confirm the amounts.
A15 An existing contract was reused The account keeps its existing role and no custodian party is added; add parties if wanted.

What is lost.

  • L1 — An amount term with no currency used to follow the account's currency, including later changes; it is now frozen to the account currency at migration time.
  • L2 — The original direction of a flipped percentage term (A8) survives only in the event text.
  • L3 — Deleting an account no longer deletes its terms. It removes only the account's party row; the contract and its terms remain and follow the contract's lifecycle. Orphaned Draft contracts left by an account deletion are expected and are not pruned automatically.
  • L4 — Guest loses read access to these terms: they are now read under contracts.read, which Guest does not hold. No role gains access.
  • L5 — Per-term history events: the move writes one summary event per account, not one per term.
  • L6 — ExistingAccount.termCount and .currentTerms are removed from the API, and with them the account row's term badge, Current-band term tiles, Terms section and New term menu item.
  • L7 — TermExport.accountId is removed from the admin data export.

API and permissions. The five /api/accounts/{accountId}/terms… routes are gone and answer 404; /api/contracts/{id}/terms… is the only term API. ExistingTerm loses accountId and its contractId is never null. The account-term read/write claim pair is deleted; RoleClaimSeeder revokes its AspNetRoleClaims rows at the next start with no migration. Cookies issued before the upgrade still carry the dead values until sign-out/sign-in, which is harmless — no endpoint checks them.

If the migration is interrupted. An interruption before the schema change is safe to re-run: every decision it takes is staged once and reused. An interruption during the final column drop is not covered by the start-up drift check; repair it by hand per migration-history-drift.md.

Release note: currencies in use can no longer be deleted (issue #241)

Take a database backup before upgrading. AddCurrencyForeignKeys writes data, and its Down restores the schema only.

What the migration does. It adds a RESTRICT foreign key to Currencies from every currency-code column: accounts, transactions, budgets, tax statements, properties, account and property estimates, and contract terms. Before this release a currency could be deleted while those rows still named it, and rows like that would make adding the keys fail. So first, every code that rows use but Currencies no longer holds is restored as an active currency named Restored currency XYZ (review) with two minor units. No referencing row is changed or removed. Every orphaned code is restored, including a blank or malformed one a hand edit may have left, because skipping it would stop the upgrade at the key it violates.

What to review afterwards. On the Currencies page, search for Restored currency. For each row, either correct its name, symbol and minor units, or change the records that use it to another currency and then delete it. From now on, deleting a currency that is still in use is refused with a 409 naming how many of each kind of record use it.

Plan for write downtime. MariaDB can add a foreign key only by copying the whole table (ALGORITHM=COPY), which blocks writes to it while the copy runs. All eight tables are rebuilt, Transactions among them. On a large database, upgrade during a quiet window.

Backups

The only stateful pieces are two named volumes — back both up:

  • mariadb_data — all application data. A nightly logical dump is recommended:
    docker exec odyssey-mariadb sh -c \
      'exec mariadb-dump -uroot -p"$MARIADB_ROOT_PASSWORD" --all-databases' \
      > odyssey-$(date +%F).sql
  • dataprotection_keys — the Data Protection key ring, and it is secret-bearing. It has always held the auth-cookie and antiforgery signing keys; since issue #444 it is also what decrypts any credential an administrator stores under System settings → Credentials. See the section below.

The Data Protection keys volume

Back it up, and back it up separately from the database dump. Both directions of that sentence are load-bearing, and each is a different failure:

  • Together is a disclosure risk. The keys and the ciphertext they protect must not land in the same archive, or a single misdirected backup yields both halves — which is precisely the database-only exposure that encrypting the credentials was meant to bound.
  • Not at all is an unrecoverable loss. docker compose down -v destroys this volume. For a rotatable credential (an SMTP password, a provider API key) that is an outage: re-issue it at the provider and paste it again. For a derivation key it is permanent — data already derived from it can never be re-derived.

Losing the volume without losing the database leaves stored credentials readable by nobody: the Credentials rows report "Set, but this server cannot decrypt it", and the features that use them behave as if the credential were unset.

Incident response. If the volume, or any backup of it, is suspected disclosed, treat every stored credential as compromised and rotate it at the provider — re-encrypting under a new key ring is not sufficient, because the plaintext is what leaked. If a derivation key was among them, run a GDPR Art. 33 breach assessment: disclosure of Legal:PseudonymizationSecret permits re-identification of pseudonymised acceptance records (Art. 4(5)), and the same reasoning applies to Email:RecipientHashKey (ISO 27001 A.5.24, A.5.26).

What this does and does not protect against. Encryption at rest here bounds database-only exposure — a stolen dump, a misdirected backup, a read-only SQL injection, a support engineer with a database client. It does not protect against an attacker who has the application host, because that attacker has the key ring and the process memory. The access boundary is unchanged by this feature; the prize behind it is larger.

The key ring itself is stored unencrypted, and the API says so on every boot. Expect this line, and do not treat it as a misconfiguration:

warn: Microsoft.AspNetCore.DataProtection.KeyManagement.XmlKeyManager[35]
      No XML encryptor configured. Key {...} may be persisted to storage in unencrypted form.

It means the XML key files on the dataprotection_keys volume are not themselves encrypted, which is correct for this deployment and follows from the paragraph above: the only things ASP.NET Core could encrypt them with — DPAPI, or a certificate — are unavailable or circular here. DPAPI is Windows-only. A certificate would have to be readable by the API container at startup, so it would live in the environment or in a mounted file on the same host, which is where the attacker who has the host already is; it would move the secret rather than protect it, and it would reintroduce exactly the "credential in the environment" arrangement issue #445 removed. The boundary that does the work is filesystem access to the volume — so back it up separately, keep it off shared storage, and read the incident-response paragraph above.

Do not confuse it with Odyssey's own startup warning, which is a different and more serious condition: Data Protection has no explicitly configured durable key repository means the ring is ephemeral — nothing is being persisted at all, every restart logs everyone out, and writes to encrypted secret settings are refused with 503. That one needs fixing; the framework's No XML encryptor configured does not.

Only the api container mounts it, and that is the point: every container holding the ring is one more that can decrypt every stored credential. The migrations job mounted it too for a while, so that a configuration-adoption step could protect a value under keys the API could read — a value protected under the job's own ephemeral ring would have produced a row the API could never decrypt. That step no longer exists and nothing in the job protects anything, so the mount was removed along with it. The API creates the ring on first start.

If you ever add a mount back, add it together with the step that needs it, pointing at this same volume and using the same SetApplicationName("Odyssey") — two services deriving different keys from the same files is the failure this arrangement exists to avoid.

The mount must be read-write. A read-only mount does not fail loudly: Data Protection falls back to an in-memory key ring with a log line, which is the same silently-ephemeral failure by another route.

Upgrade note — an existing keys volume may need a one-time chown

The API image creates /var/odyssey/dataprotection-keys owned by app at build time, and the API asserts at startup that the directory is writable, failing to start if it is not. Docker copies image directory ownership into a named volume only when the volume is empty, so a dataprotection_keys volume created before this release may still be root:root 0755 — in which case that installation has been running an ephemeral key ring all along (forcing a re-login on every restart) and will now stop instead of continuing silently. That is the intended posture once credentials depend on the ring. The remediation is one line:

docker compose -f docker-compose.yml -f docker-compose.prod.yml --env-file .env.prod \
  run --rm --user root api chown -R app:app /var/odyssey/dataprotection-keys

Note the deliberate split: an unconfigured DataProtection__KeysPath only logs a warning (a bare dotnet run and every CI host sit there, and writes to Credentials are refused with 503 instead), while a configured but unwritable path fails startup — the first is a legitimate development default, the second is always a misconfiguration whose only silent outcome is data loss.

ASPNETCORE_ENVIRONMENT: use Production, never Testing

The overlay sets ASPNETCORE_ENVIRONMENT=Production, and that is the only value a deployment should carry. Staging is a reasonable second for a pre-production host: it behaves like Production for everything below.

Testing is not a staging environment — it is the name the in-process test host runs under, and four separate places key off it:

What Where Effect
Antiforgery (CSRF) enforcement Odyssey.Api/Program.cs Skipped on the Identity endpoints and every controller
Database provider Odyssey.Api/DatabaseExtension.cs, Odyssey.MigrationService/Program.cs Switched to in-memory — nothing is persisted
Demo-data seeding Odyssey.MigrationService/DemoDataSeeder.cs Allowed, with the published shared password
Password-reset links Odyssey.Api/Email/SmtpEmailSender.cs Written to the log instead of being emailed

None of that is an authorization bypass — every controller endpoint still requires an authenticated user — but it is a materially weaker posture reached by typing one word, and "Testing" is a plausible thing to type meaning "a test deployment".

The API refuses to start under it. TestingEnvironmentGuard checks the running server: only the in-process test host (Microsoft.AspNetCore.TestHost.TestServer) may run as Testing, so a container that tries answers with a startup exception naming the settings above rather than serving traffic. The migrations job has no server to inspect and is not guarded — set it there and it migrates an in-memory database, after which the API never comes up.

The migrations job reads DOTNET_ENVIRONMENT, not ASPNETCORE_ENVIRONMENT

The API is an ASP.NET Core host and reads ASPNETCORE_ENVIRONMENT. The migrations job is a plain console host (Host.CreateApplicationBuilder), which reads DOTNET_ENVIRONMENT and ignores the ASPNETCORE_ name entirely, defaulting to Production when neither is set. Both compose files therefore set both names on that service, and any hand-written manifest — docker run, a Kubernetes Deployment, a systemd unit — has to do the same or the migrations job will silently disagree with the API about which environment it is in.

The default is fail-safe (an unset variable means Production, the strictest posture), so a manifest that sets neither is not dangerous — it just never seeds demo data. Setting only the ASPNETCORE_ name has the same effect, which is what previously made SEED_DEMO_DATA=true do nothing in the dev stack.

Rollback

Set IMAGE_TAG back to the previous version and re-run pull + up. Note that schema migrations are forward-only — if the newer release added a migration, rolling the images back does not roll the database back. Take a DB backup before deploying a release that includes migrations.

The migration history was squashed to a single InitialCreate more than once during development, most recently when the identity context was merged into OdysseyContext; a database built before 20260829095318_InitialCreate has no upgrade path and must be recreated. That is over. A production database with real data has run since v0.35.0, so from then on every schema change is an additive migration and the history is never squashed again.

A data migration is restored from backup, not rolled back. Down() reverses schema at most; a migration that moves or rewrites rows (for example RetirePropertyAndVehicleAccountTypes, which moves Property- and Vehicle-type accounts onto property records and whose Down() drops only a constraint) cannot be reversed by any EF command. Take the backup above before upgrading across one, and keep it until the upgraded instance has been checked.

Who can read contact personal data

Every role that can see the Contacts page can read every contact field, and can download all of them in one request. Guest — the lowest role — holds contacts.read, which is the claim gating both GET /api/contacts and GET /api/contacts/vcard.

That matters more than it used to. Issue #48 added aliases, each with a free-text label, plus a middle name, a date of death, and an organization's establishment and dissolution dates. Two of those are special-category-adjacent: an alias is frequently a maiden or former name, which in combination reveals marital or gender-transition history, and a date of death is data about a deceased person that is often health-adjacent. The free-text label sharpens this rather than softening it — "maiden name" as an explicit label states outright what an unlabelled alias would only imply, and unlike a free-text note it is structured and machine-extractable: "every contact with a label containing 'maiden'" is a one-line script against this shape.

This is an accepted, deliberate posture, not an oversight. It is right for a single-household deployment where every role holder is a member of that household. The rejected alternative was a separate contacts.sensitive.read claim: it would make the contact read DTO claim-conditional, need a RolePermissions change, and force every existing session to sign out and back in, for a boundary that does not exist in that threat model.

If you are deploying Odyssey somewhere the roles do not map to one trusted household, revisit this before anything else. Until a finer claim exists, the only control is role assignment: do not grant Guest — or any role — to someone who should not read the whole contact corpus.

Per-request audit lines are written for both halves, and carry no values: a contact change records the actor, the contact id and the action (alias.created / .updated / .deleted, dateOfDeath.set / .cleared), and a vCard export records the actor, the row count and whether filters were applied.

The runtime image needs ICU

The API image installs icu-libs and pins DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=false. Contact alias uniqueness is checked accent-insensitively, to agree with the database's own utf8mb4_*_ci unique index; that comparison is ICU-backed and silently stops folding accents under invariant globalization, which is the alpine runtime's default. The API asserts this at startup and refuses to serve rather than accept an alias the index will then reject. If you build your own image, keep both.

Household locations: what leaves the deployment, and how to erase one

Issue #350 added locations — the household's places, each with a name, an address line, coordinates to about 1 m, a time zone and a free-text description. Three things about them are an operator's concern.

Release note

  • New claims. locations.read (every role, Guest included), locations.create and locations.update (Admin, Owner, User). There is no locations.delete. Claims are baked into the auth cookie, so every user must sign out and back in before the Locations page appears.
  • Address search sends text to a third party (below).
  • The runtime image needs tzdata. The API Dockerfile installs it, and the API refuses to start if Europe/Oslo, Europe/Kyiv, America/New_York or Etc/UTC does not resolve. If you build your own image, keep it.
  • Two new deploy-time limits: RateLimiting:LocationSearch (per user, default 30 per 60 s) and RateLimiting:LocationSearchGlobal (whole instance, default 120 per 60 s).

The geocoding data flow

Saving, reading and editing a location never leaves the deployment. Only the address search does, and only when a user types into it:

Region chosen Receives the search text Who
Norway ws.geonorge.no (two requests: addresses and place names) Kartverket, the Norwegian Mapping Authority (a state agency)
Worldwide geocoding-api.open-meteo.com Open-Meteo, Switzerland (EU adequacy decision)

Never both. No user id, name or stored location is sent, and no API key is used. Both hosts are compile-time constants and redirects are not followed. Searches need at least 2 characters (Norway) or 3 (worldwide), and the client waits until typing pauses, so prefixes are not sent one keystroke at a time. Results from both providers are CC BY 4.0, and Odyssey shows the credit wherever their data appears.

Logging. The search text travels in a POST body, so it is in no URL, and the API never logs it. The outbound clients have their HTTP loggers removed, because their URLs do carry it. The bundled nginx turns its access log off for the location API (/api/api/locations…, the path the browser calls); the /locations page itself is an ordinary SPA route and is logged like any other. Any other proxy in front of Odyssey — Caddy in the production overlay, or your own — logs only the URL, which carries no search text; if yours is configured to log request bodies, exclude /api/api/locations from it.

Privacy notice. Odyssey ships no privacy notice of its own. If you publish one for your household, it should say: locations (addresses, coordinates and descriptions) are visible to every household member including guests; address searches are sent to Kartverket or Open-Meteo as described above; and locations are kept until someone overwrites them (next section).

Erasing a location (data-subject requests)

There is no delete in this version — a product decision, recorded in issue #350 §7.8. An update overwrites in place and keeps no previous value anywhere in the application, so erasure is an edit:

  1. As Admin, find the row on the Locations page (turn on Archived in the status filter if it is already archived).
  2. Edit it: replace the name with something neutral (it must stay unique, e.g. Erased 2026-10-23), clear the address and the description, and replace the coordinates with a coarse value (e.g. 0, 0). Save, then Archive it.
  3. The audit log records who made the change and which fields changed — never the old values.
  4. Copies outside the live row are not purged by the edit: the Admin data export (which includes locations) and database backups age out under your existing export and backup retention.
  5. Record the request, the date and the backup retention that still applies.

Deleting a user leaves every location they created; only the attribution is cleared.

Security checklist

  • ASPNETCORE_ENVIRONMENT=Production (Swagger off; set in the overlay) — and not Testing, which the API refuses to start under; see the section above.
  • No demo data: if this host ever ran the base docker compose up, its mariadb_data volume was destroyed with docker compose down -v before the production overlay came up.
  • No host port mappings for api/mariadb/client — only Caddy's 80/443 are exposed.
  • Private deployments only: CADDY_BIND=127.0.0.1: (with the trailing colon) is set, and docker compose … config shows host_ip: 127.0.0.1 on both of Caddy's ports. Confirm from another machine that the host does not answer on 443.
  • Private deployments only: ODYSSEY_DOMAIN is a *.localhost name, not bare localhost — see the HSTS note.
  • Strong, unique MARIADB_ROOT_PASSWORD and MARIADB_PASSWORD.
  • .env.prod is not committed (the .env* gitignore rule covers it) and is chmod 600.
  • TLS verified — https://your-domain shows a valid Let's Encrypt cert.
  • SMTP configured so account confirmation/reset emails actually send.
  • BOOTSTRAP_ADMIN_EMAIL / BOOTSTRAP_ADMIN_PASSWORD set to real values (the example file ships them empty).
  • The seeded admin's password changed at first sign-in (the app forces this; confirm it happened, since the one-time value is still sitting in .env.prod).
  • dataprotection_keys is backed up, and stored separately from the database dump — it is secret-bearing once any credential is stored under System settings → Credentials.
  • The API started cleanly, i.e. the keys directory is writable (see the upgrade note above).
  • Roles are assigned on the understanding that any of them can read every contact's aliases (maiden and former names included), middle names and dates of death, and export the lot — see the section above.