Skip to content

feat(proxy): first-class proxy.run.acme with secrets-backed DNS credentials - #84

Merged
mhenrixon merged 1 commit into
dashfrom
issue-73-proxy-run-acme
Jul 29, 2026
Merged

mhenrixon merged 1 commit into
dashfrom
issue-73-proxy-run-acme

Conversation

@mhenrixon

Copy link
Copy Markdown
Collaborator

Summary

A first-class proxy.run.acme block, with DNS credentials routed through .kamal/secrets instead of the docker run escape hatch.

proxy:
  run:
    acme:
      email: admin@example.com
      dns_provider: cloudflare
      prefer_wildcard: true
      http_fallback: false
      directory: https://acme-staging-v02.api.letsencrypt.org/directory
      credentials:
        - CF_API_TOKEN
  • lib/kamal/configuration/proxy/acme.rb (new) — owns the ACME surface: the five run flags, the provider allowlist, and the credential env file.
  • lib/kamal/configuration/proxy/run.rb — run_command gains the acme flags; docker_options_args gains --env-file when credentials are set; config_digest gains the credential names.
  • lib/kamal/configuration/validator/proxy.rb — rejects an unsupported dns_provider naming the supported list, and requires email.
  • lib/kamal/cli/proxy.rb, lib/kamal/cli/proxy/reboot.rb — upload the credential env file at mode 0600 before the container is created.
  • lib/kamal/configuration.rb — validate_secrets! resolves the credentials, so a missing secret fails before any SSH.
  • bin/sync-proxy-flags, test/fixtures/kamal_proxy_flags.yml — the manifest now records the DNS providers kamal-proxy advertises.

Closes #73

Why the acme flags render differently

The rest of run_command renders --flag value. The acme flags render --flag=value, because Cobra only reads a boolean flag's value in --flag=false form — --acme-http-fallback false sets the flag true and leaves a stray positional argument. Both booleans default to true in the proxy, so false is exactly the value an operator writes.

Rendering the whole run command with = would have been tidier, but it turns --metrics-port 9090 into --metrics-port=9090 for every existing deploy — a different config_digest, and a fleet-wide proxy reboot on upgrade. Acceptance criterion 4 rules that out.

Why credentials never touch the command line

docker run --env TOKEN=... puts a credential that can rewrite your DNS zone into the host's process listing and into the audit log kamal writes. The credentials go to a 0600 env file at .kamal/proxy/acme.env and are passed with --env-file. That path sits under the proxy's own directory rather than the app's env directory, because the container is host-scoped and shared by every app on the host — and so kamal proxy remove takes the credentials with it.

Test plan

  • bundle exec rubocop --parallel — clean
  • Unit suite — 4 failures + 3 errors, all in builder_test/build_test; identical on pristine dash HEAD (host-arch dependent, pass in CI)
  • Deploy with dns_provider: cloudflare + a real CF_API_TOKEN and confirm a certificate issues via DNS-01
  • Confirm .kamal/proxy/acme.env lands at 0600 and is removed by kamal proxy remove
  • Confirm a deploy with no acme block does not reboot an existing proxy (digest unchanged)

Automated coverage

Test Proves
acme settings become kamal-proxy run flags AC1 — --acme-email / --acme-dns-provider reach the run command
boolean acme settings render with an explicit value when false --acme-http-fallback=false is emitted in the form Cobra reads
credentials are read from secrets and never reach the command line AC2 — resolved from .kamal/secrets, --env-file only, no value in the argv
boot writes acme credentials to a 0600 env file instead of the command line AC2 end-to-end through kamal proxy boot, including the file mode
reboot re-uploads the acme credentials before replacing the container the reboot path is not a hole in AC2
an unknown dns_provider fails validation naming the supported providers AC3 — fails at config time, before any host is contacted
validate_secrets! resolves the acme credentials before any host is contacted a missing secret fails pre-flight, not mid-deploy
no acme block leaves the run command untouched + config_digest is unchanged for a config with no acme block AC4 — pure addition
config_digest changes when acme settings change / when the credential names change AC5 — one reboot, fleet converges
the gem's DNS provider allowlist matches what kamal-proxy advertises the allowlist cannot silently drift from the image

Deviations & judgment calls

Deviations from the issue

  • The issue said "validate dns_provider against the provider list". Implemented as a hardcoded allowlist in Proxy::Acme plus a generated acme_dns_providers entry in test/fixtures/kamal_proxy_flags.yml and a canary asserting the two agree. A bare hardcoded list drifts the moment Epic: ACME DNS-01 provider coverage — registry refactor + curated providers dash-proxy#77 lands its 21 providers, and the drift's symptom is "the gem rejects a provider the proxy supports" — a blocked deploy for an operator who is holding it right. The canary makes that fail at the MINIMUM_VERSION bump instead, next to the existing flag-coverage check from test(proxy): fail the build when kamal-proxy grows a flag the gem cannot emit #83.
  • The acme_dns_providers fixture entry was written by hand — the Docker daemon was not running here, so bin/sync-proxy-flags could not be re-run. The value is transcribed from internal/cmd/run.go at tag v1.0.0.0 in the sibling proxy repo, and the script now generates it, so the next regeneration overwrites it. Worth a second pair of eyes.
  • The issue's own example uses dns_provider: loopia, which v1.0.0.0 does not support — so that exact config is now rejected. It becomes valid when Expose rate limiting, IP allow lists and client-IP identification in deploy.yml #77 ships and MINIMUM_VERSION moves.

Judgment calls

  • email is required whenever an acme block is present. ACME account registration needs it, and the issue's stated motivation is that misconfigured ACME "silently never issues". The cost: an operator who sets ACME_EMAIL through proxy.run.options.env and only wants prefer_wildcard: false is now forced to also name the email here. Judged the better trade.
  • No alias syntax on credentials. env.secret supports NAME:SECRET_NAME aliasing; credentials takes a plain list, because the env var name is dictated by lego (CF_API_TOKEN, LOOPIA_API_USER) and has to match. Add aliasing if someone hits it.
  • Credential names are in config_digest; credential values are not. Adding or renaming a credential reboots the proxy. Rotating a value does not — the digest is published as a docker label, and hashing secret material into a world-readable label buys an offline guessing target for nothing. Rotating a DNS token needs an explicit kamal proxy reboot; that is documented in docs/proxy.yml.
  • ACME is not wired into the load balancer. When load balancing, TLS terminates at the edge, so that is where an ACME block would need to take effect — but Kamal::Configuration::Loadbalancer emits no run flags whatsoever today, so --debug, --metrics-port and --recheck-targets-on-restore do not reach it either. Fixing that means making the load balancer emit run_command, which turns those on for existing load-balanced deploys. Out of scope for First-class proxy.run.acme block with secrets-backed DNS credentials #73 — worth its own issue.
  • Provider aliases (cf, r53, do, ...) are accepted but not drift-checked. kamal-proxy's ParseProviderName takes them; its --help does not advertise them, so they cannot be generated. They live in a separate DNS_PROVIDER_ALIASES constant.

…ntials

kamal-proxy grew a full ACME/DNS-01 story and none of it was reachable from
deploy.yml. The only way in was proxy.run.options.env, a docker run escape
hatch with no .kamal/secrets integration — so a DNS API token that can rewrite
your zone sat literal in deploy.yml unless the operator hand-rolled ERB.

proxy/run/acme now emits --acme-email, --acme-dns-provider, --acme-directory,
--acme-prefer-wildcard and --acme-http-fallback, and takes a credentials list of
secret names that never touches a command line.

Two details worth knowing:

The acme flags render with `=` while the rest of the run command renders with a
space. Cobra only reads a boolean flag's value in --flag=false form, and both
booleans default to true in the proxy — so false is exactly the value an
operator writes and exactly the one that would otherwise have been ignored.
Switching the whole run command to `=` would have been cleaner but changes
--metrics-port for every existing deploy, and with it the config digest.

Credentials go through a 0600 env file under .kamal/proxy, not --env: docker run
--env leaves the token in host process listings and in the audit log kamal
writes. Their names ride along in config_digest so swapping a credential reboots
the proxy; their values do not, because the digest is published as a docker
label.

The DNS provider allowlist is drift-checked against a generated manifest entry
rather than left as a hand-maintained constant — a provider the proxy grows and
the gem does not is a rejected deploy, not a missing feature.

Closes #73
@mhenrixon mhenrixon self-assigned this Jul 29, 2026
@mhenrixon mhenrixon added the enhancement New feature or request label Jul 29, 2026
@mhenrixon
mhenrixon merged commit be8b8f3 into dash Jul 29, 2026
9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

First-class proxy.run.acme block with secrets-backed DNS credentials

1 participant