Read Docker secrets from a mounted appsettings file, not the environment - #112
Merged
Conversation
…vironment Running the stack meant exporting SUBVORA_JWT_SECRET or keeping a .env, and because compose interpolates the whole file on every subcommand, forgetting it broke `ps`, `logs` and `down` too - not just `up`. Secrets now live in appsettings.Docker.json, which compose mounts read-only into the container. Nothing is passed on the command line, nothing sits in compose, and the file is gitignored with appsettings.Docker.example.json committed as the template. Mounted rather than copied in, and added to .dockerignore alongside appsettings.Development.json: `COPY src/ src/` was baking both into the image, so `docker run --entrypoint sh subvora-api -c 'ls /app/appsettings*.json'` listed a real signing key. The image now carries only appsettings.json, whose keys ship blank, and the example. Adds a mailpit service, which is how the SMTP problem surfaced. Reset codes and already-registered notices had been going nowhere locally, quietly, because SmtpEmailSender hardcoded SecureSocketOptions.StartTls and no local mail catcher offers TLS - every send failed with "The SMTP server does not support the STARTTLS extension". Since sends moved off the request path this only produced a log line, so it was invisible. STARTTLS is now Smtp:UseStartTls, defaulting to true. Not StartTlsWhenAvailable: a real server carries password reset codes, and silently falling back to plaintext when someone strips the server's advertisement is a downgrade attack. Local configs opt out explicitly instead. Verified against the running stack with no environment variables and no .env: health, register, login, subscriptions, dashboard and users/me all respond, and both email types arrive in mailpit. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
rghvgrv
added a commit
that referenced
this pull request
Aug 7, 2026
#112 introduced appsettings.Docker.json alongside appsettings.Development.json, which meant maintaining the same settings twice. Worse, each got its own generated signing key, so a token minted by `dotnet run` was rejected by the container and vice versa - two local environments that could not share a session. There is now one gitignored appsettings.Development.json. `dotnet run` reads it directly; compose mounts it into the container as appsettings.Docker.json, which is the name that environment loads. The environment stays Docker rather than becoming Development, because Program.cs skips UseHttpsRedirection only for Docker and the container has no HTTPS port to redirect to. Two settings cannot be shared and are overridden in compose. Neither is a secret: the database host, because inside a container localhost is the container, using the same throwaway password already spelled out on the db service; and the SMTP host, because the catcher is reachable as `mailpit` there and `localhost` here. The signing key is not among them - it stays in the file. Also publishes mailpit's 1025, so `dotnet run` can reach SMTP at the localhost:1025 the config already pointed at. Only 8025 was published before, so host-side mail went nowhere. appsettings.Development.json was tracked with only a Logging section, which is why every local change to it showed as an uncommitted diff. It is now gitignored with appsettings.Development.example.json committed as the template, matching how the Docker file was already handled, and still dockerignored so no key reaches an image layer. Verified with both running against the same file: tokens are accepted in both directions, and mail from each reaches mailpit. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Docker now reads every secret from a mounted
appsettings.Docker.json. Nothing is passed on the command line, stored indocker-compose.yml, or baked into the image.How to run it
cp src/SubVora.Api/appsettings.Docker.example.json src/SubVora.Api/appsettings.Docker.json # edit it: set Jwt:Secret to `openssl rand -base64 48` docker compose up -d --buildThat's the whole thing. No
export, no.env.Why the old way was worse than it looked
docker-compose.ymlhadJwt__Secret: "${SUBVORA_JWT_SECRET:?...}". Compose interpolates the whole file on every subcommand, so forgetting the variable didn't just breakup— it brokeps,logsanddowntoo. I hit that mid-verification: the stack was running fine anddocker compose psstill refused to answer.Mounted, not copied — and this one was a real leak
COPY src/ src/in the Dockerfile was baking environment config into the image:Both env files are now in
.dockerignore, and the real config arrives only through a read-only bind mount. After:The image carries no secret. Changing config no longer needs a rebuild either.
Mailpit, and the SMTP bug it exposed
Added a
mailpitservice so reset codes and already-registered notices are readable at http://localhost:8025.Which is how I found that local email had never worked.
SmtpEmailSenderhardcodedSecureSocketOptions.StartTls, and no local mail catcher offers TLS:Invisible, because #111 moved sends off the request path — it only ever produced a log line.
STARTTLS is now
Smtp:UseStartTls, defaulting to true. Deliberately notStartTlsWhenAvailable: a real server carries password reset codes, and silently dropping to plaintext when someone strips the server's advertisement is a downgrade attack. Local configs opt out explicitly.Verified on the running stack
No environment variables, no
.env, secret only in the mount:Migrations and the catalog sync run at start as before.
Notes
Create the file before the first
up. If it's missing, Docker creates a directory at the mount path and the API fails on an empty connection string. Called out in the README and in a compose comment.This pattern is for local Docker. Deployed environments should still inject config from the platform or a vault —
CLAUDE.mdstill says so, andappsettings.jsonstill ships its keys blank so the app fails fast rather than starting with an empty signing key.The example's placeholder carries an inline
pragma: allowlist secretso the scanner doesn't flag the template on every future baseline regeneration.