Serving a Static SPA on Apache: .htaccess Fallback, Caching, and the Container Nobody Used
Deep links to a Vite/React SPA 404 on Apache until you add a fallback. Rewrite rules, FallbackResource, cache headers, soft 404s, and what really serves.
I moved a small site to a new server recently. On the old box there was a Docker container set up for it, port published, running. So the obvious plan was "move the container".
Except that container had never served a single real request. The site is a static Vite single-page app, and on the old server the hosting panel had been serving the built files straight out of the docroot the whole time. The container was running next to it, doing nothing, for who knows how long. During the move I even copied it over and started it on the new box too, where it again ran along unused before I noticed.
Harmless this time. But it's the kind of thing that turns a migration into a guessing game, so this post is two things: how to check what actually answers before you move anything, and how to serve a static SPA properly on Apache once you know.
TL;DR: Before migrating, curl -I the site, look at the Server header and the access logs of every candidate, and check whether the container gets any traffic at all. To serve a Vite/React/Vue build on Apache, drop the dist/ output in the docroot and add a routing fallback: either FallbackResource /index.html or the classic RewriteCond !-f / !-d + RewriteRule . /index.html [L]. Cache hashed assets forever, index.html never. And remember that a SPA answers 200 for every URL.
Step zero: find out what really answers
Three quick checks, in order.
1. Headers from outside:
curl -sI https://www.example.com/ | grep -iE '^(server|x-powered-by|via|etag|last-modified)'
A panel's Apache or nginx front, a Caddy, an nginx inside a container: they usually announce themselves differently in Server:. Note it. Then ask the container directly from the server:
curl -sI http://127.0.0.1:8080/ | grep -i '^server'
If those two disagree, the public site is not coming from the container.
2. Access logs of each candidate:
docker logs --since 24h <container> 2>&1 | wc -l
tail -n 50 /var/www/vhosts/<domain>/logs/access_log # panel paths vary
A container that's supposedly serving a public website and logged nothing in 24 hours isn't serving it. (Monitoring probes count as traffic too, so look at what the requests are, not just how many.)
3. The vhost config:
apachectl -S 2>/dev/null | grep -i example.com
grep -rn "ProxyPass" /etc/apache2/ | grep -i example.com
No ProxyPass to the container's port in the vhost means Apache serves from disk. That's what I'd have found in about a minute if I had looked before planning the move instead of after.
The lesson I took: inventory by observation, not by "what's installed". A running container is a claim, not a fact.
Building and deploying the static files
A Vite build produces a folder like this:
dist/
index.html
assets/
index-B3k9xQ2a.js
index-D8fLw1cZ.css
logo-Cq0aW1u2.svg
favicon.ico
robots.txt
Everything in assets/ has a content hash in the filename. Files from Vite's public/ folder (favicon, robots.txt) are copied without a hash. That difference matters for caching later.
Deploy is a copy:
npm ci && npm run build
rsync -av --delete dist/ deploy@203.0.113.10:/var/www/site/htdocs/
--delete removes old hashed bundles. If you're worried about users with an old index.html open requesting old chunks mid-deploy, drop the --delete and clean up old assets every few deploys instead.
The routing fallback
The SPA handles routes like /about or /legal in JavaScript. Click a link inside the app and it works. Open https://www.example.com/legal directly, or follow a link from somewhere else, and Apache looks for a file called legal, finds nothing, and returns 404. This is the bug that bit me in practice: another site of mine links to the legal pages, which are routes inside this SPA, and every one of those links needs the fallback to work.
Option A, short and clean (mod_dir, Apache 2.2.16+):
# /var/www/site/htdocs/.htaccess
FallbackResource /index.html
Any request that doesn't map to an existing file gets index.html, status 200.
Option B, the classic rewrite, which gives you more control:
# /var/www/site/htdocs/.htaccess
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteBase /
RewriteRule ^index\.html$ - [L]
# missing hashed assets should 404, not return HTML
RewriteCond %{REQUEST_URI} ^/assets/
RewriteRule ^ - [L]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule . /index.html [L]
</IfModule>
Read it top to bottom: requests for index.html itself stop immediately; anything under /assets/ is served or 404s normally; everything else that isn't a real file or directory gets rewritten to index.html.
Both need .htaccess to be honoured for the docroot (AllowOverride All, or at least FileInfo and Indexes). Panels usually allow that; on a plain Apache, check the <Directory> block. If you control the vhost, put the same lines there and skip .htaccess entirely; it's marginally faster.
Gotcha: missing JS files come back as HTML
With a naive fallback (Option A, or Option B without the /assets/ rule), a request for a chunk that doesn't exist anymore, say after a deploy, returns index.html with 200 OK. The browser then tries to execute HTML as JavaScript and you get the famous Failed to load module script: Expected a JavaScript module script but the server responded with a MIME type of "text/html".
That's confusing to debug because the network tab shows green 200s everywhere. Let /assets/ 404 properly, as in Option B, and the error becomes an honest 404.
Cache headers
Hashed files never change: a new build gets new names. So they can be cached for a year. index.html is the opposite: it's the file that points at the current hashes, so it must be revalidated every time, or users keep loading the old app after a deploy.
<IfModule mod_headers.c>
<If "%{REQUEST_URI} =~ m#^/assets/#">
Header set Cache-Control "public, max-age=31536000, immutable"
</If>
<Files "index.html">
Header set Cache-Control "no-cache"
</Files>
</IfModule>
no-cache doesn't mean "don't store"; it means "ask the server before using it", which with an ETag is a cheap 304. Non-hashed files from public/ get nothing special and fall back to your default.
Verify both, including a deep link that goes through the fallback:
curl -sI https://www.example.com/assets/index-B3k9xQ2a.js | grep -i cache-control
curl -sI https://www.example.com/ | grep -i cache-control
curl -sI https://www.example.com/legal | grep -iE '^(HTTP|cache-control)'
If the third one doesn't show no-cache, your index.html header isn't being applied to rewritten requests on your setup; set the header at vhost level or match on the response's content type instead.
Gotcha: everything is a 200
The fallback means /this-page-does-not-exist returns 200 with your app, which then shows its own "not found" view. Humans don't care. Search engines call that a soft 404, and a site that answers 200 for any URL can get junk URLs indexed.
Options, from cheapest to most effort:
- Have the app's not-found route add
<meta name="robots" content="noindex">to the page. Google renders JavaScript and generally respects it. Cheap, mostly works. - Whitelist real routes in
.htaccessand let everything else 404. Only sensible if the route list is short and stable:
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_URI} ^/(about|legal|privacy|contact)/?$
RewriteRule . /index.html [L]
# anything else that isn't a file: normal 404
- Prerender at build time (a static-site plugin for your framework), so each route is a real HTML file and missing ones are real 404s. Best for SEO, most work.
For a small brochure site, the first option is usually enough.
Where this leaves the container
It has no job. At the time of writing it's still idling on the new box, and it's on my list to go. A static site doesn't need a process: Apache serving files from disk is about as low-maintenance as hosting gets, and there's nothing to restart, update or forget. If you do want a container in front for some reason, put it behind Apache properly with a proxy; that's covered in Apache as a reverse proxy for Docker apps.
Related reading: if you run nginx instead, the equivalent fallback is try_files $uri $uri/ /index.html; inside the location block, see my nginx reverse proxy setup. And for the move itself, lower your DNS TTL before a migration, or you'll be waiting a day for the switch to reach everyone.