Files
deploy_home/ansible/roles/podman/templates/caddy/Caddyfile.j2
T
Bastian de BylandClaude Opus 5 f59ade748b feat(rsvp): deploy rsvp.debyl.io, the invite-only party RSVP app
A single Go binary with SQLite, built and loaded as localhost/rsvpd:<VERSION>
by make deploy-remote in ~/src/rsvp-debylio. Both of its listeners are
published on 127.0.0.1 only: Caddy proxies the public one to everyone and the
admin one (/admin, no login) only to caddy_local_networks. A Caddyfile mistake
alone cannot expose admin, and neither can a port mistake alone.

Guests' invite links are the credential and they sit in the URL path, which
shapes the vhost:

- It does not import common_headers. That snippet sets Referrer-Policy
  same-origin, which would replace the app's no-referrer and let a token leak
  in a Referer header.
- Its access log rewrites request>uri to /i/REDACTED and drops the Location
  response header, since every POST 303s back to /i/<token>.
- Caddy's error logger is separate from the site's and wrote the raw URI to
  caddy.log when the upstream was down. The global log now excludes
  http.log.error.rsvp and a filtered rsvp-errors logger takes it instead.
  Verified with zero token occurrences in both logs, locally and live.

The data directory is owned directly by the host uid of the container's uid
10001 (subuid + 10000). Setting it to the podman user and chowning back each run
flipped ownership on every deploy and briefly locked the app out of its
database.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 23:14:46 -04:00

623 lines
15 KiB
Caddyfile

# Caddy Configuration
# Managed by Ansible - do not edit directly
# Global options
{
email {{ caddy_email }}
acme_ca {{ caddy_acme_ca }}
# Admin API endpoint
admin 0.0.0.0:{{ caddy_admin_port }}
# Global server options
servers {
protocols h1 h2 h3
}
# Logging
log {
output file /var/log/caddy/caddy.log {
roll_size {{ caddy_log_roll_size }}
roll_keep {{ caddy_log_roll_keep }}
roll_keep_for {{ caddy_log_roll_keep_for }}
}
format {{ caddy_log_format }}
level {{ caddy_log_level }}
# See rsvp-errors below.
exclude http.log.error.rsvp
}
# {{ rsvp_server_name }}: when a proxied request fails, Caddy's error log
# records the raw request URI -- which for /i/<token> is a working invite
# link. Send those through the same redaction as the site's access log
# instead of the default log above.
log rsvp-errors {
output file /var/log/caddy/rsvp.log {
roll_size {{ caddy_log_roll_size }}
roll_keep {{ caddy_log_roll_keep }}
roll_keep_for {{ caddy_log_roll_keep_for }}
}
format filter {
wrap json
request>uri regexp ^/i/[^/?#]+ /i/REDACTED
request>headers>Referer delete
}
include http.log.error.rsvp
}
}
# Import snippet for common security headers
(common_headers) {
header {
{% for header, value in caddy_security_headers.items() %}
{{ header }} "{{ value }}"
{% endfor %}
-Server
-X-Powered-By
}
}
# Jinja2 macro for IP-restricted sites with redirect
{% macro ip_restricted_site(networks=caddy_local_networks) %}
@local {
remote_ip {{ networks | join(' ') }}
}
@denied {
not remote_ip {{ networks | join(' ') }}
}
# Redirect non-local traffic
handle @denied {
redir https://debyl.io{uri} 302
}
{% endmacro %}
# ============================================================================
# SITE CONFIGURATIONS
# ============================================================================
# Simple redirect: {{ base_server_name }} -> {{ base_server_name_io }}
{{ base_server_name }} {
redir https://{{ base_server_name_io }}{uri} 302
}
# ============================================================================
# SIMPLE REVERSE PROXIES
# ============================================================================
# Photos service - redirect old to new
{{ photos_server_name }} {
redir https://{{ photos_server_name_io }}{uri} 302
}
# Photos service - {{ photos_server_name_io }}
{{ photos_server_name_io }} {
import common_headers
reverse_proxy localhost:8088
log {
output file /var/log/caddy/photos.log {
roll_size {{ caddy_log_roll_size }}
roll_keep {{ caddy_log_roll_keep }}
roll_keep_for {{ caddy_log_roll_keep_for }}
}
format json
}
}
# Wiki/BookStack - {{ bookstack_server_name }} redirect to new domain
{{ bookstack_server_name }} {
redir https://{{ bookstack_server_name_new }}{uri} 302
}
# Wiki/BookStack - {{ bookstack_server_name_new }} (new primary domain)
{{ bookstack_server_name_new }} {
import common_headers
reverse_proxy localhost:6875
log {
output file /var/log/caddy/wiki.log {
roll_size {{ caddy_log_roll_size }}
roll_keep {{ caddy_log_roll_keep }}
roll_keep_for {{ caddy_log_roll_keep_for }}
}
format json
}
}
# ============================================================================
# IP-RESTRICTED SITES
# ============================================================================
# Home Assistant - redirect old to new
{{ assistant_server_name }} {
redir https://{{ assistant_server_name_io }}{uri} 302
}
# Home Assistant - {{ assistant_server_name_io }}
{{ assistant_server_name_io }} {
{{ ip_restricted_site() }}
handle @local {
reverse_proxy localhost:8123 {
# WebSocket support is automatic
flush_interval -1
}
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains"
X-Content-Type-Options "nosniff"
Referrer-Policy "same-origin"
}
}
log {
output file /var/log/caddy/assistant.log {
roll_size {{ caddy_log_roll_size }}
roll_keep {{ caddy_log_roll_keep }}
roll_keep_for {{ caddy_log_roll_keep_for }}
}
format json
}
}
# CI/Drone - REMOVED
# ci.bdebyl.net configuration removed - Drone CI infrastructure decommissioned
# Home server - {{ home_server_name_io }}
{{ home_server_name_io }} {
{{ ip_restricted_site() }}
handle @local {
respond "Home Server Access OK" 200
}
}
# Parts/Partsy - redirect old to new
{{ parts_server_name }} {
redir https://{{ parts_server_name_io }}{uri} 302
}
# Parts/Partsy - {{ parts_server_name_io }}
{{ parts_server_name_io }} {
{{ ip_restricted_site() }}
handle @local {
import common_headers
reverse_proxy localhost:8081
}
log {
output file /var/log/caddy/parts.log {
roll_size {{ caddy_log_roll_size }}
roll_keep {{ caddy_log_roll_keep }}
roll_keep_for {{ caddy_log_roll_keep_for }}
}
format json
}
}
# Partsy Skudak (authenticated) - {{ parts_skudak_server_name }}
{{ parts_skudak_server_name }} {
import common_headers
reverse_proxy localhost:8082
log {
output file /var/log/caddy/parts-skudak.log {
roll_size {{ caddy_log_roll_size }}
roll_keep {{ caddy_log_roll_keep }}
roll_keep_for {{ caddy_log_roll_keep_for }}
}
format json
}
}
# RSVP - {{ rsvp_server_name }} (public invite links, LAN-only /admin)
#
# Does NOT import common_headers: its Referrer-Policy "same-origin" would
# replace the app's "no-referrer", and invite tokens live in the URL path.
{{ rsvp_server_name }} {
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains"
X-Robots-Tag "noindex, nofollow"
Referrer-Policy "no-referrer"
Content-Security-Policy "default-src 'self'; style-src 'self'; script-src 'self'; img-src 'self' data:; form-action 'self'; frame-ancestors 'none'"
X-Content-Type-Options "nosniff"
-Server
}
# Admin is a second listener with no login. It is only published on
# loopback, and only proxied here for LAN clients -- two independent controls.
@admin_local {
path /admin /admin/*
remote_ip {{ caddy_local_networks | join(' ') }}
}
handle @admin_local {
reverse_proxy 127.0.0.1:{{ rsvp_admin_port }}
}
handle /admin* {
respond "Not found" 404
}
handle {
reverse_proxy 127.0.0.1:{{ rsvp_public_port }}
}
# The invite token is the credential and it is in the URL path, so redact
# it from the request URI and drop the redirect Location (POSTs 303 back to
# /i/<token>) before anything is written. Named "rsvp" so its error logger
# (http.log.error.rsvp) can be redirected in the global options.
log rsvp {
output file /var/log/caddy/rsvp.log {
roll_size {{ caddy_log_roll_size }}
roll_keep {{ caddy_log_roll_keep }}
roll_keep_for {{ caddy_log_roll_keep_for }}
}
format filter {
wrap json
request>uri regexp ^/i/[^/?#]+ /i/REDACTED
request>headers>Referer delete
resp_headers>Location delete
}
}
}
# Uptime Kuma (Debyltech) - {{ uptime_kuma_server_name }}
{{ uptime_kuma_server_name }} {
{{ ip_restricted_site() }}
handle @local {
import common_headers
reverse_proxy localhost:3001 {
# WebSocket support for live updates
flush_interval -1
}
}
log {
output file /var/log/caddy/uptime-kuma.log {
roll_size {{ caddy_log_roll_size }}
roll_keep {{ caddy_log_roll_keep }}
roll_keep_for {{ caddy_log_roll_keep_for }}
}
format json
}
}
# Uptime Kuma (Personal) - {{ uptime_kuma_personal_server_name }}
{{ uptime_kuma_personal_server_name }} {
{{ ip_restricted_site() }}
handle @local {
import common_headers
reverse_proxy localhost:3002 {
# WebSocket support for live updates
flush_interval -1
}
}
log {
output file /var/log/caddy/uptime-kuma-personal.log {
roll_size {{ caddy_log_roll_size }}
roll_keep {{ caddy_log_roll_keep }}
roll_keep_for {{ caddy_log_roll_keep_for }}
}
format json
}
}
# Graylog Logs - {{ logs_server_name }}
{% if not (graylog_enabled | bool) %}
# Graylog is disabled (see graylog_enabled in inventories/home/hosts.yml). The
# vhost is kept rather than dropped so the name keeps resolving and its
# certificate keeps renewing; both handlers answer 503 instead of proxying to a
# port nothing is listening on, which would hang and then 502.
#
# NOTE: this means the external Lambda that POSTs to /gelf for the
# `debyltech-api` stream is being turned away. Its events are dropped, not
# queued -- nothing else records them.
{{ logs_server_name }} {
handle /gelf {
respond "Log ingest disabled" 503
}
handle {
respond "Graylog is disabled" 503
}
log {
output file /var/log/caddy/graylog.log {
roll_size {{ caddy_log_roll_size }}
roll_keep {{ caddy_log_roll_keep }}
roll_keep_for {{ caddy_log_roll_keep_for }}
}
format json
}
}
{% else %}
{{ logs_server_name }} {
# GELF HTTP endpoint - open for Lambda (auth via header)
# Must come BEFORE ip_restricted_site to allow external access
@gelf_authorized {
path /gelf
header X-Gelf-Token "{{ gelf_auth_token }}"
}
handle @gelf_authorized {
reverse_proxy localhost:12202
}
# Reject unauthorized GELF requests
handle /gelf {
respond "Unauthorized" 401
}
# IP restriction for Graylog web UI (excludes /gelf which is handled above)
@local {
remote_ip {{ caddy_local_networks | join(' ') }}
}
@denied {
not remote_ip {{ caddy_local_networks | join(' ') }}
not path /gelf
}
handle @denied {
redir https://debyl.io{uri} 302
}
handle @local {
import common_headers
reverse_proxy localhost:9000
}
log {
output file /var/log/caddy/graylog.log {
roll_size {{ caddy_log_roll_size }}
roll_keep {{ caddy_log_roll_keep }}
roll_keep_for {{ caddy_log_roll_keep_for }}
}
format json
}
}
{% endif %}
# ============================================================================
# COMPLEX CONFIGURATIONS
# ============================================================================
# Nextcloud - redirect old to new
{{ cloud_server_name }} {
redir https://{{ cloud_server_name_io }}{uri} 302
}
# Nextcloud - {{ cloud_server_name_io }}
{{ cloud_server_name_io }} {
request_body {
max_size {{ caddy_max_request_body_mb }}MB
}
reverse_proxy localhost:8089 {
header_up Host {host}
header_up X-Real-IP {remote}
# X-Forwarded-For and X-Forwarded-Proto are automatic
}
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains"
X-Content-Type-Options "nosniff"
Referrer-Policy "same-origin"
-X-Powered-By
}
# Nextcloud specific redirects
redir /.well-known/carddav /remote.php/dav 301
redir /.well-known/caldav /remote.php/dav 301
log {
output file /var/log/caddy/cloud.log {
roll_size {{ caddy_log_roll_size }}
roll_keep {{ caddy_log_roll_keep }}
roll_keep_for {{ caddy_log_roll_keep_for }}
}
format json
}
}
# Skudak Nextcloud - serve both domains (migration period)
{{ cloud_skudak_server_name }}, {{ cloud_skudak_server_name_new }} {
request_body {
max_size {{ caddy_max_request_body_mb }}MB
}
reverse_proxy localhost:8090 {
header_up Host {host}
header_up X-Real-IP {remote}
}
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains"
X-Content-Type-Options "nosniff"
Referrer-Policy "same-origin"
-X-Powered-By
}
# Nextcloud specific redirects
redir /.well-known/carddav /remote.php/dav 301
redir /.well-known/caldav /remote.php/dav 301
log {
output file /var/log/caddy/cloud-skudak.log {
roll_size {{ caddy_log_roll_size }}
roll_keep {{ caddy_log_roll_keep }}
roll_keep_for {{ caddy_log_roll_keep_for }}
}
format json
}
}
# Gitea - {{ gitea_debyl_server_name }}
{{ gitea_debyl_server_name }} {
import common_headers
reverse_proxy localhost:3100 {
flush_interval -1
}
log {
output file /var/log/caddy/gitea-debyl.log {
roll_size {{ caddy_log_roll_size }}
roll_keep {{ caddy_log_roll_keep }}
roll_keep_for {{ caddy_log_roll_keep_for }}
}
format json
}
}
# Gitea Skudak - {{ gitea_skudak_server_name }}
{{ gitea_skudak_server_name }} {
import common_headers
reverse_proxy localhost:3101 {
flush_interval -1
}
log {
output file /var/log/caddy/gitea-skudak.log {
roll_size {{ caddy_log_roll_size }}
roll_keep {{ caddy_log_roll_keep }}
roll_keep_for {{ caddy_log_roll_keep_for }}
}
format json
}
}
# Fulfillr - {{ fulfillr_server_name }} (Static + API with IP restrictions)
{{ fulfillr_server_name }} {
{{ ip_restricted_site() }}
@api {
path /api/*
}
# Handle API requests with CORS for local development
handle @api {
header {
Access-Control-Allow-Origin "*"
Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS"
Access-Control-Allow-Headers "Content-Type, Authorization, X-Requested-With"
Access-Control-Allow-Credentials "true"
}
# Handle preflight requests
@options {
method OPTIONS
}
handle @options {
respond "" 204
}
reverse_proxy localhost:9054
}
# Serve static files with SPA fallback
handle {
root * /usr/local/share/fulfillr-site
try_files {path} {path}/ /index.html
file_server
}
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains"
X-Content-Type-Options "nosniff"
Referrer-Policy "same-origin"
}
log {
output file /var/log/caddy/fulfillr.log {
roll_size {{ caddy_log_roll_size }}
roll_keep {{ caddy_log_roll_keep }}
roll_keep_for {{ caddy_log_roll_keep_for }}
}
format json
}
}
# Fulfillr DEV/staging - {{ fulfillr_dev_server_name }} (Static + API with IP restrictions)
{{ fulfillr_dev_server_name }} {
{{ ip_restricted_site() }}
@api {
path /api/*
}
# Handle API requests with CORS for local development
handle @api {
header {
Access-Control-Allow-Origin "*"
Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS"
Access-Control-Allow-Headers "Content-Type, Authorization, X-Requested-With"
Access-Control-Allow-Credentials "true"
}
# Handle preflight requests
@options {
method OPTIONS
}
handle @options {
respond "" 204
}
reverse_proxy localhost:9055
}
# Serve static files with SPA fallback
handle {
root * /usr/local/share/fulfillr-site-dev
try_files {path} {path}/ /index.html
file_server
}
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains"
X-Content-Type-Options "nosniff"
Referrer-Policy "same-origin"
}
log {
output file /var/log/caddy/fulfillr-dev.log {
roll_size {{ caddy_log_roll_size }}
roll_keep {{ caddy_log_roll_keep }}
roll_keep_for {{ caddy_log_roll_keep_for }}
}
format json
}
}
# ============================================================================
# TEST/STAGING SITES
# ============================================================================
# Test Site - test.debyl.io (Public static site hosting, no caching)
test.debyl.io {
import common_headers
root * /srv/test-site
try_files {path} {path}/ /index.html
file_server
# Disable all caching for test sites
header Cache-Control "no-store, no-cache, must-revalidate, max-age=0"
header Pragma "no-cache"
log {
output file /var/log/caddy/test.log {
roll_size {{ caddy_log_roll_size }}
roll_keep {{ caddy_log_roll_keep }}
roll_keep_for {{ caddy_log_roll_keep_for }}
}
format json
level {{ caddy_log_level }}
}
}