From 07c276930143232a1dff9e03fab05250bf1595bc Mon Sep 17 00:00:00 2001 From: isaacclad Date: Sat, 25 Jul 2026 12:14:14 +0000 Subject: [PATCH] link: version the ladl.link cache and log rotation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both were applied by hand during an incident and existed only in /etc on one server. server-bootstrap.sh generates the ladl.link vhost, and its version had no caching at all — so rebuilding the box would have silently restored a ~44 req/s ceiling on a link being handed to a very large audience, with nothing in git recording that the cache should exist. Laravel log rotation had the same problem: none was configured anywhere, and logs had reached 808M across apps. Now tracked under deploy/ and installed by bootstrap: nginx/conf.d/ladill-link-cache.conf FastCGI cache zone nginx/snippets/ladill-link-cached.conf cached path, 10m nginx/snippets/ladill-link-cached-short.conf same, 60s nginx/snippets/ladill-link-public-cache.conf which routes are cached nginx/snippets/ladill-link-hot-pages.conf per-campaign slugs logrotate/ladill-laravel Campaign slugs live in their own snippet so a hot link can be added or retired with an nginx reload instead of a deploy, and bootstrap seeds that file only when absent so it is never clobbered. Caching stays deliberately narrow. ladl.link also serves the admin app, and the cached path strips Set-Cookie, so anything reaching it would lose its session. Only /q/, */bookshop-* and listed campaign pages are cached; SSO and dashboard fall through untouched — verified after this change: both still set cookies and carry no cache header. Generated vhosts now include the snippet from the start, and an existing vhost is patched in place (with a timestamped backup) rather than rewritten, since it may carry local edits. Both paths are idempotent. Applied to production as part of this change, so the committed config is the config that is running rather than an untested transcription: page 12ms, cover 14ms, vanity URL 11ms, all cache HIT. Co-Authored-By: Claude --- deploy/logrotate/ladill-laravel | 17 ++++++++++ deploy/nginx/conf.d/ladill-link-cache.conf | 8 +++++ .../snippets/ladill-link-cached-short.conf | 28 ++++++++++++++++ deploy/nginx/snippets/ladill-link-cached.conf | 28 ++++++++++++++++ .../nginx/snippets/ladill-link-hot-pages.conf | 19 +++++++++++ .../snippets/ladill-link-public-cache.conf | 22 +++++++++++++ deploy/server-bootstrap.sh | 33 +++++++++++++++++++ 7 files changed, 155 insertions(+) create mode 100644 deploy/logrotate/ladill-laravel create mode 100644 deploy/nginx/conf.d/ladill-link-cache.conf create mode 100644 deploy/nginx/snippets/ladill-link-cached-short.conf create mode 100644 deploy/nginx/snippets/ladill-link-cached.conf create mode 100644 deploy/nginx/snippets/ladill-link-hot-pages.conf create mode 100644 deploy/nginx/snippets/ladill-link-public-cache.conf diff --git a/deploy/logrotate/ladill-laravel b/deploy/logrotate/ladill-laravel new file mode 100644 index 0000000..694718e --- /dev/null +++ b/deploy/logrotate/ladill-laravel @@ -0,0 +1,17 @@ +# Laravel writes to a single laravel.log per app with no rotation, so files grow +# without bound (ladill-link reached 499M). copytruncate because PHP-FPM holds the +# handle open and there is no signal to make it reopen. +/var/www/*/current/storage/logs/*.log +/var/www/*/shared/storage/logs/*.log +{ + daily + rotate 14 + maxsize 100M + missingok + notifempty + compress + delaycompress + copytruncate + su root root + create 0664 deploy www-data +} diff --git a/deploy/nginx/conf.d/ladill-link-cache.conf b/deploy/nginx/conf.d/ladill-link-cache.conf new file mode 100644 index 0000000..5150066 --- /dev/null +++ b/deploy/nginx/conf.d/ladill-link-cache.conf @@ -0,0 +1,8 @@ +# Cache zone for ladl.link public bio pages and their assets. +# Sized for large book images (~2MB each) until they are optimised. +fastcgi_cache_path /var/cache/nginx/ladill-link + levels=1:2 + keys_zone=LADILLLINK:20m + max_size=8g + inactive=6h + use_temp_path=off; diff --git a/deploy/nginx/snippets/ladill-link-cached-short.conf b/deploy/nginx/snippets/ladill-link-cached-short.conf new file mode 100644 index 0000000..05a6f26 --- /dev/null +++ b/deploy/nginx/snippets/ladill-link-cached-short.conf @@ -0,0 +1,28 @@ +# Cached FastCGI path for PUBLIC bio pages and their assets only. +# Admin/SSO routes never reach here: they fall through to `location /` +# and the normal uncached `location ~ \.php$`, keeping their cookies. +include fastcgi_params; +fastcgi_pass unix:/run/php/php8.4-fpm-ladill.sock; +fastcgi_param SCRIPT_FILENAME $document_root/index.php; +fastcgi_param SCRIPT_NAME /index.php; +fastcgi_param REQUEST_URI $request_uri; + +fastcgi_cache LADILLLINK; +fastcgi_cache_key "$scheme$request_method$host$request_uri"; +fastcgi_cache_valid 200 301 302 60s; + +# One worker regenerates; the rest are served stale instead of stampeding PHP. +fastcgi_cache_lock on; +fastcgi_cache_lock_timeout 15s; +fastcgi_cache_use_stale updating error timeout invalid_header; +fastcgi_cache_background_update on; + +# Laravel marks these no-cache and attaches a session. Both are wrong for a +# public page with no forms, so ignore them - and strip Set-Cookie so the +# cached copy cannot hand one shared session to every visitor. +fastcgi_ignore_headers Cache-Control Expires Set-Cookie; +fastcgi_hide_header Set-Cookie; + +fastcgi_read_timeout 30s; +add_header X-LL-Cache $upstream_cache_status always; +add_header Cache-Control "public, max-age=600" always; diff --git a/deploy/nginx/snippets/ladill-link-cached.conf b/deploy/nginx/snippets/ladill-link-cached.conf new file mode 100644 index 0000000..52e306a --- /dev/null +++ b/deploy/nginx/snippets/ladill-link-cached.conf @@ -0,0 +1,28 @@ +# Cached FastCGI path for PUBLIC bio pages and their assets only. +# Admin/SSO routes never reach here: they fall through to `location /` +# and the normal uncached `location ~ \.php$`, keeping their cookies. +include fastcgi_params; +fastcgi_pass unix:/run/php/php8.4-fpm-ladill.sock; +fastcgi_param SCRIPT_FILENAME $document_root/index.php; +fastcgi_param SCRIPT_NAME /index.php; +fastcgi_param REQUEST_URI $request_uri; + +fastcgi_cache LADILLLINK; +fastcgi_cache_key "$scheme$request_method$host$request_uri"; +fastcgi_cache_valid 200 301 302 10m; + +# One worker regenerates; the rest are served stale instead of stampeding PHP. +fastcgi_cache_lock on; +fastcgi_cache_lock_timeout 15s; +fastcgi_cache_use_stale updating error timeout invalid_header; +fastcgi_cache_background_update on; + +# Laravel marks these no-cache and attaches a session. Both are wrong for a +# public page with no forms, so ignore them - and strip Set-Cookie so the +# cached copy cannot hand one shared session to every visitor. +fastcgi_ignore_headers Cache-Control Expires Set-Cookie; +fastcgi_hide_header Set-Cookie; + +fastcgi_read_timeout 30s; +add_header X-LL-Cache $upstream_cache_status always; +add_header Cache-Control "public, max-age=600" always; diff --git a/deploy/nginx/snippets/ladill-link-hot-pages.conf b/deploy/nginx/snippets/ladill-link-hot-pages.conf new file mode 100644 index 0000000..b453bf7 --- /dev/null +++ b/deploy/nginx/snippets/ladill-link-hot-pages.conf @@ -0,0 +1,19 @@ +# Per-campaign cached pages for ladl.link. +# +# A bio page is only worth caching when it is being handed to a large audience; +# caching every slug would waste the zone on links nobody is clicking. Add an +# exact-match location here when a link is about to go out at scale, then +# `nginx -t && systemctl reload nginx`. No deploy required. +# +# Two forms: +# @llcache 10m — bio pages, whose content changes rarely +# @llcacheshort 60s — direct-redirect slugs. LinkRedirectController sends +# no-store on purpose so destination edits apply at once; +# the short TTL caps that delay rather than removing it. +# +# Remove entries once a campaign is over. + +# Apostle Eric Nyamekye book launch (July 2026 conference). +location = /6sjcsc7r { try_files $uri @llcache; } +# Vanity URL printed on the conference banner; 302s to the page above. +location = /apostleericbooks { try_files $uri @llcacheshort; } diff --git a/deploy/nginx/snippets/ladill-link-public-cache.conf b/deploy/nginx/snippets/ladill-link-public-cache.conf new file mode 100644 index 0000000..c61cd7c --- /dev/null +++ b/deploy/nginx/snippets/ladill-link-public-cache.conf @@ -0,0 +1,22 @@ +# Public bio-page cache for ladl.link. Include once inside each server block. +# +# Scope is deliberately narrow. ladl.link serves the full admin app as well as +# public links (SSO, dashboard, campaigns, wallet), and the cached path strips +# Set-Cookie — so anything that reaches it would lose its session. Only routes +# matched below are cached; everything else falls through to `location /` and the +# normal uncached `location ~ \.php$`. +# +# Why this exists: a bio page cost ~450ms of PHP per visitor and the FastCGI pool +# has 20 workers, capping the site near 44 req/s. One link handed to a large +# audience exhausted the pool and took every ladl.link app down with it. + +# Asset proxy prefix, and bio-page images. Public and byte-identical per visitor. +location ^~ /q/ { try_files $uri @llcache; } +location ~ "^/[A-Za-z0-9_-]{4,20}/bookshop-" { try_files $uri @llcache; } + +# Campaign-specific pages. Kept in their own file so a hot link can be added or +# removed with an nginx reload, without a code deploy. +include snippets/ladill-link-hot-pages.conf; + +location @llcache { include snippets/ladill-link-cached.conf; } +location @llcacheshort { include snippets/ladill-link-cached-short.conf; } diff --git a/deploy/server-bootstrap.sh b/deploy/server-bootstrap.sh index 838a384..27d9f4a 100644 --- a/deploy/server-bootstrap.sh +++ b/deploy/server-bootstrap.sh @@ -73,6 +73,27 @@ else echo "WARN: run setup-service-subdomain-nginx.sh link manually" fi +log "Installing nginx cache + logrotate config" +REPO_DEPLOY="$APP_ROOT/current/deploy" +if [ -d "$REPO_DEPLOY/nginx" ]; then + install -m 0644 "$REPO_DEPLOY/nginx/conf.d/"*.conf /etc/nginx/conf.d/ + # Do not clobber hot-pages: it is edited in place between deploys to add or + # retire campaign links, so only seed it when absent. + for f in "$REPO_DEPLOY/nginx/snippets/"*.conf; do + base="$(basename "$f")" + if [ "$base" = "ladill-link-hot-pages.conf" ] && [ -f "/etc/nginx/snippets/$base" ]; then + log " keeping existing snippets/$base" + continue + fi + install -m 0644 "$f" "/etc/nginx/snippets/$base" + done + mkdir -p /var/cache/nginx/ladill-link + chown www-data:www-data /var/cache/nginx/ladill-link +fi +if [ -f "$REPO_DEPLOY/logrotate/ladill-laravel" ]; then + install -m 0644 "$REPO_DEPLOY/logrotate/ladill-laravel" /etc/logrotate.d/ladill-laravel +fi + log "Configuring nginx for ladl.link" LADL_CONF="/etc/nginx/sites-available/ladl.link.conf" if [ ! -f "$LADL_CONF" ]; then @@ -83,6 +104,7 @@ server { server_name ladl.link www.ladl.link; root /var/www/ladill-link/current/public; location ^~ /.well-known/acme-challenge/ { allow all; } + include snippets/ladill-link-public-cache.conf; location / { try_files $uri $uri/ /index.php?$query_string; } location ~ \.php$ { include snippets/fastcgi-php.conf; @@ -117,6 +139,7 @@ server { ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; root /var/www/ladill-link/current/public; index index.php; + include snippets/ladill-link-public-cache.conf; location / { try_files $uri $uri/ /index.php?$query_string; } location ~ \.php$ { include snippets/fastcgi-php.conf; @@ -155,3 +178,13 @@ fi log "Bootstrap complete" curl -sI "https://link.ladill.com/up" 2>/dev/null | head -3 || curl -sI "http://link.ladill.com/up" 2>/dev/null | head -3 || true + +# Existing servers were provisioned before the cache existed, and the vhost above +# is only written when absent. Patch the include in rather than rewriting a file +# that may carry local edits. +if [ -f "$LADL_CONF" ] && ! grep -q "ladill-link-public-cache.conf" "$LADL_CONF"; then + log "Adding cache include to existing ladl.link vhost" + cp "$LADL_CONF" "$LADL_CONF.bak-$(date +%Y%m%d-%H%M%S)" + sed -i 's|^\( *\)location / { try_files \$uri \$uri/ /index.php?\$query_string; }|\1include snippets/ladill-link-public-cache.conf;\n\1location / { try_files $uri $uri/ /index.php?$query_string; }|' "$LADL_CONF" + nginx -t && systemctl reload nginx || log "WARN: nginx test failed; restore from $LADL_CONF.bak-*" +fi