> ## Content Index
> Fetch the complete content index at: https://blog.diengdoh.com/llms.txt
> Use this file to discover other available public pages before exploring further.

# Serving a Static SPA on Apache: .htaccess Fallback, Caching, and the Container Nobody Used
- URL: https://blog.diengdoh.com/static-spa-apache-htaccess-fallback/
- Published: 2026-08-20T10:00:00.000Z
- Updated: 2026-10-11T10:16:27.000Z
- Description: 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.
- Author: Lukas
- Tags: Linux

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:**

```bash
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:

```bash
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:**

```bash
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:**

```bash
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:

```text
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:

```bash
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+):

```apache
# /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:

```apache
# /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.

```apache
<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:

```bash
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 `.htaccess`** and let everything else 404\. Only sensible if the route list is short and stable:

```apache
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](/apache-reverse-proxy-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](/nginx-reverse-proxy-setup/). And for the move itself, [lower your DNS TTL before a migration](/lower-dns-ttl-before-migration/), or you'll be waiting a day for the switch to reach everyone.

---

### Related posts

- [Extracting a Proxmox VMA Backup Without Proxmox](https://blog.diengdoh.com/extract-proxmox-vma-backup-without-proxmox/)
- [Proxmox + ZFS — A Storage Pool That Survives a Disk Death](https://blog.diengdoh.com/proxmox-zfs-storage-pool-setup/)
- [Lower Your DNS TTL Before You Migrate a Server (A Checklist)](https://blog.diengdoh.com/lower-dns-ttl-before-migration/)