Arthis.Land

An .htaccess That Earns Its Keep

Caching, compression and security headers for a static site, with the reasoning behind each line and an honest account of what the cache policy costs.

It is a security file that happens to do rewrites

Most people meet .htaccess as the place you paste a rewrite rule so /about stops 404ing. That is why it gets treated as configuration trivia — edited in a hurry, at the end of a deploy, without review.

On arthis.land it is the only thing standing between the public internet and the document root. It decides which host the site answers on, whether the connection is encrypted, which paths are reachable, which HTTP methods the server honours, what headers every response carries, and how long every cache between origin and visitor may hold a stale copy. Most of those are security decisions. A mistake in the rewrite block gives you a 404; a mistake in the dotfile block gives away .env.

So edits here get the care you would give auth code. Read the whole file before changing a line, understand what the block above yours does, and never append at the bottom because that was the easiest place to put the cursor. Order matters in Apache in ways that are not obvious until something silently stops applying.

Canonical host and HTTPS, in that order

The first block forces HTTPS and a single canonical hostname. Splitting those into two rules is the obvious approach and the wrong one: a visitor on http://www.arthis.land should arrive at https://arthis.land in one redirect, not two.

<IfModule mod_rewrite.c>
  RewriteEngine On

  # Single hop: any combination of no-TLS and www lands on the canonical origin.
  RewriteCond %{HTTPS} !=on [OR]
  RewriteCond %{HTTP_HOST} ^www\. [NC]
  RewriteRule ^ https://arthis.land%{REQUEST_URI} [R=301,L]
</IfModule>

The [OR] collapses both cases into one hop. A 301 is cached aggressively by browsers, which is fine here — the canonical origin will not change — but it is why you test on a fresh profile. Once a browser has learned a bad 301 you will spend twenty minutes convincing yourself the server is broken.

Staging complicates this: staging.arthis.land physically lives inside production's document root, so it inherits this file unless it carries its own. A canonical-host rule that is too greedy redirects staging into production, and you notice only when you wonder why the staging deploy looks exactly like live.

Blocking what should never be fetched

A static document root gets whatever the deploy put in it, and rsync is not opinionated about hidden files. That is the whole argument for blocking dotfiles at the server rather than trusting your ignore rules.

# Nothing beginning with a dot is servable, with one exception.
RewriteRule "(^|/)\.(?!well-known/)" - [F,L]

# Belt and braces for the files that matter most.
<FilesMatch "^\.(env|git.*|htaccess|htpasswd|rsyncignore)">
  Require all denied
</FilesMatch>

# TRACE/TRACK/DEBUG are never legitimate traffic here.
RewriteCond %{REQUEST_METHOD} ^(TRACE|TRACK|DEBUG)$ [NC]
RewriteRule ^ - [F,L]

The /.well-known/ exception is not optional. ACME renewal writes its challenge into /.well-known/acme-challenge/, so a dotfile rule without that carve-out quietly breaks TLS renewal sixty days from now, long after you have forgotten you wrote it. Comment it, because the next person to read that regex will want to simplify it.

TRACE is blocked because of cross-site tracing — the method reflects the request back including headers, turning any XSS foothold into a way to read cookies marked HttpOnly. TRACK is the IIS spelling of the same idea and DEBUG belongs to old ASP.NET tooling. None have a use here, and blocking them costs one rule.

Clean URLs, declared twice

The wall has no router — page state is the URL hash, handled in app.jsx. But the standalone pages and sub-sites are real paths wanting clean URLs: /paper-model, not /paper-model.html. In production that is a rewrite.

# Clean URLs. Every entry here has a twin in vite.config.mjs.
RewriteRule ^paper-model/?$        /paper-model.html   [L]
RewriteRule ^guides/?$             /guides/index.html  [L]
RewriteRule ^city/([a-z0-9-]+)/?$  /city/viewer/index.html [L,QSA]

# Real files and directories always win.
RewriteCond %{REQUEST_FILENAME} -f [OR]
RewriteCond %{REQUEST_FILENAME} -d
RewriteRule ^ - [L]

Here is the trap, and it has caught this project for real. The dev server does not read .htaccess; Vite has its own rewrite middleware in vite.config.mjs. Every clean URL is therefore declared twice, in two files, in two syntaxes, with nothing connecting them. Add a route to one and not the other and it works perfectly on localhost and 404s live — and testing locally never finds it, because locally it is correct.

Both declarations, plus the route list in project.manifest.json, are now checked against each other by npm run guard. The guard is not testing behaviour — it cannot — it is testing that three files agree about what routes exist. That is exactly the class of bug a structural check catches and a human review does not.

The /city/<slug> rule matters because the slug is a folder name on disk that becomes a URL. It is restricted to lowercase alphanumerics and hyphens rather than a bare (.*): a permissive capture on a path that maps to the filesystem is how directory traversal starts.

The cache policy, and what it actually costs

Assets split into two classes. Fingerprinted files carry a hash in the URL — Vite's bundles, and thumbnails with a ?v= hash of their own bytes — so a stale copy cannot exist and the policy can be maximally aggressive. Files replaced in place have a stable URL whose bytes change underneath it: room .glb models, thumb.*, rooms.config.js, room.json, loop.mp4. Unity appends .glb to any path not already ending in it, which is why the Unity build these rules are written around cannot fingerprint its model URLs the way the immutable snapshot file does.

The in-place class used to be no-cache, must-revalidate. Textbook-correct for a browser, ruinous for a shared cache. no-cache does not mean "do not store", it means "revalidate before every use" — so every CDN edge, in every region, phones the origin on every request. You have built a global network of machines whose job is to ask one server whether anything changed.

# Fingerprinted: the URL changes when the bytes change.
<FilesMatch "\.(js|css|woff2)$">
  Header set Cache-Control "public, max-age=31536000, immutable"
</FilesMatch>

# Replaced in place: browser revalidates, edges serve for 10 minutes.
<FilesMatch "\.(glb|mp4|webp|png|jpg)$">
  Header set Cache-Control "public, max-age=0, s-maxage=600, stale-while-revalidate=86400"
</FilesMatch>

<FilesMatch "^(rooms\.config\.js|room\.json)$">
  Header set Cache-Control "public, max-age=0, s-maxage=600, stale-while-revalidate=86400"
</FilesMatch>

# HTML: same bargain, shorter fuse.
<FilesMatch "\.html$">
  Header set Cache-Control "public, max-age=0, s-maxage=120, stale-while-revalidate=86400"
</FilesMatch>

max-age=0 keeps browser behaviour identical. s-maxage applies only to shared caches, so an edge serves its own copy for ten minutes without consulting the origin, and stale-while-revalidate=86400 means that afterwards it serves the stale copy immediately and refreshes in the background — nobody ever waits on a revalidation.

File classCache-ControlWhy
Fingerprinted bundles, CSS, fontsmax-age=31536000, immutableA change is a new URL. No cache can ever be wrong.
Fingerprinted snapshot (?v=hash)max-age=31536000, immutableSame contract; the hash is of the file's own bytes.
.glb, thumb.*, loop.mp4max-age=0, s-maxage=600, swr=86400Stable URL, changing bytes. Edges absorb the traffic; ten minutes of staleness is the price.
rooms.config.js, room.jsonmax-age=0, s-maxage=600, swr=86400Regenerated by the build, same filename every time.
HTML documentsmax-age=0, s-maxage=120, swr=86400HTML names the fingerprinted bundle, so it must turn over fastest.

Be honest about the bill. An asset replaced in place takes up to ten minutes to reach everyone, and a deploy takes up to two minutes to be globally visible. Swap a room model, open the page immediately, and you may see the old one and conclude the upload failed. It did not. When you need a change live now, purge that path at the CDN: the TTLs are sized for the steady state, and shortening them permanently to make one bad minute more comfortable trades the whole benefit away.

One wrinkle: the policy does anything only if the edge is willing to cache the file. Cloudflare's default cache list is keyed on file extension, and .unityweb and .glb are not on it. Without an explicit Cache Rule marking /city/viewer/* eligible, the two largest files bypass the cache entirely whatever headers they carry.

Security headers, and what each one actually stops

Header always set X-Content-Type-Options "nosniff"
Header always set Referrer-Policy "strict-origin-when-cross-origin"
Header always set X-Frame-Options "SAMEORIGIN"
Header always set Permissions-Policy "geolocation=(), microphone=(), camera=(), payment=()"
Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains" env=HTTPS

# Still Report-Only. Do not treat this as enforcement.
Header always set Content-Security-Policy-Report-Only "default-src 'self'; img-src 'self' data: https:; font-src 'self' https://fonts.gstatic.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; connect-src 'self' https://*.supabase.co"

nosniff stops a browser deciding a user-supplied .webp is really HTML and executing it. Referrer-Policy stops full URLs leaking to third-party origins. X-Frame-Options stops clickjacking by framing. Permissions-Policy denies device APIs the site never uses — most relevant in the Unity viewer, where an embedded runtime could otherwise ask. HSTS removes the plaintext first hop the canonical redirect still has to handle.

The always keyword is load-bearing: without it a header attaches only to 2xx and 3xx responses, so error pages ship unprotected.

CSP is Report-Only, and this article will not pretend otherwise. It is Report-Only because the fonts are still third-party and render-blocking, and style-src still needs 'unsafe-inline' for the same reason. A Report-Only policy tells you what would have broken; it stops nothing. Do not cite it as a control.

The practical consequence: while CSP is advisory it is very easy to "fix" the render-blocking font with the popular trick of a preload plus an inline onload handler flipping rel to stylesheet. That works today only because nothing enforces script-src. Promote the policy and the handler is an inline script that dies silently, taking the fonts with it. The fix that removes the problem rather than routing around it is to self-host the five faces the font audit left, as woff2, served immutable — the third-party origin then leaves the critical path along with the reason CSP is still Report-Only.

Pre-compressed assets

The Unity build ships already Brotli-compressed, roughly 28–30 MB of it. Compressing it again at request time would be absurd. The server's only job is to tell the truth about what the bytes are.

# Unity ships these pre-Brotli'd. Announce, do not re-encode.
<FilesMatch "\.(br|unityweb)$">
  Header set Content-Encoding "br"
  Header append Vary "Accept-Encoding"
  RemoveLanguage .br
</FilesMatch>

<IfModule mod_deflate.c>
  AddOutputFilterByType DEFLATE text/html text/css application/javascript application/json image/svg+xml
  SetEnvIfNoCase Request_URI \.(br|unityweb|glb|webp|mp4|woff2)$ no-gzip dont-vary
</IfModule>

Vary: Accept-Encoding stops a shared cache handing a Brotli body to a client that did not ask for one. The no-gzip exclusion list matters as much as the inclusion list: gzipping an already-compressed .glb or .webp burns origin CPU to make the file marginally larger. For the same reason CDN compression and minification must stay off for /city/viewer/* — a proxy that re-encodes a file already carrying Content-Encoding: br produces a body no browser can read.

How to change this file safely

There is no test suite here, and Apache will not tell you a block stopped matching — it simply stops applying it while everything keeps returning 200. Run npm run guard before touching anything, so you know whether what you are about to see was already broken. Change one block at a time. After deploying, check real response headers with curl -I against the live origin: a mistake inside an <IfModule> whose module is absent fails silently.

This file and the deploy pipeline are coupled. The deploy is an rsync --delete into the live document root, which is not atomic: for the seconds it runs, a visitor can fetch an index.html naming a fingerprinted bundle not yet on disk. No cache policy and no rewrite rule fixes that — only rsync into a sibling directory and swap. Knowing which problems belong to this file, and which merely show up in it, is most of what makes it maintainable.

Written from the arthis.land and arthis.space codebases by Aayush Kumar, who builds both. If something here is wrong, or you have shipped the same problem a better way, tell me — corrections get made and credited.