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.
| 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. |
-
Provision a VPS (Debian/Ubuntu recommended) and install Docker Engine + the Compose plugin. Open inbound TCP 80 and 443 in the firewall.
-
Point DNS — an
A/AAAArecord 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. -
Get the deploy files onto the server. Either clone the repo, or copy just
docker-compose.yml,docker-compose.prod.yml, andCaddyfile. (The images carry the app itself; these three files are all the server needs.)If you ran a bare
docker compose upon 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 volumeThe base
docker-compose.ymlis the development stack. It defaults toASPNETCORE_ENVIRONMENT=DevelopmentwithSEED_DEMO_DATA=true, so itsmariadb_datavolume 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 to127.0.0.1only, 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
downalone does not fix. The production overlay resets all three port mappings (ports: !reset []), but it reuses the samemariadb_datavolume name — so bringing the overlay up over a volume the dev stack initialised carries those accounts, and that database password, straight into production.down -vis 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
zoption —./Caddyfileand./docker/mariadb/init— because a freshly checked-out file keeps the label of the directory you cloned into (typicallyuser_home_t), which a container process is not permitted to read. Without it Caddy crash-loops onopen /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.zapplies the sharedcontainer_file_tlabel (not the per-containerZ), and it does so in place, permanently — after the firstup,ls -Z Caddyfileon your deploy checkout reportssystem_u:object_r:container_file_t:s0rather 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. -
Create
.env.prodfrom 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_PASSWORDcreate 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.examplemarks each retired variable in place.
- Generate strong DB passwords (e.g.
-
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
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_TAGon the server so a release is a single command.
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 -dThen 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.
| 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.
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.
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.
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 / UbuntuFirefox 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.
- Backups still matter. The
dataprotection_keysvolume is secret-bearing and is destroyed bydocker 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_PASSWORDis 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 tohttps://odyssey.localhost. - File analysis still sends documents to Anthropic if enabled at
/settings. Being on loopback does not make that local. It ships off.
-
BOOTSTRAP_ADMIN_EMAILandBOOTSTRAP_ADMIN_PASSWORDare mandatory on a fresh database. The initial administrator is created by themigrationscontainer 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
MustChangePasswordset, so the first sign-in lands on/change-password-requiredand the API refuses everything else until a new password is set. Then come/accept-termsand/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
migrationscontainer on every deploy — there is no manual migration step. -
Do not interrupt the
migrationscontainer 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__EFMigrationsHistoryrow, 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 bareTable 'X' already exists— the procedure is inmigration-history-drift.md. A migration in flight is given up to 5 minutes to finish before a shutdown signal takes effect —stop_grace_period: 5mon the service plus a matchingHostOptions.ShutdownTimeout, which is whydocker compose downcan pause during a deploy.
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.
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:MaxFileSizeBytesis 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/settingstoo or users still hit the old number.
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_packetbuys 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_packetbelow 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.
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. |
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.
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:ApiKeymoved 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 absolutehttps://address with no path — the provider appends/v1/messagesitself — 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 toapi.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; a3xxfrom the configured host is recorded as a provider error. .NET strips onlyAuthorizationacross origins, so a customx-api-keyheader would otherwise survive a redirect — along with the document on a307/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.
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_SECRETout 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
ValidateOnStartgate 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.jsondirectly, 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.
/settingsshows 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.
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_PASSWORDand 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.
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.
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.
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.
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.
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.termCountand.currentTermsare 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.accountIdis 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.
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.
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.
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 -vdestroys 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.
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-keysNote 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.
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 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.
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.
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 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.
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.
- New claims.
locations.read(every role, Guest included),locations.createandlocations.update(Admin, Owner, User). There is nolocations.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 ifEurope/Oslo,Europe/Kyiv,America/New_YorkorEtc/UTCdoes not resolve. If you build your own image, keep it. - Two new deploy-time limits:
RateLimiting:LocationSearch(per user, default 30 per 60 s) andRateLimiting:LocationSearchGlobal(whole instance, default 120 per 60 s).
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).
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:
- As Admin, find the row on the Locations page (turn on Archived in the status filter if it is already archived).
- 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. - The audit log records who made the change and which fields changed — never the old values.
- 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.
- 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.
-
ASPNETCORE_ENVIRONMENT=Production(Swagger off; set in the overlay) — and notTesting, which the API refuses to start under; see the section above. - No demo data: if this host ever ran the base
docker compose up, itsmariadb_datavolume was destroyed withdocker compose down -vbefore 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, anddocker compose … configshowshost_ip: 127.0.0.1on both of Caddy's ports. Confirm from another machine that the host does not answer on 443. - Private deployments only:
ODYSSEY_DOMAINis a*.localhostname, not barelocalhost— see the HSTS note. - Strong, unique
MARIADB_ROOT_PASSWORDandMARIADB_PASSWORD. -
.env.prodis not committed (the.env*gitignore rule covers it) and ischmod 600. - TLS verified —
https://your-domainshows a valid Let's Encrypt cert. - SMTP configured so account confirmation/reset emails actually send.
-
BOOTSTRAP_ADMIN_EMAIL/BOOTSTRAP_ADMIN_PASSWORDset 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_keysis 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.