Server configuration
Tune the web server and PHP for UNA 15 — rewrites, compression, static cache headers, OPcache, and container traps. gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 5;
gzip_types
text/plain text/css text/xml text/javascript
application/javascript application/json application/xml
application/rss+xml image/svg+xml;
# Prefer not to gzip binaries (woff2/jpeg/png/webp) — omit them from gzip_types.
# Avoid re-gzipping URI gzip_loader.php (already encoded by BxDolGzip).
# Enable: a2enmod deflate
<IfModule mod_deflate.c>
AddOutputFilterByType DEFLATE text/html text/plain text/xml text/css
AddOutputFilterByType DEFLATE text/javascript application/javascript application/json
AddOutputFilterByType DEFLATE application/xml image/svg+xml
# Skip already-compressed formats
SetEnvIfNoCase Request_URI \\.(?
### Caddy
`scripts/Caddyfile` already has `encode zstd br gzip` and does **not** exclude fonts, images, or `gzip_loader.php`. Keep that sample as-is. If another proxy in front also compresses, watch for double-gzip on loader responses.
## Static asset caching
Long browser cache on **real** static files (templates, module assets, `cache_public/`, fonts, images on disk) is intended. Naïve “anything ending in `.css` / `.js` / `.jpg` is static” rules break UNA:
- Local storage URLs look like `/s/{object}/{file.ext}` and must hit PHP (`storage.php`).
- Missing SEO paths fall through to `r.php` and can also end in asset-like extensions.
**Shipped nginx** (`scripts/docker-compose/nginx.conf`) rewrites `/s/`, `/page/`, `/m/`, then uses `if (!-e $request_filename)` to send missing paths to `r.php`. It does **not** use `try_files`, and it sets **no** `gzip`, `expires`, or static `Cache-Control`. **Caddy** rewrites `/s/` then uses `try_files`. Root `.htaccess` serves existing files, then rewrites the rest to `r.php`. None of those samples set long cache headers on static files.
> [!IMPORTANT]
> When **adding** static cache rules, only attach long Cache-Control / Expires after an existence check proves the file is on disk. Never short-circuit `/s/`, `/page/`, `/m/`, or the `r.php` fallback with an extension-only static location.
### nginx (recommendation when adding cache headers)
```nginx
# After UNA /s/ rewrite and if (!-e …) → r.php rules (see scripts/docker-compose/nginx.conf).
# try_files here is an operator pattern for static expires — not what the shipped sample uses for routing.
location ~* \\.(?:css|js|mjs|map|jpg|jpeg|png|gif|webp|svg|ico|woff2?|ttf|eot)$ {
# Only if the file exists; otherwise fall through to PHP / r.php
try_files $uri =404;
expires 30d;
Keep `/s/…jpg` on the shipped rewrite to `storage.php`; do not let this location win over that rewrite.
### Apache `mod_expires` (recommendation)
```apache
# Enable: a2enmod expires
<IfModule mod_expires.c>
ExpiresActive On
ExpiresByType text/css "access plus 30 days"
ExpiresByType application/javascript "access plus 30 days"
ExpiresByType image/jpeg "access plus 30 days"
ExpiresByType image/png "access plus 30 days"
ExpiresByType image/webp "access plus 30 days"
ExpiresByType image/svg+xml "access plus 30 days"
ExpiresByType font/woff2 "access plus 30 days"
</IfModule>
# Alongside encode and UNA rewrites in scripts/Caddyfile.
@static {
file
path *.css *.js *.mjs *.map *.jpg *.jpeg *.png *.gif *.webp *.svg *.ico *.woff *.woff2
not path /s/* /page/* /m/*
}
header @static Cache-Control "publ
## Apache, nginx, Caddy, and Railway
| Stack | Shipped in repo | Operator add-ons (recommendation) |
| --- | --- | --- |
| Apache / Railway | `a2enmod rewrite` only | `deflate` and `expires` (Studio audit looks for them) |
| nginx sample | Rewrites + `if (!-e …)` → `r.php`; no gzip / expires | Add `gzip` + existence-aware static expires if you want them |
| Caddy / FrankenPHP sample | `encode zstd br gzip` (no exclusions) + rewrites + `try_files` | Optional matcher-scoped `Cache-Control` as above |
| Railway `railway.toml` | Build + restart policy only — **no cron** | Add a separate cron process that runs `periodic/cron.php` every minute |
Ubuntu zip install docs match Railway on modules: rewrite only. For custom images, enable extra Apache modules in the Dockerfile or entrypoint the same way rewrite is enabled today.
Reference paths: `scripts/docker-compose/nginx.conf`, `scripts/Caddyfile`, `scripts/railway/Dockerfile`, `scripts/railway/entrypoint.sh`, `scripts/railway/railway.toml`, root `.htaccess`, `gzip_loader.php`.
## PHP OPcache
OPcache is optional on the [Requirements](wiki/requirements-overview) checklist but strongly recommended in production. File cache under `cache/` is often `include()`d PHP (`BxDolCacheFile`) with **hash-named** files (site hash in the name). Writers call `opcache_invalidate` when those files change. Undersized OPcache still thrash under many unique files — size it for large multi-module sites.
**Shipped docker-compose** (`scripts/docker-compose/php.ini` and `php-cron.ini`):
| Directive | Packaged value |
| --- | --- |
| `opcache.enable` | `1` |
| `opcache.memory_consumption` | `72` |
| `opcache.interned_strings_buffer`
**Production guidance:** raise `opcache.memory_consumption` and `opcache.interned_strings_buffer` **above the docker sample 72 / 16** on busy sites. Use `opcache_get_status()` to watch memory pressure; do not treat any particular larger pair as a UNA-shipped floor.
```ini
opcache.enable=1
opcache.memory_consumption=72
opcache.interned_strings_buffer=16
opcache.max_accelerated_files=100000
opcache.validate_timestamps=1
opcache.revalidate_freq=0
Keep `/s/`, `/page/`, and `/m/` on the PHP rewrite path; apply `Cache-Control` only when `file` matches a real asset.
add_header Cache-Control "public, max-age=2592000";
access_log off;
}
3d