Skip to content

fix(serving): name php_fastcgi's root so its health check asks the web root - #1296

Merged
github-actions[bot] merged 1 commit into
mainfrom
claude/fastcgi-transport-root
Oct 10, 2026
Merged

github-actions[bot] merged 1 commit into
mainfrom
claude/fastcgi-transport-root

Conversation

@lens0021

@lens0021 lens0021 commented Oct 10, 2026 •

Copy link
Copy Markdown
Member

Generation 100. Caddy's active health check has no request root, so health_uri /healthz.php resolved 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's php_fastcgi has 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 has healthz.php at 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.com as caddy-run does: without the line, health checks get 404 and /healthz.php through Caddy 503; with it, 200 and 200. caddy adapt and caddy fmt pass.

🤖 Generated with Claude Code

https://claude.ai/code/session_01EUs9fEBo3i9rYmgqBJ2uTQ

===fix===

*name php_fastcgi's root so its health check asks the web root [https://github.com/femiwiki/infra/pull/1296]

@lens0021
lens0021 requested a review from a team as a code owner October 10, 2026 18:03
@infracost

infracost Bot commented Oct 10, 2026 •

Copy link
Copy Markdown

💰 Infracost report

This pull request is aligned with your company's FinOps policies and the Well-Architected Framework.

Monthly estimate generated
Estimate details (includes details of unsupported resources)
Key: * usage cost, ~ changed, + added, - removed

──────────────────────────────────
Key: * usage cost, ~ changed, + added, - removed

*Usage costs can be estimated by updating [Infracost Cloud settings](https://dashboard.infracost.io/org/femiwiki/settings/usage-cost-defaults), see [docs](https://www.infracost.io/docs/features/usage_based_resources/#infracost-usageyml) for other options.

261 cloud resources were detected:
∙ 52 were estimated
∙ 200 were free
∙ 9 are not supported yet, see https://infracost.io/requested-resources:
  ∙ 1 x aws_athena_workgroup
  ∙ 1 x aws_bcmdataexports_export
  ∙ 1 x aws_cloudwatch_log_delivery
  ∙ 1 x aws_cloudwatch_log_delivery_destination
  ∙ 1 x aws_cloudwatch_log_delivery_source
  ∙ 1 x aws_eip_association
  ∙ 1 x aws_iam_outbound_web_identity_federation
  ∙ 1 x aws_subnet
  ∙ 1 x aws_vpc

There are also 10 pre-existing issues in main. Fix them with your AI coding agent - and climb your org's leaderboard 🥇

This comment will be updated when code changes.

@github-actions

github-actions Bot commented Oct 10, 2026 •

Copy link
Copy Markdown

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

@github-actions

Copy link
Copy Markdown

docker apply: success in tofu #408, the start cut to fit a comment

Apply log
                		# 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)}
                				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
                		# Named, as a health check has no request root and would ask Caddy's cwd
                		root /srv/docroot
                		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=9180",
          + "FW_PROBE_PORT=8080",
          + "PHP_FPM_LISTEN=9000",
          + "PHP_FPM_STATUS_LISTEN=9200",
            # (19 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                                        = "http-99" -> "http-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
        # (22 unchanged attributes hidden)

        # (4 unchanged blocks hidden)
    }

Plan: 2 to add, 0 to change, 2 to destroy.
Releasing state lock. This may take a few moments...
docker_container.http: Creating...
docker_container.fastcgi: Creating...
docker_container.http: Creation complete after 8s [id=3dc7688fa3c559f936319105a307e02ee715175dbd850188d1252d61a4d2107d]
docker_container.http (deposed object 4a5d12a6): Destroying... [id=11e6101071c7e60aad69e9ecc5f7c90bc226c1660bfc186e010989cc5ccdee50]
docker_container.fastcgi: Still creating... [10s elapsed]
docker_container.fastcgi: Creation complete after 14s [id=1d5841e80dbb13a3456f86a6baa7569e0374902a6fec98f1b8ae6aaf47d82630]
docker_container.fastcgi (deposed object b04241b1): Destroying... [id=1a93ff9d54485dd18fb2dfb60830ccece27fd8693bd6c4959c039bfd8655741b]
docker_container.http: Still destroying... [id=11e6101071c7e60aad69e9ecc5f7c90bc226c1660bfc186e010989cc5ccdee50, 10s elapsed]
docker_container.http: Destruction complete after 11s
docker_container.fastcgi: Destruction complete after 6s

Apply complete! Resources: 2 added, 0 changed, 2 destroyed.

@github-actions
github-actions Bot merged commit 6cc227c into main Oct 10, 2026
23 checks passed
@github-actions
github-actions Bot deleted the claude/fastcgi-transport-root branch October 10, 2026 18:21

This branch was successfully deployed

1 active deployment
docker — b8b9af65 Deployed Oct 10, 2026 by lens0021 via docker apply #408
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