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