Skip to content

Read Docker secrets from a mounted appsettings file, not the environment - #112

Merged
rghvgrv merged 1 commit into
mainfrom
feat/docker-appsettings
Aug 7, 2026
Merged

Read Docker secrets from a mounted appsettings file, not the environment#112
rghvgrv merged 1 commit into
mainfrom
feat/docker-appsettings

Conversation

@rghvgrv

@rghvgrv rghvgrv commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Docker now reads every secret from a mounted appsettings.Docker.json. Nothing is passed on the command line, stored in docker-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 --build

That's the whole thing. No export, no .env.

Why the old way was worse than it looked

docker-compose.yml had Jwt__Secret: "${SUBVORA_JWT_SECRET:?...}". Compose interpolates the whole file on every subcommand, so forgetting the variable didn't just break up — it broke ps, logs and down too. I hit that mid-verification: the stack was running fine and docker compose ps still 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:

$ docker run --rm --entrypoint sh subvora-api -c 'ls /app/appsettings*.json'
/app/appsettings.Development.json
/app/appsettings.Docker.example.json
/app/appsettings.Docker.json      ← real signing key, in a layer
/app/appsettings.json

Both env files are now in .dockerignore, and the real config arrives only through a read-only bind mount. After:

/app/appsettings.Docker.example.json
/app/appsettings.json      ← ships with blank keys

The image carries no secret. Changing config no longer needs a rebuild either.

Mailpit, and the SMTP bug it exposed

Added a mailpit service so reset codes and already-registered notices are readable at http://localhost:8025.

Which is how I found that local email had never worked. SmtpEmailSender hardcoded SecureSocketOptions.StartTls, and no local mail catcher offers TLS:

System.NotSupportedException: The SMTP server does not support the STARTTLS extension.

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 not StartTlsWhenAvailable: 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:

health                Healthy
register              202
login -> token        ok (347 chars)
subscriptions         200
dashboard/burn-rate   200
users/me              200
forgot-password       200
mail delivered        3 message(s)   ← both types visible in mailpit
api      Up
db       Up (healthy)
mailpit  Up (healthy)

Migrations and the catalog sync run at start as before.

Application.Tests      28 passed
Infrastructure.Tests   98 passed
Api.Tests              92 passed

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.md still says so, and appsettings.json still 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 secret so the scanner doesn't flag the template on every future baseline regeneration.

…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
rghvgrv merged commit a22e2ce into main Aug 7, 2026
2 checks passed
@rghvgrv
rghvgrv deleted the feat/docker-appsettings branch August 7, 2026 07:09
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant