Skip to content

docs(deploy): a Coolify runbook, checked against a real container - #63

Merged
derekslenk merged 1 commit into
mainfrom
feat/coolify-deploy
Aug 22, 2026
Merged

docs(deploy): a Coolify runbook, checked against a real container#63
derekslenk merged 1 commit into
mainfrom
feat/coolify-deploy

Conversation

@derekslenk

Copy link
Copy Markdown
Owner

Point Coolify at the repo, Build Pack Dockerfile, port 8091. The image already did the work — multi-stage, pinned digests, non-root under tini, /healthz healthcheck — so this is config and documentation, not a rebuild.

The footgun this exists for

AYS_TRUSTED_PROXIES is unset by default, and that default is correct: honouring X-Forwarded-For from any caller walks straight past the login throttle (CWE-348).

But _get_client_ip then falls back to the direct peer — and behind a reverse proxy the direct peer is the proxy, for every request. The limiter allows 5 attempts per 5 minutes per client, so:

Five failed logins by one stranger lock out every user of the instance.

Nothing looks wrong while that's misconfigured. The app starts, serves and throttles; the symptom arrives minutes later as "nobody can log in". So the app now says so at startup — a WARNING on a named logger, not a print, so it lands in the platform's log view and the suite can build a thousand apps without it becoming noise.

The default is not changed. Defaulting to "trust the private ranges" would reverse the F-2 fix and hand anything on the same network the ability to spoof past the throttle.

Running it found a real problem

Which is why it was run rather than written. Built the image, started it with production env:

AYS_ENV=prod  ->  curl /openapi.json  ->  200

That reads like the docs gate failing. It isn't — the response is the SPA shell, byte-identical to what any nonsense URL returns, because the static catch-all GET /{full_path:path} answers everything unmatched. The schema is not served.

But every test in test_docs_gating.py builds an app with no static dir and asserts 404 — a status code production never produces. The gate holds; the assertion mechanism stops being true the moment the app is deployed.

Two tests now cover the deployed shape:

  • static dir + env=prod → the body must not contain the schema
  • static dir + env=dev → the real JSON must still get past the catch-all, since route order decides that and route order is easy to change by accident

Mutation-checked: forcing is_dev True fails all three parametrized cases.

Every claim in the runbook was executed

Both branches, against real containers, and the output is pasted into the doc verbatim:

prod : <!doctype html>
dev  : {"openapi":"3.1.0","info":{"title":"AreY

The first draft also told operators to check the Secure cookie flag with curl -I /api/auth/status. There is no cookie there — cookies are set only by a successful login — so it was an instruction that would always look broken. Replaced with the browser check and the symptom to expect (logged straight back out).

Also in here

  • docker-compose.yml marked LOCAL DEVELOPMENT ONLY — it publishes to 127.0.0.1, which a platform's proxy cannot reach.
  • The README.md row for AYS_TRUSTED_PROXIES now names the behind-a-proxy consequence, not just the mechanism.
  • DEPLOY.md documents the two things that surprise operators: sessions are in memory so every redeploy logs everyone out (no volume needed — the store holds plaintext passwords and shouldn't outlive the process), and the SSRF guard refuses a private mail server, so one on the same Coolify box is unreachable by design.

Deliberately not done

  • No rate-limiter change. Keying on a shared proxy IP is the root cause, but changing throttle semantics needs its own threat model and tests. Better as its own bead than smuggled into a deploy task.
  • No .dockerignore change. It doesn't exclude .beads/ (23 MB, holds a credential key), but that can't reach an image — Coolify builds from a git clone, the key is gitignored, and the Dockerfile has no COPY . .. It's build-context hygiene, and areyousievious-b1r already owns that file.

Verification

  • 1125 passed, 4 xfailed · ruff clean · frontend untouched and green
  • Image built and run twice (prod and dev env), healthcheck reached healthy, container confirmed running as uid=1000(ays)
  • Startup warning confirmed present in a container without AYS_TRUSTED_PROXIES and absent in one with it

🤖 Generated with Claude Code

Point Coolify at the repo, Build Pack: Dockerfile, port 8091. The image
already did the work — multi-stage, pinned digests, non-root under tini,
/healthz HEALTHCHECK — so this is config and documentation, not a rebuild.

THE FOOTGUN THIS EXISTS FOR. AYS_TRUSTED_PROXIES is unset by default, and
that default is correct: honouring X-Forwarded-For from any caller walks
straight past the login throttle. But _get_client_ip then falls back to the
direct peer, and behind a reverse proxy the direct peer is the PROXY, for
every request. The limiter allows 5 attempts per 5 minutes per client, so
five failed logins by one stranger lock out every user of the instance.

Nothing looks wrong while that is misconfigured. The app starts, serves and
throttles; the symptom arrives minutes later as "nobody can log in". So the
app says so at startup — a WARNING on a named logger, not a print, so it
lands in the platform's log view and the suite can build a thousand apps
without it becoming noise. The default is NOT changed: defaulting to trust
the private ranges would reverse the F-2 fix and hand anything on the same
network the ability to spoof past the throttle.

RUNNING IT FOUND A REAL PROBLEM, which is why it was run rather than
written. Built the image and started it with production env:

    AYS_ENV=prod  ->  curl /openapi.json  ->  200

That reads like the docs gate failing. It is not: the response is the SPA
shell, identical to what any nonsense URL returns, because the static
catch-all GET /{full_path:path} answers everything unmatched. The schema is
not served.

But every test in test_docs_gating.py builds an app with NO static dir and
asserts 404 — a status code production never produces. The gate holds; the
assertion mechanism stops being true the moment the app is deployed. Two
tests now cover the deployed shape: with a static dir and env=prod the body
must not contain the schema, and with env=dev the real JSON must still get
past the catch-all, since route order decides that and route order is easy
to change by accident. Mutation-checked: forcing is_dev True fails all
three parametrized cases.

Every claim in DEPLOY.md's verification section was run against a real
container before being written down, both branches:

    prod : <!doctype html>
    dev  : {"openapi":"3.1.0","info":{"title":"AreY

The first draft of that section also told operators to check the Secure
cookie flag with `curl -I /api/auth/status`. There is no cookie there —
cookies are set only by a SUCCESSFUL login — so it was an instruction that
would always look broken. Replaced with the browser check and the symptom
to expect.

docker-compose.yml is marked LOCAL DEVELOPMENT ONLY: it publishes to
127.0.0.1, which a platform's proxy cannot reach.

.dockerignore does not exclude .beads/ (23 MB, holds a credential key). It
cannot reach an image — Coolify builds from a git clone, the key is
gitignored, and the Dockerfile has no COPY . . — so it is build-context
hygiene, and areyousievious-b1r already owns that file. Left alone.

1125 tests, 4 xfailed, ruff clean.
@claude

claude Bot commented Aug 22, 2026

Copy link
Copy Markdown

Claude finished @derekslenk's task in 3m 3s —— View job


Code Review in progress

Review is running in the background; will update this comment with findings once it completes.

@claude

claude Bot commented Aug 22, 2026

Copy link
Copy Markdown

Claude finished @derekslenk's task in 1m 49s —— View job


Claude finished the code review

Todo list:

  • Gather context (PR body, diff, changed files)
  • Understand request (code review)
  • Review each changed file against its claims
  • Post findings

Review

Went through every hunk in git diff origin/main...HEAD and cross-checked the doc/comment claims against the actual code:

  • backend/app.py: _warn_if_no_trusted_proxies correctly checks cfg.trusted_proxies (a tuple, so truthiness works as intended), uses a named logger (logging.getLogger("app")) rather than print, and is wired in before the docs-gating/static-router setup — matches the "warns at startup" claim.
  • backend/routers/auth.py (unchanged, read for verification): _get_client_ip and _is_secure confirm the exact behavior described — direct peer is used when no proxy is trusted, and behind a reverse proxy that peer is the proxy itself for every request, corroborating the shared-rate-limit-bucket claim.
  • backend/tests/test_proxy_trust_warning.py and the two new cases in test_docs_gating.py: assertions match real behavior (Settings has no validation on env, so env="" correctly falls to is_dev=False; the static-router module-global configure()/try…finally pattern matches the existing convention already used elsewhere in the suite).
  • docs/DEPLOY.md: every concrete claim checks out against the repo — Dockerfile exposes 8091, has a /healthz HEALTHCHECK, runs as USER 1000:1000 under tini; the SSRF guard's error string (ssrf.py:68) matches the quoted message verbatim; docker-compose.yml does publish to 127.0.0.1.
  • README.md / CHANGELOG.md / docs/AGENTS.md: wording changes are accurate and consistent with existing file conventions (new CHANGELOG.md entry is correctly placed newest-first under ### Added, matching the surrounding entries).

No bugs, inaccuracies, or unverified claims found — the diff matches what it documents, and the two new docs-gating tests close a real gap (the old suite only asserted a 404 that production never returns, since the SPA catch-all answers everything unmatched with 200).

No findings to report. This PR is well-scoped, and the "deliberately not done" section correctly identifies out-of-scope items (rate-limiter keying change, .dockerignore) rather than smuggling them in.

@derekslenk
derekslenk merged commit c43608d into main Aug 22, 2026
8 of 9 checks passed
@derekslenk
derekslenk deleted the feat/coolify-deploy branch August 22, 2026 23:23
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