Base Docker image for serving statically-built web apps (SPAs, MPAs, Astro, Docusaurus, Vite, etc.) on nginx. Two
features set it apart from a vanilla nginx:alpine:
- Runtime env substitution. Stamp container-env values into your already-built JS/CSS/HTML/JSON/XML at boot. Build
the bundle once, deploy it everywhere with different
API_HOST,ANALYTICS_KEY, etc. No rebuild per environment. - Asset carryover across deploys. Hashed assets from previous deploys are kept around for a configurable window, so users with a cached SPA tab still load the chunk they need instead of crashing on a 404 — even though the new container only ships the latest build.
- Comes with a small library of nginx config snippets you
includeinto a skeleton, so each downstream app's nginx config stays under a dozen lines.
FROM skileydotnet/evergreen-static-web:0.4.0
# Copy your build output, then move hashed assets aside so the hydration
# entrypoint can merge them into the live (persisted) /web/assets directory:
COPY ./build /web
RUN mv /web/assets /web-baked/assets
# Drop in your app-specific location blocks (included inside the server { } block):
COPY ./nginx-app/ /etc/nginx/snippets/app/# docker-compose.yml
services:
my-app:
image: my-app:latest
environment:
API_HOST: "https://api.example.com"
volumes:
- my-app-assets:/web/assets
volumes:
my-app-assets:In your source code, reference env vars through a placeholder that survives bundling:
const apiHost = "__EVERGREEN_ENV_API_HOST__"At container boot, 40-envsubst-static.sh scans /web/**/*.{html,js,css,xml,json}, finds every
__EVERGREEN_ENV_<NAME>__ placeholder, and replaces it with the value of the matching env var from the container.
- Missing env var → boot fails. A placeholder with no matching env var is a deploy-time error, not a runtime 500.
- Empty values are allowed (lets callers explicitly opt out of, e.g., an analytics key).
- Final sanity check verifies no placeholder slipped through.
Hand-typing "__EVERGREEN_ENV_FOO__" everywhere is ugly. Instead, keep the natural process.env.FOO syntax in source
and have your bundler swap it for the placeholder at build time, so the placeholder only exists in the built output.
In Vite (works the same in Astro via vite.define):
// vite.config.ts
function envsAsDefines(): Record<string, string> {
const envs = ["API_HOST", "ANALYTICS_KEY", "BUILD_NUMBER"]
return Object.fromEntries(envs.map((name) => [
`process.env.${name}`,
JSON.stringify(
process.env.NODE_ENV === "development"
? (process.env[name] ?? "")
: `__EVERGREEN_ENV_${name}__`
)
]))
}
export default defineConfig({
define: envsAsDefines()
})Then write the natural form everywhere:
const apiHost = process.env.API_HOST- Dev → resolved to the real
process.env.API_HOST(or empty string) at build, sovite devworks without any container. - Prod build → emitted as
"__EVERGREEN_ENV_API_HOST__"in the bundle, then substituted at container boot.
The list of var names lives in one place (envsAsDefines) and doubles as your "what must be set in the container"
contract — the envsubst sanity check enforces it at boot.
For webpack the equivalent is new webpack.DefinePlugin({...}) with the same value shape. For esbuild it's the
define option. Anything that does compile-time string replacement on process.env.X works.
| Variable | Default | Notes |
|---|---|---|
EVERGREEN_ROOT |
/web |
Directory tree scanned for placeholders. |
EVERGREEN_ENV_PREFIX |
__EVERGREEN_ENV_ |
Placeholder prefix. Restricted to [A-Za-z0-9_]+. |
EVERGREEN_ENV_SUFFIX |
__ |
Placeholder suffix. Restricted to [A-Za-z0-9_]+. |
If you already have a codebase using a different placeholder convention (e.g. __MYAPP_ENV_), override the prefix and
the same script handles it.
Some frameworks need a real, parseable URL at build time — Astro's site: config and Docusaurus's url: field both
run new URL() on the value to compute canonical URLs, sitemap entries, and hreflang links. The
__EVERGREEN_ENV_FOO__ placeholder string isn't a valid URL, so it can't be used in those fields directly.
45-substitute-url.sh solves this: at boot it finds a literal placeholder URL across /web/**/*.{html,js,css,xml,json}
and replaces every occurrence with the value of an env var.
In the build, give the framework a URL-shaped placeholder:
// astro.config.ts (or docusaurus.config.ts)
site: process.env.EXTERNAL_URL ?? "https://external-url.example.placeholder"At deploy time, set two env vars on the container:
environment:
EVERGREEN_URL_PLACEHOLDER: "https://external-url.example.placeholder"
EXTERNAL_URL: "https://example.com"The script finds every occurrence of EVERGREEN_URL_PLACEHOLDER and replaces it with the value of the env var named by
EVERGREEN_URL_VALUE_VAR (default EXTERNAL_URL).
If EVERGREEN_URL_PLACEHOLDER is unset the script logs a skip and exits 0 — it's fully opt-in. If it's set but the
value-var is unset, the script fails fast at boot.
Astro and Docusaurus normalize the URL with new URL(), which lowercases the hostname. If the source has
https://EXTERNAL-URL.example.placeholder, the built HTML ends up with https://external-url.example.placeholder —
and the substituter, looking for the original uppercase form, finds nothing. Use a lowercase hostname in the placeholder
from the start.
| Variable | Default | Notes |
|---|---|---|
EVERGREEN_URL_PLACEHOLDER |
(unset) | Literal string to find. Unset = feature disabled. |
EVERGREEN_URL_VALUE_VAR |
EXTERNAL_URL |
Name of the env var holding the replacement value. |
EVERGREEN_ROOT |
/web |
Directory tree scanned. |
30-hydrate-assets.sh runs before envsubst:
- Copies anything in
/web-baked/assets/into/web/assets/without overwriting existing files. New deploys add their hashed chunks alongside the previous deploy's. - Touches the mtime of every just-seen file so retention is keyed off "last deploy that shipped it", not original build time.
- Deletes files in
/web/assets/older than the retention window.
Mount /web/assets as a named volume so the older chunks survive container recreation. envsubst runs after hydration,
so carried-over files just get re-scanned (already-substituted ones contain no placeholders and are skipped).
| Variable | Default | Notes |
|---|---|---|
EVERGREEN_ASSETS_BAKED |
/web-baked/assets |
Where this build's freshly-built hashed assets live. |
EVERGREEN_ASSETS_LIVE |
/web/assets |
The served (and persisted) assets directory. |
EVERGREEN_RETENTION_DAYS |
30 |
Files in LIVE not seen in a deploy for this long are deleted. Set 0 to disable pruning. |
A chunk carried over from a previous deploy still contains the env values that were substituted at that deploy. If
API_HOST changes between deploys, old tabs hit the old endpoint until they reload. For most rarely-changing values
this is fine; if you change a value, set retention short (or 0) for the affected deploy to evict stale chunks.
The base ships /etc/nginx/conf.d/default.conf with a server { } block that:
- Listens on
:8080 - Sets
root /web,absolute_redirect off,port_in_redirect off - Auto-includes these snippets from
/etc/nginx/snippets/evergreen/:compression.conf— gzipassets-cache.conf—/assets/*with 1y immutable cachewell-known.conf—/.well-known/*no-cacheimages-cache.conf—avif|gif|ico|jpe?g|png|svg|webpwith 8h cacheno-cache.conf—json|txt|webmanifest|xmlwithmust-revalidate
- Then auto-includes
/etc/nginx/snippets/app/*.conf— where your app drops itslocationblocks.
For an SPA (index.html fallback):
COPY ./nginx-app/spa.conf /etc/nginx/snippets/app/spa.confOr reuse one of the bundled fallback snippets:
# SPA: every unmatched path serves /index.html (for client-side routing).
RUN cp /etc/nginx/snippets/evergreen/spa-fallback.conf /etc/nginx/snippets/app/fallback.conf
# OR — static site: serves $uri / $uri/ / $uri.html, real 404 for missing.
RUN cp /etc/nginx/snippets/evergreen/static-fallback.conf /etc/nginx/snippets/app/fallback.confFor things that must live at the http { } level (e.g. map blocks for language detection), drop them into
/etc/nginx/conf.d/ — nginx auto-includes every *.conf in there:
COPY ./nginx-http/maps.conf /etc/nginx/conf.d/10-maps.conf(Pick a prefix lower than default.conf if order matters.)
If your app needs a wildly different server { }, just overwrite the default:
COPY ./nginx.conf /etc/nginx/conf.d/default.confThe entrypoint scripts (hydration + envsubst) keep working — they don't depend on the nginx config.
docker build -t evergreen-static-web:local .MIT — see LICENSE.