A static site is files. There is no application, no database and nothing executing, which is most of its appeal: very little to attack, very little to maintain, and it serves quickly on any hosting.
Three settings decide whether it actually works.
The fallback rule
This one only affects single-page applications, and it affects all of them.
The routing happens in the browser. A visitor who loads the homepage and clicks through to /products/42 is fine: the application handled it, and no request was made.
The same visitor who refreshes that page, or arrives from a link, asks the server for /products/42. No such file exists, and the server returns 404.
The server must be told to serve the entry page for anything that is not a real file:
RewriteEngine On
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule . /index.html [L]
The two conditions matter: without them, requests for real files and directories are also rewritten, and every image on the site returns HTML.
For an ordinary static site with real files at real paths, none of this is needed, and adding it hides genuine 404s, which is worse than not having it.
Cache headers
Static files are cached by browsers using default rules, which is why a deployment sometimes appears not to have happened.
The correct arrangement has two parts.
Files with a content hash in the name (app.4f2a9b.js) can be cached for a year, because a change produces a different filename. This is what modern build tools produce, and it is what makes long caching safe.
The entry page must not be cached, because its name never changes and it is what points at the hashed files. A cached index.html is a site that keeps loading yesterday's build.
<FilesMatch "\.(js|css|woff2|png|jpg|svg)$"> Header set Cache-Control "public, max-age=31536000, immutable" </FilesMatch> <FilesMatch "^index\.html$"> Header set Cache-Control "no-cache" </FilesMatch>
If your files do not have hashed names, do not cache them for a year. You will be unable to publish a change. Compression and cache headers goes over the panel's version.
Directory listings
A directory with no index file lists its contents to anyone who requests it, which is how build artefacts, source maps and backup files get discovered.
Options -Indexes
One line, worth having on every site. Directory indexes and hidden files goes into it.
Compression
Text files compress substantially, and enabling it is usually the single largest transfer reduction available.
AddOutputFilterByType DEFLATE text/html text/css application/javascript application/json image/svg+xml
Do not compress images or video. They are already compressed, and the attempt costs processing for nothing.
Deploying
Uploading files one by one leaves the site in a half-updated state while it runs, and a visitor arriving in that window gets a broken page.
Better: upload to a new directory, then switch. If the hosting allows a symbolic link, that switch is instantaneous and the rollback is switching it back.
Failing that, upload a single archive and extract it, which is fast enough that the window is small: uploading your website files explains the pattern, and deploying with Git goes into automating it.
The 404 page
With a fallback rule in place, the server never returns 404 for a page: the application does, in the browser.
Search engines see a 200 for a page that does not exist, which means missing pages get indexed as real ones. If that matters, the application should be able to make the server return a genuine 404 for known-bad paths, or the routes should be real files.
For an ordinary static site, a real error page is straightforward and worth having. See creating a custom error page.
Keep it static
The appeal is that nothing executes. Adding a small PHP script for a contact form reintroduces an application, a runtime to keep updated, and something to attack.
Where a form is needed, a hosted form service or a separate endpoint keeps the site itself inert, which is the property worth protecting.
Name the files so they can be cached forever
The whole caching arrangement for a static site rests on filenames, and getting it wrong forces you to choose between stale visitors and no caching at all.
ls -la assets/ | head curl -sI https://example.com/assets/app.4f2a1b.js | grep -iE 'cache-control|etag' curl -sI https://example.com/index.html | grep -i cache-control
Files whose name contains a hash of their contents can be cached for a year, because a change produces a different name. The entry document must not be, since it is what points at the current names.
That split is the entire strategy: one file that is never cached and everything else cached indefinitely. A build that emits fixed filenames cannot use it, and that is worth fixing in the build rather than working around in the server.
The fallback rule must not swallow real files
Routing everything to the entry document is what makes client side routing work, and written carelessly it also hides genuine errors.
curl -s -o /dev/null -w '%{http_code}\n' https://example.com/does-not-exist.js
curl -s -o /dev/null -w '%{http_code}\n' https://example.com/api/missing
curl -sI https://example.com/robots.txt | head -1
A missing script returning the entry document instead of an error is the fault to look for. The browser then tries to execute a page of markup as code, and the resulting message says nothing about the real problem.
Exclude anything with a file extension from the fallback, and exclude the interface paths. The rule should apply to addresses that look like routes, not to everything that was not found.
Deploy without a moment where the two versions mix
A visitor who loads the page just before a deployment holds names that stop existing seconds later.
Upload the new assets first and replace the entry document last. That way every name referenced by either version is present throughout, and the change is a single atomic switch rather than a window.
rsync -a --delete-after dist/assets/ user@server:/var/www/assets/ rsync -a dist/index.html user@server:/var/www/index.html
Deleting after rather than before is what keeps the old files available until the new ones are in place. Keep the previous build's assets for a while rather than removing them immediately, since a visitor with the page already open will still request them.