Docker Internal Tools Page with envsubst and nginx: No Database, No Framework, Under 25MB

Why This Pattern Exists (And When to Skip It)

## Why This Pattern Exists (And When to Skip It)

The problem is narrow on purpose. You have somewhere between 8 and 20 internal URLs — monitoring endpoints, CI dashboards, internal wikis, staging APIs — and those URLs differ between dev, staging, and prod. You want one HTML page that reflects the current environment’s actual addresses. No login screen. No database migrations. No Node process to restart when a URL changes.

This is not a bookmark manager. It is an environment-aware static dashboard, and the distinction matters. A bookmark manager solves a discovery problem. This solves a *configuration surfacing* problem.

**The honest alternatives and what they actually cost in 2026:**

– **Linkding** now ships as a single container with SQLite by default. It is genuinely lightweight for what it does — a full tagging, search, and import system with a usable mobile view. If you need any of that, use Linkding.
– **Shaarli** requires PHP. The image is heavier and the maintenance surface is larger. Its feature set is reasonable but the operational overhead scales with the PHP dependency chain.
– **Homepage** and **Dashy** both require Node, and both expect config file mounts plus some amount of YAML or JSON configuration authoring. Homepage in particular has a dense config schema. Neither is wrong — they are genuinely polished tools — they just don’t fit the “zero runtime, one shell script” constraint.

None of these alternatives are bad choices. They are just heavier than a 24MB `nginx:alpine` image running `envsubst` once at startup.

**When to skip this pattern entirely:**

If you need per-user bookmarks, tag-based search, favicon fetching, mobile sync, or any kind of write capability, `envsubst` + nginx is the wrong tool. Full stop. This pattern fits one specific operational profile: a small team of 1–5 people who already know what the URLs are and want one internal page that doesn’t require a separate state layer to stay current across environments.

A common failure mode is reaching for this when the real need is a shared bookmark collection — something that grows over time via user input. `envsubst` renders a template once at container start. It cannot accumulate state. If you find yourself thinking “I’ll just add a form later,” you’ve already outgrown the pattern.

By the end of this post you will have a complete, copy-pasteable project — Dockerfile, `docker-compose.yml`, HTML template, and entrypoint script — that passes a basic CI smoke test and produces an image under 25MB.

The Full Project Structure (Copy This First)

## The Full Project Structure (Copy This First)

Before any code, here is the complete file tree. Every file mentioned later in this article exists in this list — nothing appears out of nowhere mid-section.

bookmarks/
├── bookmarks.html.tmpl # HTML template with ${VAR} placeholders
├── entrypoint.sh # Runs envsubst, then hands off to nginx
├── Dockerfile # Builds the <25MB image from nginx:alpine ├── docker-compose.yml # Defines the service and passes env vars in ├── nginx.conf # Minimal config: serve one static file on 8080 └── .env.example # Committed to git; placeholder values only Six files. That's the whole project. --- ### The render pipeline in one sentence `docker-compose` passes environment variables into the container → `entrypoint.sh` runs `envsubst` to render `bookmarks.html.tmpl` into a static `bookmarks.html` → `nginx:alpine` serves that file on port 8080. Nothing runs after that. No cron job, no Node process, no Python interpreter sitting idle. The container is effectively a static file server after the first two seconds of startup. This matters for internal tooling because the failure surface is tiny. If the page is wrong, the bug is in the template or the env var value — not in application logic that could behave differently under load. --- ### The `.env.example` convention Commit `.env.example` to git. Never commit `.env`. `.env.example` should have every variable the project uses, filled with obvious placeholder values: bash GRAFANA_URL=http://grafana:3000 PROMETHEUS_URL=http://prometheus:9090 ALERTMANAGER_URL=http://alertmanager:9093 KIBANA_URL=http://kibana:5601 APP_TITLE=Internal Tools A new teammate clones the repo, runs `cp .env.example .env`, fills in real addresses, and runs `docker compose up`. No documentation required because the file is self-describing. Placeholder values like `http://grafana:3000` also communicate the *expected format* — a URL, not a hostname fragment, not a port number alone. The reason to address this now rather than later: people skim articles and copy-paste the `docker-compose.yml` before they reach the security footnote at the bottom. If your compose file references `${GRAFANA_URL}` and you have a committed `.env` with real internal addresses, that is a git history problem that does not go away when you delete the file. Add `.env` to `.gitignore` before the first commit. Add `.env.example` in the same commit. That ordering is not optional. Your `.gitignore` entry: .env Not `.env*` — that would also ignore `.env.example`, which you want tracked.

The Correct entrypoint.sh and Dockerfile

## The Correct `entrypoint.sh` and Dockerfile

Get either of these files wrong and you’ll spend an hour debugging a blank page or a container that takes 10 seconds to stop. I’ve done both. Here’s the working version.

### `entrypoint.sh`

sh
#!/bin/sh
set -e

envsubst ‘$GRAFANA_URL $PROMETHEUS_URL $VAULT_URL’ \
< /templates/bookmarks.html.tmpl \ > /usr/share/nginx/html/index.html

exec nginx -g ‘daemon off;’

Break down each piece:

**`envsubst ‘$GRAFANA_URL $PROMETHEUS_URL $VAULT_URL’`** — the first positional argument is the variable whitelist. This is the part everyone skips, and it’s the most common silent failure with this pattern. Without it, `envsubst` replaces *every* `$SOMETHING` token it finds, including CSS custom property references like `–color-primary: var(–base)`. Those don’t use `$` syntax directly, but if your template has any `${}` style references in inline `