Self-hosted Gitea on a single e2-small AlmaLinux 10 VM in us-east1, serving gitea.jasonmross.dev. Runtime is podman quadlets (systemd .container/.network/.volume units). Both images are built on Debian 13: Gitea from a GPG-verified release binary, and Caddy from an xcaddy build carrying the Google Cloud DNS provider (ACME DNS-01) and the Coraza WAF with the OWASP CRS embedded. Infrastructure is a Pulumi program in Go against a GCS state backend. Cloud Build handles CI: a push trigger for images, one for infra, and a weekly scheduled rebuild. Everything Cloud Build touches is 2nd gen. Notable design decisions, each documented where it lives: - Quadlets track a floating :prod tag. AutoUpdate=registry compares digests for a tag, so a digest-pinned image silently disables auto-updates. - Git transport and LFS bypass the WAF. With the bypass removed, a plain git push returns 403 -- packfiles trip CRS reliably. - gitea:wafMode drives both SecRuleEngine and whether the fail2ban jail acting on WAF verdicts exists. Banning on detections that were never blocks would turn a tuning false positive into an nftables ban. - fail2ban bans at the nftables prerouting hook. Published container ports are DNAT'd and never traverse INPUT, where the stock actions install their rules. - The DNS zone, backup bucket, and Gitea signing secrets are not Pulumi-owned, so pulumi destroy cannot take them with it. - The podman subnet is pinned because it is what Gitea's REVERSE_PROXY_TRUSTED_PROXIES names. Three update layers: dnf5-automatic for the OS, podman-auto-update with health-gated rollback for containers, and a weekly image rebuild that gives the second layer something to pull. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
112 lines
3.7 KiB
Cheetah
112 lines
3.7 KiB
Cheetah
# Rendered by vm/bootstrap.sh -> /etc/caddy/Caddyfile
|
|
#
|
|
# Two compile-time plugins are doing the work here:
|
|
#
|
|
# googleclouddns -- ACME DNS-01, so issuance and renewal never need inbound 80
|
|
# coraza_waf -- OWASP Coraza with the Core Rule Set embedded in the binary
|
|
{
|
|
email ${ACME_EMAIL}
|
|
admin 127.0.0.1:2019
|
|
# Required by coraza-caddy: Caddy has no built-in ordering for a third-party
|
|
# directive, and the WAF must run before anything that could act on the
|
|
# request. Applies inside handle blocks too.
|
|
order coraza_waf first
|
|
}
|
|
|
|
${DOMAIN} {
|
|
tls {
|
|
dns googleclouddns {
|
|
# Application Default Credentials come from the GCE metadata server.
|
|
# The VM service account holds roles/dns.admin scoped to this zone only.
|
|
gcp_project {env.GCP_PROJECT}
|
|
}
|
|
# Only used for propagation checks. If issuance stalls waiting for
|
|
# propagation, this is the knob to turn.
|
|
resolvers 8.8.8.8 8.8.4.4
|
|
}
|
|
|
|
encode zstd gzip
|
|
|
|
# Governs the git/LFS branch below. The WAF branch has its own, much smaller,
|
|
# SecRequestBodyLimit -- they apply to different routes and are not a mismatch
|
|
# to be "fixed".
|
|
request_body {
|
|
max_size 512MB
|
|
}
|
|
|
|
# ---------------------------------------------------------------------
|
|
# Git transport and LFS: deliberately NOT behind the WAF.
|
|
#
|
|
# Packfiles are binary and trip CRS's SQLi/XSS rules constantly, and
|
|
# coraza.conf-recommended's SecRequestBodyLimit would reject any push
|
|
# larger than ~12MB outright. Running CRS here does not harden anything;
|
|
# it just breaks git. Authentication still applies -- Gitea does that.
|
|
# ---------------------------------------------------------------------
|
|
@gittransport path_regexp ^/[^/]+/[^/]+/(?:info/refs|git-upload-pack|git-receive-pack|HEAD|objects/.*|info/lfs(?:/.*)?)$
|
|
handle @gittransport {
|
|
reverse_proxy ${GITEA_UPSTREAM} {
|
|
transport http {
|
|
read_timeout 900s
|
|
write_timeout 900s
|
|
}
|
|
}
|
|
}
|
|
|
|
# ---------------------------------------------------------------------
|
|
# Everything else -- web UI and API -- goes through the WAF.
|
|
# ---------------------------------------------------------------------
|
|
handle {
|
|
coraza_waf {
|
|
load_owasp_crs
|
|
directives `
|
|
Include @coraza.conf-recommended
|
|
Include @crs-setup.conf.example
|
|
Include @owasp_crs/*.conf
|
|
|
|
# DetectionOnly logs what it would have blocked without blocking.
|
|
# The fail2ban jail is enabled ONLY when this is On -- banning on
|
|
# detections that were never blocks would turn a false positive
|
|
# into an nftables ban, which is worse than the 403 it avoided.
|
|
SecRuleEngine ${WAF_MODE}
|
|
|
|
# Inspecting responses on a git host costs CPU and catches nothing
|
|
# worth catching.
|
|
SecResponseBodyAccess Off
|
|
|
|
# Truncate-and-inspect rather than reject: a large but legitimate
|
|
# attachment upload should not 413 because the WAF gave up.
|
|
SecRequestBodyLimitAction ProcessPartial
|
|
|
|
# RelevantOnly, not On. Auditing every request would pour the full
|
|
# request volume into journald and then into Cloud Logging.
|
|
SecAuditEngine RelevantOnly
|
|
|
|
# The default relevant-status is ^(?:5|4(?!04)), which audits every
|
|
# 401 -- and on a public git host unauthenticated API and web probes
|
|
# produce those constantly. That noise would bury the would-be blocks
|
|
# this log exists to surface. Narrowed to real WAF refusals and
|
|
# server errors; rule-triggered transactions are still audited on
|
|
# severity regardless of status, which is what keeps DetectionOnly
|
|
# useful.
|
|
SecAuditLogRelevantStatus "^(?:5[0-9]{2}|403)$"
|
|
|
|
SecAuditLog /dev/stdout
|
|
SecAuditLogFormat json
|
|
SecAuditLogParts ABIJDEFHZ
|
|
`
|
|
}
|
|
|
|
reverse_proxy ${GITEA_UPSTREAM} {
|
|
transport http {
|
|
read_timeout 900s
|
|
write_timeout 900s
|
|
}
|
|
}
|
|
}
|
|
|
|
log {
|
|
output stderr
|
|
format json
|
|
}
|
|
}
|