Skip to content

Skiley/evergreen-static-web

Repository files navigation

evergreen-static-web

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:

  1. 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.
  2. 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.
  3. Comes with a small library of nginx config snippets you include into a skeleton, so each downstream app's nginx config stays under a dozen lines.

Quick start

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:

How env substitution works

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.

Wiring it into your build

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, so vite dev works 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.

Configuration

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.

URL placeholder substitution (optional)

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.

Why the placeholder must be lowercase

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.

Configuration

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.

How asset carryover works

30-hydrate-assets.sh runs before envsubst:

  1. Copies anything in /web-baked/assets/ into /web/assets/ without overwriting existing files. New deploys add their hashed chunks alongside the previous deploy's.
  2. Touches the mtime of every just-seen file so retention is keyed off "last deploy that shipped it", not original build time.
  3. 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).

Configuration

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.

Caveat: env-var drift in carried-over assets

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.

nginx skeleton

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 — gzip
    • assets-cache.conf/assets/* with 1y immutable cache
    • well-known.conf/.well-known/* no-cache
    • images-cache.confavif|gif|ico|jpe?g|png|svg|webp with 8h cache
    • no-cache.confjson|txt|webmanifest|xml with must-revalidate
  • Then auto-includes /etc/nginx/snippets/app/*.confwhere your app drops its location blocks.

Adding app blocks

For an SPA (index.html fallback):

COPY ./nginx-app/spa.conf /etc/nginx/snippets/app/spa.conf

Or 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.conf

Adding http-level blocks

For 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.)

Replacing the skeleton entirely

If your app needs a wildly different server { }, just overwrite the default:

COPY ./nginx.conf /etc/nginx/conf.d/default.conf

The entrypoint scripts (hydration + envsubst) keep working — they don't depend on the nginx config.

Building locally

docker build -t evergreen-static-web:local .

License

MIT — see LICENSE.

About

Docker image for statically-built websites with runtime env substitution and asset carryover across deploys.

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors