én dør inn til huset: caddy, docker, og reverse-proxyen

1. juli 2026 meta devlogdocker

seks containere, én dør

Hele stacken er seks tjenester i docker compose: postgres, minio, auth, blog, frontend, og caddy som sitter foran alt sammen. Bare én av dem, caddy, har faktisk en port eksponert mot verden utenfor. Alt annet snakker med alt annet over compose-nettverket, containernavn til containernavn, helt usynlig utenfra boksen.

Det er hele ideen bak en reverse-proxy, når du fjerner buzzwordene fra den: én dør inn til huset, og hvilket rom du enn faktisk ville ha, finner døren det ut for deg basert på stien du ba om. Nettleseren kjenner bare til én adresse noensinne. Den har ingen anelse om at auth og blog i det hele tatt er separate prosesser som kjører separat.

handle /media/* {
    request_body {
        max_size 2MB
    }
    reverse_proxy minio:9000
}

handle {
    request_body {
        max_size 2MB
    }
    reverse_proxy frontend:3000
}

Alt som ikke er eksplisitt matchet ovenfor faller til den siste bare handle-blokken nederst, som går til frontend. Frontend gjør så sin egen interne proxying til auth og blog for de faktiske API-kallene, så fra Caddys synspunkt er det egentlig bare to destinasjoner som betyr noe direkte: minio for rå mediefiler, og frontend for absolutt alt annet.

auto-TLS, tingen jeg faktisk ikke har trengt ennå

Caddys hele ryktet er automatisk HTTPS, den skal hente deg et ekte sertifikat fra Let's Encrypt bare ved å navngi et ekte domene i konfigurasjonen. Lokalt gjelder ingenting av det, {$DOMAIN} faller tilbake til localhost og Caddy serverer bare vanlig HTTP, ingen sertifikatdans nødvendig i det hele tatt. I det øyeblikket dette faktisk kommer på et ekte domene, skal den samme konfigurasjonslinjen bare begynne å be om og fornye sertifikater helt på egen hånd, ingen separat certbot-cronjobb, aldri manuell fornyelse. Har ikke testet den delen for ordentlig ennå siden denne bloggen ikke er live på et ekte domene ennå, noe som ærlig talt er delen jeg er minst sikker på vil gå knirkefritt på første forsøk. Alt ser alltid enkelt ut i dokumentasjonen helt til du er den som faktisk drifter det.

ett hopp, tiltrodd, og ingenting lenger tilbake enn det

Å sitte bak en proxy ødelegger to ting du ikke tenker på før de ødelegges: frontendens egen CSRF-sjekk, som sammenligner forespørselens opprinnelse mot det den tror er sin egen adresse, og rate limiting, som trenger den ekte besøkendes IP, ikke Caddys.

# Behind Caddy the browser's origin is the public DOMAIN, not :3000.
# Derive the origin from Caddy's forwarded headers so adapter-node's
# CSRF/origin check on form POSTs passes for both http://localhost and
# https://<domain>.
PROTOCOL_HEADER: x-forwarded-proto
HOST_HEADER: x-forwarded-host
ADDRESS_HEADER: x-forwarded-for
XFF_DEPTH: "1"

Den siste linjen, XFF_DEPTH: "1", tok lengst tid å faktisk forstå. Uten den returnerer getClientAddress() bare hvilken container som tilfeldigvis åpnet socket-en, som alltid er Caddy selv, siden Caddy er det eneste som noensinne kobler seg direkte til frontend-containeren. Å fortelle den å stole på nøyaktig ett hopp betyr at den leser klientadressen fra headeren Caddy selv la til, ikke fra noe en klient kunne ha forfalsket lenger opp i kjeden ved å sette inn sine egne falske oppføringer først. Får du det tallet feil i noen retning, stol på null hopp og hver besøkende ser ut som Caddy, stol på for mange og en ondsinnet klient kan bare lyve om hvem de er.

healthchecks: å få "oppe" til å bety noe

Hver tjeneste i compose har en healthcheck, og jeg tok ikke disse seriøst i starten, bare kopierte en fra et eksempel og gikk videre:

# what I copied from an example, didn't think about it further
frontend:
  healthcheck:
    test: ["CMD", "wget", "--spider", "-q", "http://localhost:3000/"]
    interval: 5s
    timeout: 5s
    retries: 5

Så trengte jeg faktisk depends_on: condition: service_healthy til å fungere riktig, siden blog ikke engang burde begynne å ta imot trafikk før postgres faktisk er klar til å svare på spørringer, ikke bare "container-prosessen startet."

frontend:
  healthcheck:
    # Use 127.0.0.1, not localhost: in Alpine localhost resolves to IPv6 ::1
    # while the Node server listens on IPv4, so a localhost probe fails and
    # the container is wrongly marked unhealthy.
    test: ["CMD", "wget", "--spider", "-q", "http://127.0.0.1:3000/"]
    interval: 5s
    timeout: 5s
    retries: 5

Den 127.0.0.1 i stedet for localhost kostet meg en genuint forvirret halvtime. På de Alpine-baserte imagene alt dette kjører på, løses localhost først til IPv6-loopback, men Node-serveren lytter bare på IPv4-socketen, så healthchecken koblet til ingenting, tidsavbrøt, og merket en helt fungerende container som usunn. Compose nektet så å starte alt som var avhengig av den, så hele stacken bare sto der, alt faktisk helt fint under overflaten, docker overbevist om det motsatte. Å bytte inn den bokstavelige IP-en fikset det helt, og nå bruker jeg som standard 127.0.0.1 i hver eneste healthcheck av vane fremfor å stole på at localhost betyr det jeg tror det betyr inne i en container.

Caddys egen healthcheck treffer sitt interne admin-API i stedet for det offentlige nettstedet av samme grunn, slik at den fortsetter å fungere når DOMAIN blir et ekte vertsnavn og en vanlig http://localhost-forespørsel ville sluttet å matche noe som helst.

Samme resonnement går gjennom postgres, minio, auth, og blog også, hver av dem tester noe som faktisk beviser at de er klare (pg_isready, MinIOs eget helse-endepunkt, hver Go-tjenestes /healthz) i stedet for bare å sjekke at prosessen ikke har krasjet rett ut. En krasjet prosess og en prosess som er oppe men fortsatt kjører sine egne oppstartsmigrasjoner ser identiske ut utenfra hvis alt du sjekker er "tok den imot en TCP-tilkobling," og jeg brydde meg først om det skillet da compose faktisk kappløp auths migrasjoner mot blog som prøvde å snakke med en database som ikke var klar for det ennå.

sti-matching-fellen

Én ting med Caddy som ikke er åpenbart fra en rask skumming av dokumentasjonen: handle-blokker matches ikke i den rekkefølgen du skrev dem i filen, de matches etter hvor spesifikt sti-mønsteret er. Det finnes en blokk for /auth/permissions som returnerer en bar 404 (det endepunktet serverer en intern rolle-til-kapasitet-matrise som ikke har noe å gjøre å være nåbar fra en offentlig GET), som sitter godt over catch-all-en handle {} helt nederst i filen. Betyr ingenting at den ikke er sist, den mer spesifikke stien vinner alltid uansett hvor den sitter. Tok å faktisk lese Caddys dokumentasjon i stedet for å gjette for å stole på det, siden det er nøyaktig den typen antagelse ("det er vel første match som vinner, som alt annet jeg har brukt") som stille ville ha ødelagt noe etter hvert, sannsynligvis på det verst tenkelige tidspunktet.

lastet Caddys konfigurasjon på nytt uten å miste en tilkobling

0 kommentarer

Logg inn for å kommentere.

Logg inn

Ingen konto?