Repository navigation
fix(serving): name php_fastcgi's root so its health check asks the web root - #1296
Merged
Merged
Conversation
💰 Infracost reportThis pull request is aligned with your company's FinOps policies and the Well-Architected Framework. Monthly estimate generatedEstimate details (includes details of unsupported resources)There are also 10 pre-existing issues in |
|
OpenTofu plan in docker Plan: 2 to add, 0 to change, 2 to destroy.OpenTofu used the selected providers to generate the following execution
plan. Resource actions are indicated with the following symbols:
+/- create replacement and then destroy
OpenTofu will perform the following actions:
# docker_container.fastcgi must be replaced
+/- resource "docker_container" "fastcgi" {
+ bridge = (known after apply)
!~ command = [
- "/usr/local/bin/run",
] -> (known after apply)
+ container_logs = (known after apply)
- cpu_shares = 0 -> null
- dns = [] -> null
- dns_opts = [] -> null
- dns_search = [] -> null
!~ entrypoint = [
- "/tini",
- "--",
] -> (known after apply)
!~ env = [ # forces replacement
- "FCGI_URL=127.0.0.1:9101",
- "PHP_FPM_LISTEN=9001",
- "PHP_FPM_PROBE_LISTEN=9101",
+ "FCGI_URL=127.0.0.1:9100",
+ "PHP_FPM_LISTEN=9000",
+ "PHP_FPM_PROBE_LISTEN=9100",
# (35 unchanged elements hidden)
]
+ exit_code = (known after apply)
- group_add = [] -> null
!~ hostname = "ip-10-20-8-176.ap-northeast-2.compute.internal" -> (known after apply)
!~ id = "****************************************************************" -> (known after apply)
!~ init = false -> (known after apply)
!~ ipc_mode = "private" -> (known after apply)
- log_opts = {} -> null
- max_retry_count = 0 -> null
!~ name = "fastcgi-99" -> "fastcgi-100" # forces replacement
!~ network_data = [
- {
- gateway = ""
- global_ipv6_address = ""
- global_ipv6_prefix_length = 0
- ip_address = ""
- ip_prefix_length = 0
- ipv6_gateway = ""
- mac_address = ""
- network_name = "host"
},
] -> (known after apply)
- privileged = false -> null
- publish_all_ports = false -> null
!~ runtime = "runc" -> (known after apply)
!~ security_opts = [] -> (known after apply)
!~ shm_size = 64 -> (known after apply)
!~ stop_timeout = 0 -> (known after apply)
- storage_opts = {} -> null
- sysctls = {} -> null
- tmpfs = {} -> null
- working_dir = "/srv/femiwiki.com" -> null
# (21 unchanged attributes hidden)
# (7 unchanged blocks hidden)
}
# docker_container.http must be replaced
+/- resource "docker_container" "http" {
+ bridge = (known after apply)
+ container_logs = (known after apply)
- cpu_shares = 0 -> null
- dns = [] -> null
- dns_opts = [] -> null
- dns_search = [] -> null
!~ entrypoint = [
- "/tini",
- "--",
] -> (known after apply)
!~ env = [ # forces replacement
- <<-EOT
FW_CADDYFILE={
# Global options
storage s3
order mwcache before rewrite
order respond before mwcache
order encode before mwcache
# The idle fastcgi port fails its check every second; keep that out of the
# log. FW_LOG_EXCLUDE adds to the list, so a logger that turns out to be
# too chatty can be silenced from infra without building an image.
log default {
exclude http.handlers.reverse_proxy.health_checker.active {$FW_LOG_EXCLUDE}
}
# CloudFront's origin-facing ranges, the only peers whose X-Forwarded-For
# is read. Strict takes the rightmost address they did not add, so a
# viewer cannot pick its own {client_ip} by sending the header. A request
# straight to the box keeps its connection address. See femiwiki#639.
servers {
metrics
trusted_proxies static {$FW_TRUSTED_PROXIES}
trusted_proxies_strict
}
}
# Headers that contradict the browser the User-Agent claims. Every term is
# conditioned on a Chromium claim, so Safari and Firefox are never tested, and
# one variable holds the lot so a term can change by an apply. CEL placeholders
# cannot go in it: {$VAR:default} eats the first } of a {http.request...}
# placeholder, which caddy adapt accepts and then silently mismatches. Hence
# header_regexp(), and !header_regexp(name, '.') for a header not sent at all.
(botlike) {
expression {$FW_BOTLIKE:(header_regexp('User-Agent', '(Chrome|Chromium|Edg|CriOS)/') && (!header_regexp('Sec-Ch-Ua', '.') || !header_regexp('Priority', '.') || header_regexp('Accept-Language', 'q=0\\.5'))) || !header_regexp('User-Agent', '(Mozilla/5\\.0|Opera)')}
}
# Old revisions and diffs: a space a crawler walks, rather than something a
# reader asks for once. Page history left for history_requests below.
(expensive_requests) {
match {
# api.php is left out: the search box asks it on every keystroke
path /index.php*
vars_regexp {query} (^|&)(diff|oldid)=
not header Cookie *femiwikiUserID=*
}
}
# History and info are one click each, and the reader is looking at the result,
# so they are counted on their own for the same reason search is.
(history_requests) {
match {
path /index.php*
vars_regexp {query} (^|&)action=(history|info)(&|$)
not header Cookie *femiwikiUserID=*
}
}
# Search is the one of these a reader performs on purpose and waits for, so it
# is counted on its own rather than out of the bucket the crawlers empty.
(search_requests) {
match {
path /index.php*
vars_regexp {query} (^|&)search=
not header Cookie *femiwikiUserID=*
}
}
femiwiki.com *.femiwiki.com 127.0.0.1:80 localhost:80 {
tls {
dns route53
}
root * /srv/docroot
# Requests that reach php-fpm without a session, per /24. Cache hits never
# get here, and the login and signup pages set a session cookie on first
# view, so a room full of people joining an edit-a-thon counts once each.
# Readers peak at a few a minute; crawlers at 30 and more, all day.
rate_limit {
zone dynamic {
match {
path /index.php* /api.php*
not header Cookie *femiwiki*
not vars_regexp {query} (^|&)action=cspreport(&|$)
}
key {client_ip}
ipv4_prefix 24
ipv6_prefix 64
events {$FW_DYNAMIC_EVENTS:300}
window {$FW_DYNAMIC_WINDOW:10m}
}
# Browsers' CSP violation reports, per /24 in a budget of their own so they spend
# none of the reader's. A reader never sees the answer; a refusal drops a report.
zone csp_report {
match {
path /api.php*
vars_regexp {query} (^|&)action=cspreport(&|$)
}
key {client_ip}
ipv4_prefix 24
ipv6_prefix 64
events {$FW_CSP_REPORT_EVENTS:120}
window {$FW_CSP_REPORT_WINDOW:10m}
}
# Diffs and old revisions from anyone not logged in, counted across the
# whole site. A crawler spread over enough /24s stays under the zone
# above in every one of them while together taking every php-fpm worker,
# and a session cookie is no exemption here since any client gets one
# from the login page. One bucket for everybody means a reader who is
# not logged in is refused as soon as crawlers have spent it, which is
# why the things a reader asks for once are counted elsewhere. Every
# zone reads its count and window from the environment so infra can
# retune them without waiting for an image.
zone expensive {
import expensive_requests
key expensive
events {$FW_EXPENSIVE_EVENTS:1500}
window {$FW_EXPENSIVE_WINDOW:10m}
}
# The same requests counted per /24 as well, so one heavy client spends
# its own budget before the shared one. The busiest /24 asks for about
# fifty in ten minutes, so this only bites a single hard scraper.
zone expensive_per_ip {
import expensive_requests
key {client_ip}
ipv4_prefix 24
ipv6_prefix 64
events {$FW_EXPENSIVE_IP_EVENTS:100}
window {$FW_EXPENSIVE_IP_WINDOW:10m}
}
# A reader searching is not a crawler following links, and sharing one
# bucket with them meant the wiki stopped answering searches for hours:
# with a scrape running, two logged-out searches in three were refused
# and one in nine returned results. Counted per /24, the busiest network
# searching was a person at fifteen an hour, ten of them inside one ten
# minute window, and every other /24 sat at four or below, so thirty
# leaves a person three times the room they use while a scraper working
# out of one network still runs out. A search that also carries the bot
# signature keeps its shared cap in botlike_expensive below.
zone search_per_ip {
import search_requests
key {client_ip}
ipv4_prefix 24
ipv6_prefix 64
events {$FW_SEARCH_EVENTS:30}
window {$FW_SEARCH_WINDOW:10m}
}
# History and info shared the bucket above, so a revision crawl spent it
# and a logged-out reader clicking 역사 was refused 95% of the time:
# 331 history requests an hour, 14 of them served. Counted per /24, the
# busiest network asking for history was 9 an hour, so 20 in ten minutes
# leaves a person twice the room they use, while the crawl gains nothing
# because it is spread over 883 networks and already under this in every
# one of them. The revision crawl keeps its cap in expensive above. A
# history request that also carries the bot signature keeps its shared
# cap in botlike_expensive below. See #902.
zone history_per_ip {
import history_requests
key {client_ip}
ipv4_prefix 24
ipv6_prefix 64
events {$FW_HISTORY_EVENTS:20}
window {$FW_HISTORY_WINDOW:10m}
}
# Red links, the mobile and desktop toggle, and what links here, from
# anyone not logged in, counted across the whole site. None of them is
# cached, and a reader clicks one now and then, but on 2026-09-30 a crawl
# spread over more than 500 addresses walked them at about 5,700 in half
# an hour (red links 85 to 2,418), filled the php-fpm queue and left
# every uncached page waiting 50 seconds. The zones above count per /24
# or only diffs and history, so none of this reached them.
zone link_walk {
match {
path /index.php*
vars_regexp {query} {$FW_LINK_WALK_QUERY:(?i)(^|&)(redlink=1|mobileaction=)|(^|&)title=(%ED%8A%B9%EC%88%98|특수|Special)(%3A|:)(%EA%B0%80%EB%A6%AC%ED%82%A4%EB%8A%94%EB%AC%B8%EC%84%9C|가리키는문서|WhatLinksHere)}
not header Cookie *femiwikiUserID=*
}
key link_walk
events {$FW_LINK_WALK_EVENTS:150}
window {$FW_LINK_WALK_WINDOW:10m}
}
# Flow post permalinks take 10 to 20 seconds each and are never cached.
# Drop the zone along with Flow.
zone flow_topics {
match {
path /index.php*
vars_regexp {query} {$FW_FLOW_TOPICS_QUERY:(^|&)(topic_showPostId|topic_postId|topic_revId)=|(^|&)action=single-view(&|$)}
not header Cookie *femiwikiUserID=*
}
key flow_topics
events {$FW_FLOW_TOPICS_EVENTS:30}
window {$FW_FLOW_TOPICS_WINDOW:10m}
}
# Sidebar page tools from anyone not logged in, site-wide, on the URI as sent
# since /w/ is rewritten first. New item calls our own api.php: two workers.
zone page_tools {
match {
method GET
vars_regexp {http.request.orig_uri} {$FW_PAGE_TOOLS_URI:(?i)[?&]action=(edit|watch|unwatch|credits)(&|$)|[?&]title=(%ED%8A%B9%EC%88%98|특수|Special)(%3A|:)(%EC%83%88%ED%95%AD%EB%AA%A9|새항목|NewItem|%EB%A7%81%ED%81%AC%EC%B5%9C%EA%B7%BC%EB%B0%94%EB%80%9C|링크최근바뀜|RecentChangesLinked|%EC%9D%B4%EB%AC%B8%EC%84%9C%EC%9D%B8%EC%9A%A9|이문서인용|CiteThisPage)|^/w/(%ED%8A%B9%EC%88%98|특수|Special)(%3A|:)(%EC%83%88%ED%95%AD%EB%AA%A9|새항목|NewItem|%EB%A7%81%ED%81%AC%EC%B5%9C%EA%B7%BC%EB%B0%94%EB%80%9C|링크최근바뀜|RecentChangesLinked|%EC%9D%B4%EB%AC%B8%EC%84%9C%EC%9D%B8%EC%9A%A9|이문서인용|CiteThisPage|%EA%B0%80%EB%A6%AC%ED%82%A4%EB%8A%94%EB%AC%B8%EC%84%9C|가리키는문서|WhatLinksHere)}
not header Cookie *femiwikiUserID=*
}
key page_tools
events {$FW_PAGE_TOOLS_EVENTS:150}
window {$FW_PAGE_TOOLS_WINDOW:10m}
}
# Logged-out special pages, site-wide, except the ones readers come for and the
# ones other things depend on; Route 53 health checks pass by range. See #1073.
zone special_pages {
match {
method GET HEAD
vars_regexp {http.request.orig_uri} {$FW_SPECIAL_URI:(?i)(^/w/|[?&]title=)(특수|%ED%8A%B9%EC%88%98|Special)(:|%3A)}
not vars_regexp {http.request.orig_uri} {$FW_SPECIAL_EXEMPT_URI:(?i)(^/w/|[?&]title=)(특수|%ED%8A%B9%EC%88%98|Special)(:|%3A)(로그인|%EB%A1%9C%EA%B7%B8%EC%9D%B8|사용자로그인|%EC%82%AC%EC%9A%A9%EC%9E%90%EB%A1%9C%EA%B7%B8%EC%9D%B8|UserLogin|Login|계정만들기|%EA%B3%84%EC%A0%95%EB%A7%8C%EB%93%A4%EA%B8%B0|가입|%EA%B0%80%EC%9E%85|CreateAccount|검색|%EA%B2%80%EC%83%89|찾기|%EC%B0%BE%EA%B8%B0|Search|최근바뀜|%EC%B5%9C%EA%B7%BC%EB%B0%94%EB%80%9C|RecentChanges|임의문서|%EC%9E%84%EC%9D%98%EB%AC%B8%EC%84%9C|Random|RandomPage|특수문서|%ED%8A%B9%EC%88%98%EB%AC%B8%EC%84%9C|특수기능|%ED%8A%B9%EC%88%98%EA%B8%B0%EB%8A%A5|SpecialPages|모바일설정|%EB%AA%A8%EB%B0%94%EC%9D%BC%EC%84%A4%EC%A0%95|MobileOptions|분류|%EB%B6%84%EB%A5%98|Categories|캡차|%EC%BA%A1%EC%B0%A8|Captcha|MathShowImage|비밀번호재설정|%EB%B9%84%EB%B0%80%EB%B2%88%ED%98%B8%EC%9E%AC%EC%84%A4%EC%A0%95|비밀번호초기화|%EB%B9%84%EB%B0%80%EB%B2%88%ED%98%B8%EC%B4%88%EA%B8%B0%ED%99%94|PasswordReset|이메일확인|%EC%9D%B4%EB%A9%94%EC%9D%BC%ED%99%95%EC%9D%B8|이메일인증|%EC%9D%B4%EB%A9%94%EC%9D%BC%EC%9D%B8%EC%A6%9D|ConfirmEmail|이메일인증취소|%EC%9D%B4%EB%A9%94%EC%9D%BC%EC%9D%B8%EC%A6%9D%EC%B7%A8%EC%86%8C|이메일인증해제|%EC%9D%B4%EB%A9%94%EC%9D%BC%EC%9D%B8%EC%A6%9D%ED%95%B4%EC%A0%9C|InvalidateEmail|개체데이터|%EA%B0%9C%EC%B2%B4%EB%8D%B0%EC%9D%B4%ED%84%B0|EntityData|MWO인증|MWO%EC%9D%B8%EC%A6%9D|OAuth|MWOAuth|파일경로|%ED%8C%8C%EC%9D%BC%EA%B2%BD%EB%A1%9C|그림경로|%EA%B7%B8%EB%A6%BC%EA%B2%BD%EB%A1%9C|FilePath|넘겨주기|%EB%84%98%EA%B2%A8%EC%A3%BC%EA%B8%B0|Redirect|로그아웃|%EB%A1%9C%EA%B7%B8%EC%95%84%EC%9B%83|사용자로그아웃|%EC%82%AC%EC%9A%A9%EC%9E%90%EB%A1%9C%EA%B7%B8%EC%95%84%EC%9B%83|UserLogout|Logout)(/|%2F|&|\?|$)}
not client_ip {$FW_ROUTE53_HEALTHCHECKS}
not header Cookie *femiwikiUserID=*
}
key special_pages
events {$FW_SPECIAL_EVENTS:60}
window {$FW_SPECIAL_WINDOW:10m}
}
# Login and signup pages opened from anywhere but a page here, site-wide; the
# #978 crawl forges search engine Referers. GET only, so an open form logs in.
zone login_walk {
match {
method GET
path /index.php*
vars_regexp {query} {$FW_LOGIN_WALK_QUERY:(?i)(^|&)title=(%ED%8A%B9%EC%88%98|특수|Special)(%3A|:)(%EB%A1%9C%EA%B7%B8%EC%9D%B8|로그인|%EA%B3%84%EC%A0%95%EB%A7%8C%EB%93%A4%EA%B8%B0|계정만들기|UserLogin|CreateAccount)(/|&|$)}
not header_regexp Referer ^https://([a-z0-9-]+\.)?femiwiki\.com/
}
key login_walk
events {$FW_LOGIN_WALK_EVENTS:60}
window {$FW_LOGIN_WALK_WINDOW:10m}
}
# The login page's language links, site-wide. A crawl walking them sends the login
# page itself as Referer, so login_walk misses it. Readers rarely click them.
zone login_uselang {
match {
method GET
path /index.php*
vars_regexp {query} {$FW_LOGIN_USELANG_QUERY:(?i)(^|&)title=(%ED%8A%B9%EC%88%98|특수|Special)(%3A|:)(%EB%A1%9C%EA%B7%B8%EC%9D%B8|로그인|%EA%B3%84%EC%A0%95%EB%A7%8C%EB%93%A4%EA%B8%B0|계정만들기|UserLogin|CreateAccount)(/[^&]*)?&(.*&)?uselang=|(^|&)uselang=[^&]*&(.*&)?title=(%ED%8A%B9%EC%88%98|특수|Special)(%3A|:)(%EB%A1%9C%EA%B7%B8%EC%9D%B8|로그인|%EA%B3%84%EC%A0%95%EB%A7%8C%EB%93%A4%EA%B8%B0|계정만들기|UserLogin|CreateAccount)(/[^&]*)?(&|$)}
not header Cookie *femiwikiUserID=*
}
key login_uselang
events {$FW_LOGIN_USELANG_EVENTS:30}
window {$FW_LOGIN_USELANG_WINDOW:10m}
}
# Anything that calls itself a crawler, counted per agent string. These
# are a quarter of the time php-fpm spends, and nearly all of it is
# article reads that robots.txt allows, so a budget is the only thing
# that reaches them. A bucket each rather than a shared one, so a
# crawler that spends its own does not refuse Googlebot; and a pattern
# rather than a list of names, so one nobody has heard of yet is
# covered on the day it arrives. A reader never writes any of these
# three words in a User-Agent.
zone crawlers {
match {
path /index.php* /api.php* /w/*
header_regexp User-Agent {$FW_CRAWLER_AGENTS:(?i)(bot|spider|crawl)}
}
key {http.request.header.User-Agent}
events {$FW_CRAWLER_EVENTS:100}
window {$FW_CRAWLER_WINDOW:10m}
}
# The crawlers that send no readers back. Measured on generation 60, the
# declared crawlers were 23% of the time php-fpm spent, and the search
# engines among them about 8%: Meta's alone was 9.4% and Googlebot was
# one request in twelve minutes. A tighter budget for everything that is
# not a search engine takes the larger half without costing a reader who
# arrives from a search. Both the list and the count are variables, so
# moving an agent from one side to the other is an apply.
zone crawlers_lean {
match {
path /index.php* /api.php* /w/*
header_regexp User-Agent {$FW_CRAWLER_AGENTS:(?i)(bot|spider|crawl)}
not header_regexp User-Agent {$FW_SEARCH_AGENTS:(?i)(Googlebot|bingbot|Applebot|YandexBot|SeznamBot|DuckDuckBot|Yeti|Daum)}
}
key {http.request.header.User-Agent}
events {$FW_CRAWLER_LEAN_EVENTS:10}
window {$FW_CRAWLER_LEAN_WINDOW:10m}
}
# Searches, diffs, old revisions and page history from a faked browser.
# One bucket for all of them, so a reader the signature misreads is
# refused only while crawlers are spending it, where a 403 refused them
# always. A plain article read arrives here rewritten to /index.php
# with no query and never counts. See femiwiki#523 and #586.
zone botlike_expensive {
match {
path /index.php*
vars_regexp {query} {$FW_EXPENSIVE_QUERY:(^|&)(search|diff|direction|oldid)=|(^|&)action=(history|info)(&|$)}
not header Cookie *femiwikiUserID=*
import botlike
}
key botlike_expensive
events {$FW_BOTLIKE_EVENTS:150}
window {$FW_BOTLIKE_WINDOW:10m}
}
# The api.php calls the skin makes on every article view, from a faked
# browser. Readers make them too, so a budget rather than a 403. See #1038.
zone botlike_api {
match {
path /api.php*
vars_regexp {query} {$FW_API_QUERY:(^|&)action=flow(&|$)|(^|&)inprop=watchers|(^|&)prop=linkshere}
not header Cookie *femiwikiUserID=*
import botlike
}
key botlike_api
events {$FW_BOTLIKE_API_EVENTS:150}
window {$FW_BOTLIKE_API_WINDOW:10m}
}
log_key
# The module keeps a histogram per key, one series set per /24 seen
disable_metrics
# Counters in memory would reset with every deploy, so each generation
# adds in the counts the others write to the bucket
distributed {
read_interval {$FW_RATE_LIMIT_SYNC:30s}
write_interval {$FW_RATE_LIMIT_SYNC:30s}
purge_age 20m
}
storage s3 {
host {$FW_RATE_LIMIT_S3_HOST}
bucket {$FW_RATE_LIMIT_S3_BUCKET}
prefix instances
use_iam_provider true
}
}
# 429.html and 429.json are built from MediaWiki's own messages by 429.php;
# the page is English, the browser fetches its own language from the json
handle_errors 429 {
rewrite * /429.html
file_server {
status 429
}
}
# Two ports so a new fastcgi container can start beside the old one
php_fastcgi 127.0.0.1:9000 127.0.0.1:9001 {
# Take the response off php-fpm at memory speed so a slow reader holds
# Caddy rather than a worker. Responses here are 8 KB to 150 KB, and a
# larger one streams as before instead of failing. See femiwiki#583.
response_buffers {$FW_RESPONSE_BUFFERS:1MB}
# php_fastcgi sets REMOTE_ADDR from the connection, which behind
# CloudFront is an edge; MediaWiki blocks and CheckUser want the reader
env REMOTE_ADDR {client_ip}
lb_policy round_robin
# A full php-fpm queue drops the connection attempt, so keep trying the
# ports this long before a 502 or the fallback below (#1121)
lb_try_duration {$FW_LB_TRY_DURATION:10s}
health_uri /healthz.php
health_interval 1s
health_timeout 10s
health_fails 3
# No index.php fallback: a path that is not a file is a 404, not the main page
try_files {path} {path}/index.php
}
# When no port passes /healthz.php, send reads to any php-fpm that answers
# instead of a 503. A deploy drains one port only once the other is
# healthy, so it never gets here; a drain file left behind, as on
# 2026-09-21, does. Anything with a body still gets the 503, because the
# proxy above has already read it. Caddy hands this route the original
# URI, so the rewrites below are repeated. The fallback field keeps these
# out of the Site down alert's count of good responses.
handle_errors 503 {
@read method GET HEAD
handle @read {
log_append fallback true
rewrite /w/api.php /api.php
rewrite /w/* /index.php
php_fastcgi 127.0.0.1:9000 127.0.0.1:9001 {
try_files {path} {path}/index.php
response_buffers {$FW_RESPONSE_BUFFERS:1MB}
env REMOTE_ADDR {client_ip}
lb_policy round_robin
lb_retries 1
# Try the other port when one refuses; left alone, only GET is retried
lb_retry_match {
method GET HEAD
}
}
encode gzip
}
handle {
respond 503
}
}
file_server
encode gzip
mwcache {
# A budget in bytes rather than a count of entries. 64 MiB holds about
# 1,400 responses of the size this wiki serves, well above the 595
# distinct addresses an ordinary twenty minutes asks for, and leaves
# room under this container's limit. num_counters wants to be about ten
# times the entries the budget holds. ignore_internal_cost is gone with
# the count: ristretto's per entry overhead is memory really used, so a
# budget in bytes wants it counted.
ristretto {
num_counters {$FW_MWCACHE_NUM_COUNTERS:14000}
max_cost_bytes {$FW_MWCACHE_MAX_COST_BYTES:67108864}
buffer_items 64
}
purge_acl {
172.31.0.0/16
10.20.0.0/16
127.0.0.1
}
# The key holds the host: www serves the same pages, and MediaWiki
# purges through 127.0.0.1 ($wgInternalServer). See femiwiki/caddy-mwcache#170.
host_alias femiwiki.com www.femiwiki.com 127.0.0.1 localhost
# Cache-Control for skin icons and other files MediaWiki links with a
# version hash, which browsers otherwise revalidate on every page
static {
paths /fw-resources/* /resources/* /skins/* /extensions/*
unversioned_max_age 5m
}
}
header {
# HSTS
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
# Enable XSS filtering for legacy browsers
X-XSS-Protection "1; mode=block"
# Block content sniffing, and enable Cross-Origin Read Blocking
X-Content-Type-Options "nosniff"
# Avoid clickjacking
X-Frame-Options "DENY"
}
log {
# Two thirds of a line used to be headers that are the same on every
# request: the security headers this block sets, Caddy's Via and Alt-Svc,
# PHP's X-Powered-By, a Date that ts already carries, and a TLS handshake
# that never varies. Only the fields that tell one request from another
# are left.
format filter {
wrap json
fields {
request>tls delete
request>headers>Accept delete
request>headers>Accept-Encoding delete
resp_headers>Accept-Ch delete
resp_headers>Alt-Svc delete
resp_headers>Date delete
resp_headers>Strict-Transport-Security delete
resp_headers>Via delete
resp_headers>X-Content-Type-Options delete
resp_headers>X-Frame-Options delete
resp_headers>X-Powered-By delete
resp_headers>X-Xss-Protection delete
}
}
}
# A page render pulls the same handful of skin icons every time, and on
# 2026-09-29 a distributed harvest asking each one for its first 500 bytes
# took them from 2.8% of the access log to 28%: eleven URIs, four of them
# 70%. They are read off disk under a version hash, so a line per hit
# answers nothing the next line does not. What goes with them is the 404 a
# renamed asset would have shown here.
@static_image path_regexp ^/(fw-resources|resources|skins)/.+\.(svg|png|jpe?g|gif|ico|webp)$
log_skip @static_image
# Paths that will never serve again, from serving/dead-urls.txt; respond runs
# before rewrite, so this answers ahead of every rate limit and PHP (#1185)
map {http.request.orig_uri.path} {dead_url} {dead_status} {
{$FW_DEAD_URLS}
default "" ""
}
@dead not vars {dead_url} ""
log_append @dead dead_url {dead_url}
respond @dead "" {dead_status}
rewrite /w/api.php /api.php
rewrite /w/* /index.php
# Core resolves a title from oldid or diff only when the title is not a
# special page, so these do nothing there but put the request in the
# expensive zone, where they spend the budget real old-revision viewing
# draws on: 204 an hour measured on 2026-09-27, 364 of 408 already refused.
# Stripped rather than refused because only about half carry the botlike
# signature and the rest present as ordinary mobile browsers. See #910.
@special_title {
path /index.php*
vars_regexp {query} (^|&)title=(%ED%8A%B9%EC%88%98|특수|Special)(%3A|%3a|:)
}
uri @special_title query -oldid
uri @special_title query -diff
respond /health-check 200
# The file itself with Wikimedia's favicon headers, instead of MediaWiki's empty 200 (#1073)
@favicon path /favicon.ico
rewrite @favicon /fw-resources/favicons/favicon-32.png
header @favicon >Cache-Control "public, s-maxage=31536000, max-age=31536000, must-revalidate"
# Code under the asset directories is never a page; only its files are served
@asset_code path_regexp (?i)^/(resources|skins|extensions)/(.*/)?(\.[^/]*|[^/]*\.php)(/|$)
respond @asset_code 404
}
# php-fpm's status page and opcache for Alloy on the host. infra alternates
# every port here per generation, the way it does the probe port, so the
# previous container cannot answer for the new one. The status page has a port
# of its own, so it answers while www is saturated; the opcache report is a
# script and needs a www worker. /srv/metrics is outside the public site's root.
http://127.0.0.1:{$FW_METRICS_PORT} {
bind 127.0.0.1
handle /fpm-status {
reverse_proxy 127.0.0.1:{$PHP_FPM_STATUS_LISTEN} {
transport fastcgi {
root /srv/metrics
# Leaves /fpm-status whole as SCRIPT_NAME, which is what php-fpm matches
split .php
}
}
}
handle /opcache.php {
reverse_proxy 127.0.0.1:{$PHP_FPM_LISTEN} {
transport fastcgi {
root /srv/metrics
split .php
}
}
}
}
# A port of this container's own, so the healthcheck cannot be answered by the
# previous Caddy sharing :80 on the host network; infra alternates it per
# generation like php-fpm's
http://127.0.0.1:{$FW_PROBE_PORT} {
bind 127.0.0.1
respond /health-check 200
}
EOT,
- "FW_METRICS_PORT=9181",
- "FW_PROBE_PORT=8081",
- "PHP_FPM_LISTEN=9001",
- "PHP_FPM_STATUS_LISTEN=9201",
+ <<-EOT
FW_CADDYFILE={
# Global options
storage s3
order mwcache before rewrite
order respond before mwcache
order encode before mwcache
# The idle fastcgi port fails its check every second; keep that out of the
# log. FW_LOG_EXCLUDE adds to the list, so a logger that turns out to be
# too chatty can be silenced from infra without building an image.
log default {
exclude http.handlers.reverse_proxy.health_checker.active {$FW_LOG_EXCLUDE}
}
# CloudFront's origin-facing ranges, the only peers whose X-Forwarded-For
# is read. Strict takes the rightmost address they did not add, so a
# viewer cannot pick its own {client_ip} by sending the header. A request
# straight to the box keeps its connection address. See femiwiki#639.
servers {
metrics
trusted_proxies static {$FW_TRUSTED_PROXIES}
trusted_proxies_strict
}
}
# Headers that contradict the browser the User-Agent claims. Every term is
# conditioned on a Chromium claim, so Safari and Firefox are never tested, and
# one variable holds the lot so a term can change by an apply. CEL placeholders
# cannot go in it: {$VAR:default} eats the first } of a {http.request...}
# placeholder, which caddy adapt accepts and then silently mismatches. Hence
# header_regexp(), and !header_regexp(name, '.') for a header not sent at all.
(botlike) {
expression {$FW_BOTLIKE:(header_regexp('User-Agent', '(Chrome|Chromium|Edg|CriOS)/') && (!header_regexp('Sec-Ch-Ua', '.') || !header_regexp('Priority', '.') || header_regexp('Accept-Language', 'q=0\\.5'))) || !header_regexp('User-Agent', '(Mozilla/5\\.0|Opera)')}
}
# Old revisions and diffs: a space a crawler walks, rather than something a
# reader asks for once. Page history left for history_requests below.
(expensive_requests) {
match {
# api.php is left out: the search box asks it on every keystroke
path /index.php*
vars_regexp {query} (^|&)(diff|oldid)=
not header Cookie *femiwikiUserID=*
}
}
# History and info are one click each, and the reader is looking at the result,
# so they are counted on their own for the same reason search is.
(history_requests) {
match {
path /index.php*
vars_regexp {query} (^|&)action=(history|info)(&|$)
not header Cookie *femiwikiUserID=*
}
}
# Search is the one of these a reader performs on purpose and waits for, so it
# is counted on its own rather than out of the bucket the crawlers empty.
(search_requests) {
match {
path /index.php*
vars_regexp {query} (^|&)search=
not header Cookie *femiwikiUserID=*
}
}
femiwiki.com *.femiwiki.com 127.0.0.1:80 localhost:80 {
tls {
dns route53
}
root * /srv/docroot
# Requests that reach php-fpm without a session, per /24. Cache hits never
# get here, and the login and signup pages set a session cookie on first
# view, so a room full of people joining an edit-a-thon counts once each.
# Readers peak at a few a minute; crawlers at 30 and more, all day.
rate_limit {
zone dynamic {
match {
path /index.php* /api.php*
not header Cookie *femiwiki*
not vars_regexp {query} (^|&)action=cspreport(&|$)
}
key {client_ip}
ipv4_prefix 24
ipv6_prefix 64
events {$FW_DYNAMIC_EVENTS:300}
window {$FW_DYNAMIC_WINDOW:10m}
}
# Browsers' CSP violation reports, per /24 in a budget of their own so they spend
# none of the reader's. A reader never sees the answer; a refusal drops a report.
zone csp_report {
match {
path /api.php*
vars_regexp {query} (^|&)action=cspreport(&|$)
}
key {client_ip}
ipv4_prefix 24
ipv6_prefix 64
events {$FW_CSP_REPORT_EVENTS:120}
window {$FW_CSP_REPORT_WINDOW:10m}
}
# Diffs and old revisions from anyone not logged in, counted across the
# whole site. A crawler spread over enough /24s stays under the zone
# above in every one of them while together taking every php-fpm worker,
# and a session cookie is no exemption here since any client gets one
# from the login page. One bucket for everybody means a reader who is
# not logged in is refused as soon as crawlers have spent it, which is
# why the things a reader asks for once are counted elsewhere. Every
# zone reads its count and window from the environment so infra can
# retune them without waiting for an image.
zone expensive {
import expensive_requests
key expensive
events {$FW_EXPENSIVE_EVENTS:1500}
window {$FW_EXPENSIVE_WINDOW:10m}
}
# The same requests counted per /24 as well, so one heavy client spends
# its own budget before the shared one. The busiest /24 asks for about
# fifty in ten minutes, so this only bites a single hard scraper.
zone expensive_per_ip {
import expensive_requests
key {client_ip}
ipv4_prefix 24
ipv6_prefix 64
events {$FW_EXPENSIVE_IP_EVENTS:100}
window {$FW_EXPENSIVE_IP_WINDOW:10m}
}
# A reader searching is not a crawler following links, and sharing one
# bucket with them meant the wiki stopped answering searches for hours:
# with a scrape running, two logged-out searches in three were refused
# and one in nine returned results. Counted per /24, the busiest network
# searching was a person at fifteen an hour, ten of them inside one ten
# minute window, and every other /24 sat at four or below, so thirty
# leaves a person three times the room they use while a scraper working
# out of one network still runs out. A search that also carries the bot
# signature keeps its shared cap in botlike_expensive below.
zone search_per_ip {
import search_requests
key {client_ip}
ipv4_prefix 24
ipv6_prefix 64
events {$FW_SEARCH_EVENTS:30}
window {$FW_SEARCH_WINDOW:10m}
}
# History and info shared the bucket above, so a revision crawl spent it
# and a logged-out reader clicking 역사 was refused 95% of the time:
# 331 history requests an hour, 14 of them served. Counted per /24, the
# busiest network asking for history was 9 an hour, so 20 in ten minutes
# leaves a person twice the room they use, while the crawl gains nothing
# because it is spread over 883 networks and already under this in every
# one of them. The revision crawl keeps its cap in expensive above. A
# history request that also carries the bot signature keeps its shared
# cap in botlike_expensive below. See #902.
zone history_per_ip {
import history_requests
key {client_ip}
ipv4_prefix 24
ipv6_prefix 64
events {$FW_HISTORY_EVENTS:20}
window {$FW_HISTORY_WINDOW:10m}
}
# Red links, the mobile and desktop toggle, and what links here, from
# anyone not logged in, counted across the whole site. None of them is
# cached, and a reader clicks one now and then, but on 2026-09-30 a crawl
# spread over more than 500 addresses walked them at about 5,700 in half
# an hour (red links 85 to 2,418), filled the php-fpm queue and left
# every uncached page waiting 50 seconds. The zones above count per /24
# or only diffs and history, so none of this reached them.
zone link_walk {
match {
path /index.php*
vars_regexp {query} {$FW_LINK_WALK_QUERY:(?i)(^|&)(redlink=1|mobileaction=)|(^|&)title=(%ED%8A%B9%EC%88%98|특수|Special)(%3A|:)(%EA%B0%80%EB%A6%AC%ED%82%A4%EB%8A%94%EB%AC%B8%EC%84%9C|가리키는문서|WhatLinksHere)}
not header Cookie *femiwikiUserID=*
}
key link_walk
events {$FW_LINK_WALK_EVENTS:150}
window {$FW_LINK_WALK_WINDOW:10m}
}
# Flow post permalinks take 10 to 20 seconds each and are never cached.
# Drop the zone along with Flow.
zone flow_topics {
match {
path /index.php*
vars_regexp {query} {$FW_FLOW_TOPICS_QUERY:(^|&)(topic_showPostId|topic_postId|topic_revId)=|(^|&)action=single-view(&|$)}
not header Cookie *femiwikiUserID=*
}
key flow_topics
events {$FW_FLOW_TOPICS_EVENTS:30}
window {$FW_FLOW_TOPICS_WINDOW:10m}
}
# Sidebar page tools from anyone not logged in, site-wide, on the URI as sent
# since /w/ is rewritten first. New item calls our own api.php: two workers.
zone page_tools {
match {
method GET
vars_regexp {http.request.orig_uri} {$FW_PAGE_TOOLS_URI:(?i)[?&]action=(edit|watch|unwatch|credits)(&|$)|[?&]title=(%ED%8A%B9%EC%88%98|특수|Special)(%3A|:)(%EC%83%88%ED%95%AD%EB%AA%A9|새항목|NewItem|%EB%A7%81%ED%81%AC%EC%B5%9C%EA%B7%BC%EB%B0%94%EB%80%9C|링크최근바뀜|RecentChangesLinked|%EC%9D%B4%EB%AC%B8%EC%84%9C%EC%9D%B8%EC%9A%A9|이문서인용|CiteThisPage)|^/w/(%ED%8A%B9%EC%88%98|특수|Special)(%3A|:)(%EC%83%88%ED%95%AD%EB%AA%A9|새항목|NewItem|%EB%A7%81%ED%81%AC%EC%B5%9C%EA%B7%BC%EB%B0%94%EB%80%9C|링크최근바뀜|RecentChangesLinked|%EC%9D%B4%EB%AC%B8%EC%84%9C%EC%9D%B8%EC%9A%A9|이문서인용|CiteThisPage|%EA%B0%80%EB%A6%AC%ED%82%A4%EB%8A%94%EB%AC%B8%EC%84%9C|가리키는문서|WhatLinksHere)}
not header Cookie *femiwikiUserID=*
}
key page_tools
events {$FW_PAGE_TOOLS_EVENTS:150}
window {$FW_PAGE_TOOLS_WINDOW:10m}
}
# Logged-out special pages, site-wide, except the ones readers come for and the
# ones other things depend on; Route 53 health checks pass by range. See #1073.
zone special_pages {
match {
method GET HEAD
vars_regexp {http.request.orig_uri} {$FW_SPECIAL_URI:(?i)(^/w/|[?&]title=)(특수|%ED%8A%B9%EC%88%98|Special)(:|%3A)}
Plan is too large to fit in a PR comment. See the full plan in the workflow log.✅ Plan applied in tofu #408 |
lens0021
enabled auto-merge (squash)
October 10, 2026 18:13
…b root Refs femiwiki/docker-mediawiki#1333 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EUs9fEBo3i9rYmgqBJ2uTQ
lens0021
force-pushed
the
claude/fastcgi-transport-root
branch
from
October 10, 2026 18:14
4d00097 to
b8b9af6
Compare
|
docker apply: success in tofu #408, the start cut to fit a comment Apply log |
This branch was successfully deployed
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.
Generation 100. Caddy's active health check has no request root, so
health_uri /healthz.phpresolved against Caddy's working directory,/srv/femiwiki.com, and generation 98 (femiwiki/docker-mediawiki#1479, which removed that path) read every port unhealthy. Naming the transport's root makes the check ask/srv/docroot/healthz.php. The 503 fallback'sphp_fastcgihas no health check and is left alone. See femiwiki/docker-mediawiki#1333.This ships only the Caddyfile line; the image stays
2026-10-10T17-04-5e8a1bf8, which hashealthz.phpat both paths. The #1479 image comes back with a later bump, after this serves.Checked in the #1479 image (
7971f4dd), php-fpm on 9000 and Caddy started from/srv/femiwiki.comascaddy-rundoes: without the line, health checks get 404 and/healthz.phpthrough Caddy 503; with it, 200 and 200.caddy adaptandcaddy fmtpass.🤖 Generated with Claude Code
https://claude.ai/code/session_01EUs9fEBo3i9rYmgqBJ2uTQ