TL;DR: The thing that finally broke me was opening a bookmarks HTML file I'd maintained for two years and discovering that roughly half the links pointed to `192. 168.
📖 Reading time: ~23 min
What's in this article
- The Problem: Bookmarks That Break When You Move Servers
- The Approach: envsubst + a Static HTML Template
- Step 1: Write the HTML Template
- Step 2: The Entrypoint Script That Wires It Together
- Step 3: The Dockerfile — Keep It Small
- Step 4: Docker Compose Setup for Real Usage
- The Rough Edges I Hit
- Going Further: Multi-Environment Builds with Docker Bake
The Problem: Bookmarks That Break When You Move Servers
The thing that finally broke me was opening a bookmarks HTML file I'd maintained for two years and discovering that roughly half the links pointed to 192.168.1.45 — a dev box I'd retired months earlier. The other half pointed to localhost:3000, which only worked on my machine. The file was useless to anyone else on the team, and worse, it was useless to me the moment I rebuilt my local environment. Every environment migration turned into an archaeological dig through static HTML.
The root problem is that static bookmarks files naturally accumulate hardcoded IPs, port numbers, and hostnames. You write http://10.0.0.5:8080/jenkins when you're on the dev network, then paste that file into your staging wiki, and now your staging team is filing confused tickets. You end up maintaining three nearly-identical versions of the same file — one per environment — and they drift apart the moment anyone adds a new link to one and forgets to update the others. I've seen teams with four versions of a "useful links" page floating around, none of them authoritative.
The knee-jerk solution is "just use Linkding or Shaarli." I actually like both of those tools for personal use. But spinning up a Postgres database, a persistent volume, handling auth, and keeping a separate web service alive just so your team can see nine links to internal tools is absurd. That's a services dependency and an ops burden for something that should be a static-ish HTML page. You don't need user accounts, tagging, or full-text search on a team links page. You need: CI/CD dashboard, staging app, staging API, Grafana, Kibana, Vault UI. Nine links. Done.
The actual goal worth building toward is a single Docker image that generates — or serves — a bookmarks page where every URL is injected at runtime via environment variables. No files to edit after the image is built. No environment-specific image variants. You run the same image in dev, staging, and prod, you pass different env vars, and you get the right links. The Dockerfile bakes in the template; the container startup resolves the actual values. Zero external dependencies means no database sidecar, no volume mounts for state, no secret rotation complexity. The image should be runnable with a single docker run -e command and produce a working page.
`shell
What you want to be able to do:
docker run -d \
-p 8080:80 \
-e BOOKMARK_JENKINS="http://ci.staging.internal:8080" \
-e BOOKMARK_GRAFANA="http://metrics.staging.internal:3000" \
-e BOOKMARK_VAULT="http://vault.staging.internal:8200/ui" \
-e PAGE_TITLE="Staging Tools" \
my-bookmarks-page:latest
Not this:
vim bookmarks-staging.html # ...and then forgetting to commit it
`
That docker run invocation is the north star. Every architectural decision in building this tool should be measured against it: does this decision make that command simpler or more complicated? A database makes it more complicated. A config file mount makes it more complicated. A build-time ARG instead of a runtime ENV makes it much more complicated — because now you need separate image tags per environment, and you've just recreated the three-drifting-HTML-files problem but with Docker registries instead of a wiki.
The Approach: envsubst + a Static HTML Template
The thing that surprised me when I first looked at this problem was how many people reach for a template engine — Jinja2, ERB, Mustache — and then realize they've added a Python or Ruby runtime to a container that just needs to serve some HTML. envsubst is already installed on your machine right now, almost certainly. It ships as part of gettext-base on Debian/Ubuntu and gettext on Alpine and RHEL-family distros. No pip install, no gem install, no language runtime in your image at all.
What envsubst actually does is embarrassingly simple: it reads stdin (or a file), finds every $VARIABLE or ${VARIABLE} placeholder, replaces it with the matching value from the current environment, and writes to stdout. That's the whole program. The reason that's powerful here is that your Docker Compose environment: block or your .env file is already your data source. You're not wiring up a config system — you're just letting the shell do what it does.
Before you write a single line of template, run the sanity check:
`shell
Should print: Hello your-actual-username
echo 'Hello $USER' | envsubst
More explicit — good for verifying a specific var is in scope
export BOOKMARK_TITLE="My Links"
echo 'Page title: "$BOOKMARK_TITLE' | envsubst"
`
If that second one prints Page title: " with nothing after the colon, your variable isn't exported — that's the gotcha. envsubst only sees exported variables. Inside a Docker build or at container startup, everything in the environment: block is automatically exported, but if you're testing locally with a source .env, you need set -a; source .env; set +a to export everything. I've wasted twenty minutes on that exact thing."
The mental model that makes this click: treat bookmarks.html.tmpl exactly like you'd treat a .sql file with placeholders, and treat your environment as the bind parameters. The template file lives in your repo and is just static HTML with $VAR markers wherever you want runtime values. The .env file (or your Compose environment: block, or actual shell exports in CI) is the data layer. At container start, one envsubst call fuses them into a real bookmarks.html file that Nginx or any static server can serve directly.
Why this beats Jinja2 for this specific use case: Jinja2 is genuinely better when you need loops, conditionals, filters, and inheritance. But if your bookmarks page is a fixed structure where you're only swapping out URLs and labels, you don't need any of that. Adding Python just to call jinja2-cli in an entrypoint script means your final image jumps from ~25MB (nginx:alpine) to 80-100MB+ depending on what else drags in. With envsubst you're using a binary that's already in alpine via apk add gettext (adds ~2MB), and your entrypoint is a five-line shell script. Simpler failure modes, easier to audit, nothing to CVE-scan except a well-maintained GNU utility.
Step 1: Write the HTML Template
Writing the HTML Template
The gotcha that bites everyone first time: envsubst without arguments replaces every $VARIABLE pattern in your file. If your CSS has $primary-color or your JavaScript references $event, they get nuked. The fix is dead simple — whitelist exactly the vars you want substituted:
`shell
envsubst '${GRAFANA_URL} ${KIBANA_URL} ${CI_URL} ${ALERTS_URL}' \
< bookmarks.html.tmpl \
bookmarks.html
`
That quoted string is the allow-list. Anything not in there passes through untouched. I burned 20 minutes debugging a broken flexbox before I learned this — the CSS calc() with a CSS variable had silently turned into an empty string.
Here's the actual template I use. The placeholder syntax is standard shell variable expansion — no special templating engine required, just ${VAR_NAME}:
`html
<!DOCTYPE html>
<br> /* CSS variables here are safe because we whitelist specific $vars above */<br> :root {<br> --card-bg: #1e1e2e;<br> --page-bg: #13131f;<br> --text: #cdd6f4;<br> --accent: #89b4fa;<br> --border: #313244;<br> }</p> <div class="highlight"><pre class="highlight plaintext"><code>body { background: var(--page-bg); color: var(--text); font-family: system-ui, sans-serif; margin: 0; padding: 2rem; } .grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 1.5rem; max-width: 1200px; margin: 0 auto; } .category { background: var(--card-bg); border: 1px solid var(--border); border-radius: 8px; padding: 1.25rem; } .category h2 { font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.08em; color: var(--accent); margin: 0 0 1rem 0; } .category a { display: block; color: var(--text); text-decoration: none; padding: 0.4rem 0; border-bottom: 1px solid var(--border); font-size: 0.95rem; } .category a:last-child { border-bottom: none; } .category a:hover { color: var(--accent); } </code></pre></div> <p>
<!-- OBSERVABILITY GROUP -->
<div class="category">
<h2>Observability</h2>
<a href="${GRAFANA_URL}" target="_blank">Grafana</a>
<a href="${KIBANA_URL}" target="_blank">Kibana</a>
<a href="${ALERTS_URL}" target="_blank">Alertmanager</a>
</div>
<!-- CI/CD GROUP -->
<div class="category">
<h2>CI / CD</h2>
<a href="${CI_URL}" target="_blank">CI Pipelines</a>
<a href="${REGISTRY_URL}" target="_blank">Container Registry</a>
<a href="${DEPLOY_URL}" target="_blank">Deployments</a>
</div>
<!-- DATA GROUP -->
<div class="category">
<h2>Data</h2>
<a href="${METABASE_URL}" target="_blank">Metabase</a>
<a href="${SUPERSET_URL}" target="_blank">Superset</a>
</div>
`
The auto-fill / minmax(280px, 1fr) grid is the one CSS trick I reach for constantly — no media queries needed, cards reflow naturally as the window resizes, and it degrades fine on mobile. The whole layout works without Bootstrap, Tailwind, or any external asset fetch, which matters if this page is going to load inside a locked-down internal network where CDN requests time out.
The category grouping maps directly to env var prefixes. Notice GRAFANA_URL, KIBANA_URL, and ALERTS_URL all live in the Observability card. That's intentional — I name my env vars with a domain prefix (GRAFANA_, CI_, DATA_) so the grouping in the template mirrors the grouping in whatever .env file or secrets manager I'm pulling from. When a new tool joins the observability stack, I add one env var and one anchor tag in the right card. No restructuring required.
One more thing about the template syntax: don't use $VARNAME (without braces). The brace form ${VARNAME} is unambiguous — envsubst handles both, but bare $VAR next to HTML attribute text can cause parsing confusion in editors and breaks syntax highlighting in most IDEs. Braces cost you nothing and save you a headache when you're staring at the raw template at midnight trying to figure out why a URL is malformed.
Step 2: The Entrypoint Script That Wires It Together
The entrypoint script is where the magic actually happens, and getting it wrong means nginx never receives SIGTERM correctly when Docker stops the container — your deployments hang for 10 seconds waiting for a timeout instead of shutting down cleanly. I learned this the hard way before I understood why exec isn't optional here.
Here's the full script. Every line is load-bearing:
`shell
!/bin/sh
entrypoint.sh
Substitute env vars into the template, write to nginx's serve directory
envsubst < /etc/nginx/templates/index.html.tmpl > /usr/share/nginx/html/index.html
Replace this shell process with nginx — nginx becomes PID 1
exec nginx -g 'daemon off;'
`
The exec call isn't style — it's correctness. Without exec, your shell script spawns nginx as a child process and sits around as PID 1 doing nothing. When Docker sends SIGTERM on docker stop, PID 1 (the shell) gets it, does nothing useful with it, and nginx never receives the signal at all. With exec, the shell replaces itself with nginx. Nginx becomes PID 1, catches SIGTERM directly, drains connections, and exits cleanly. This is the difference between a 0-second shutdown and a 10-second timeout every single time you redeploy.
Default values keep the page functional even when someone runs the container without setting every variable. Use the ${VAR:-fallback} syntax directly in the template (not in the script itself), but I also add a pre-flight block in the entrypoint to make debugging faster:
`shell
!/bin/sh
Defaults — the page renders something useful even without full config
export GRAFANA_URL="${GRAFANA_URL:-http://localhost:3000}"
export PROMETHEUS_URL="${PROMETHEUS_URL:-http://localhost:9090}"
export KIBANA_URL="${KIBANA_URL:-http://localhost:5601}"
export PAGE_TITLE="${PAGE_TITLE:-My Bookmarks}"
envsubst < /etc/nginx/templates/index.html.tmpl > /usr/share/nginx/html/index.html
exec nginx -g 'daemon off;'
`
Setting them explicitly with export before running envsubst means the defaults propagate correctly even if the parent environment never defined those variables at all. If you only use ${VAR:-default} inside the template, envsubst will substitute an empty string because the shell variable is genuinely unset — it doesn't evaluate bash parameter expansion syntax, it just swaps $VAR for whatever $VAR holds. Exporting first closes that gap.
The Dockerfile side is a one-liner that people forget until their container fails at runtime with a permission error:
`docker
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
ENTRYPOINT ["/entrypoint.sh"]
`
Use the JSON array form for ENTRYPOINT (not the shell string form). The shell string form wraps your command in /bin/sh -c, which means you're back to the PID 1 problem — your exec in the script correctly replaces the script's shell, but the /bin/sh -c wrapper process is now PID 1 instead of nginx. JSON array form bypasses that wrapper entirely and runs your script directly as PID 1 before exec hands off to nginx.
Step 3: The Dockerfile — Keep It Small
The thing that catches most people off guard: envsubst isn't in the base Alpine image. It lives in the gettext package, and if you forget to install it, your container will start fine, substitute nothing, and serve a page full of literal ${BOOKMARK_URL_1} strings. Fun to debug at 11pm.
Here's the full Dockerfile. Every line is intentional:
`docker
FROM nginx:alpine
gettext gives us envsubst — without this the whole thing is pointless
RUN apk add --no-cache gettext
Template first, so Docker cache invalidates correctly when content changes
COPY bookmarks.html.tmpl /etc/nginx/templates/bookmarks.html.tmpl
Entrypoint runs envsubst at startup, writes the final HTML, then hands off to nginx
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
Only copy a custom nginx.conf if you need non-80 serving or auth
COPY nginx.conf /etc/nginx/nginx.conf
EXPOSE 80
ENTRYPOINT ["/entrypoint.sh"]
`
The COPY order isn't arbitrary. If you copy the entrypoint script first and the template second, any change to bookmarks.html.tmpl busts the cache layer that installed gettext too — because Docker sees a changed layer earlier in the stack. Template first, script second, keeps your rebuilds fast. The final image with nginx:alpine as base and gettext added lands around 22–24MB depending on your template size. Nothing else needed.
If you need to serve on a non-standard port (say, 8080 behind a Traefik reverse proxy) or bolt on basic auth via htpasswd, uncomment that COPY nginx.conf line and use something like this:
`nginx
server {
listen 8080;
server_name _;
root /usr/share/nginx/html;
index bookmarks.html;
# Basic auth — generate htpasswd with:
# htpasswd -c .htpasswd youruser
auth_basic "Bookmarks";
auth_basic_user_file /etc/nginx/.htpasswd;
location / {
try_files $uri $uri/ =404;
}
}
`
If you go the basic auth route, mount the .htpasswd file as a secret or volume at runtime — don't bake credentials into the image. The command to generate one is htpasswd -c .htpasswd youruser (requires apache2-utils on Debian or httpd-tools on RHEL). On Alpine itself you can get it via apk add apache2-utils in a separate build stage if you want to generate it inside the pipeline rather than locally.
One real gotcha with the custom nginx.conf: the default nginx:alpine image includes conf snippets under /etc/nginx/conf.d/ and they will conflict if you define a server block in the top-level nginx.conf and also leave the default conf.d/default.conf in place. Either drop a config file into /etc/nginx/conf.d/bookmarks.conf instead (overriding just that server block), or explicitly remove default.conf in your Dockerfile with RUN rm /etc/nginx/conf.d/default.conf. I prefer the first approach — less surgery on the base image.
Step 4: Docker Compose Setup for Real Usage
The thing that bit me first time I set this up: I put the container's environment variables directly in the compose file, committed it, and pushed. URL list, internal hostnames, everything — sitting in git history forever. The .env file pattern exists specifically to prevent this, and Docker Compose handles it natively without any extra tooling.
Here's the full compose setup I actually use. The env_file directive pulls every variable from .env into the container's environment, and the .env file itself never leaves the machine:
`yaml
docker-compose.yml
services:
bookmarks:
image: your-registry/bookmarks-generator:latest
# or build: . if you're iterating locally
build: .
env_file:
- .env # keeps secrets out of this file entirely
ports:
- "8080:80" # expose on 8080 locally, nginx serves on 80 inside
restart: unless-stopped # homelab default — stops cleanly on shutdown
healthcheck:
test: ["CMD-SHELL", "curl -f http://localhost/ || exit 1"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s # give nginx time to actually start before first check
`
Your .env file sits next to docker-compose.yml and contains your actual bookmark data. Add it to .gitignore immediately — before your first git add .:
`properties
.env — never commit this
BOOKMARK_SECTION_WORK=GitHub:https://github.com,Jira:https://jira.internal.corp,CI:https://ci.internal.corp
BOOKMARK_SECTION_INFRA=Grafana:https://grafana.internal.corp,Portainer:https://portainer.internal.corp
BOOKMARK_SECTION_DOCS=Confluence:https://wiki.internal.corp,Runbooks:https://runbooks.internal.corp
PAGE_TITLE=Team Dashboard
THEME=dark
`
`properties
.gitignore — add this line
.env
`
The healthcheck deserves more attention than it usually gets. Without it, Docker reports the container as "Up" the moment the process starts — but nginx might still be initializing, or the entrypoint script that renders bookmarks from env vars might not have finished writing the HTML yet. The start_period: 10s tells Docker not to count failed checks during that initial window, so you don't get false "unhealthy" statuses on a cold start. If you're running this behind Traefik or another reverse proxy that reads container health before routing traffic, this matters a lot. Portainer also surfaces the health status visually, which makes debugging easier when something's wrong.
On the restart policy: unless-stopped is the right default for a homelab because it respects manual stops — if you run docker compose stop to do maintenance, the container stays down after a daemon restart until you explicitly start it again. Switch to restart: always only when you have a real availability requirement, because it will start the container automatically even after you manually stopped it, which is confusing during debugging. For a production internal tools server where people are relying on the page being up, always is the right call. If you're running multiple instances behind a load balancer, pair it with a depends_on check or an actual orchestrator healthcheck so you're not routing to a container that's mid-restart.
One gotcha: if your image build process generates the static HTML at container startup (reading env vars in an entrypoint script rather than at build time), make sure your healthcheck actually validates that the page content is there — not just that nginx responded. You can extend the check slightly:
yaml
healthcheck:
test: ["CMD-SHELL", "curl -sf http://localhost/ | grep -q 'bookmarks' || exit 1"]
interval: 30s
timeout: 5s
retries: 3
start_period: 15s
This greps for a string you know will be in the rendered output, so a 200 response serving an empty or error page still fails the check. Saved me twice when an env var was malformed and the generator silently produced an empty page while nginx happily served a 200.
The Rough Edges I Hit
The first time I ran envsubst on my HTML template, my CSS broke completely. Every calc() expression and every var(--color) reference got eaten because envsubst replaces everything that looks like $SOMETHING — including CSS custom properties. The fix is to explicitly whitelist only the variables you want substituted instead of letting it run wild:
`shell
Instead of this (destroys your CSS):
envsubst < template.html > index.html
Do this — only substitute the vars you actually own:
envsubst '${BOOKMARK_TITLE} ${LINKS_JSON} ${BACKGROUND_COLOR}' \
< template.html > index.html
`
That third argument to envsubst is a string of variable names in ${VAR} format. Anything not in that list gets left alone. The calc(100vh - 2rem) expressions survive, your var(--accent) tokens survive, and only the actual bookmark data gets injected. I wasted an hour debugging a layout that looked fine in isolation before I figured this out.
The stale-page-after-restart problem is a classic self-inflicted wound. I restarted the container, refreshed the browser, and kept seeing the old bookmarks. I spent 20 minutes tailing nginx logs and checking volume mounts before realizing the browser was the problem, not nginx. The page had loaded with no cache headers, so Chrome cached it aggressively. Adding this to the nginx location block fixed the confusion permanently:
`nginx
location / {
root /usr/share/nginx/html;
index index.html;
# Bookmarks are rebuilt per-container-start, so never cache them
add_header Cache-Control "no-store, no-cache, must-revalidate";
add_header Pragma "no-cache";
expires 0;
}
`
Windows line endings will silently kill your container with zero useful output in the logs. If you edit entrypoint.sh on Windows — even in VS Code with the wrong settings — the file gets CRLF line endings. Bash inside the Alpine or Debian container sees the carriage return as part of the command name and exits immediately. docker logs <container> shows nothing because the script dies before it can write anything meaningful. The fix before you even build:
`shell
If you have dos2unix installed locally:
dos2unix entrypoint.sh
Or strip it with sed if you're on Linux/Mac already:
sed -i 's/\r//' entrypoint.sh
Verify the file has no CRs:
cat -A entrypoint.sh | head -5
Clean output ends with $ not ^M$
`
The long-term prevention is a .gitattributes file with entrypoint.sh text eol=lf so Git normalizes it on checkout regardless of the editor. VS Code also shows the line ending mode in the status bar — click it and switch to LF before you ever save the file.
Raw $VAR placeholders showing up in the rendered page almost always means your ENTRYPOINT in the Dockerfile is pointing to the wrong path, so the real entrypoint script never ran and nginx is serving your raw template. Double-check two things: the path in the Dockerfile matches where you actually COPYd the script, and the script has execute permissions:
`shell
Common mistake — script copied to /app but Dockerfile says /entrypoint.sh:
COPY entrypoint.sh /app/entrypoint.sh
RUN chmod +x /app/entrypoint.sh
ENTRYPOINT ["/app/entrypoint.sh"] # must match the COPY destination
Quick sanity check — exec into a running container and verify:
docker exec -it ls -la /app/entrypoint.sh
Should show -rwxr-xr-x, not -rw-r--r--
`
The symptom of seeing literal ${LINKS_JSON} in the browser is so distinct that once you've seen it you know exactly what happened. But the first time it catches you, it looks like an environment variable injection failure when really nginx just served the template file directly because nothing ever processed it.
Going Further: Multi-Environment Builds with Docker Bake
The thing that surprised me most after getting a single-environment bookmark page working was how quickly "let me just add a staging version" became a real maintenance problem. Copy-pasting Dockerfiles for each environment is how you end up with prod accidentally running staging URLs three months later. docker buildx bake with an HCL config file solves this cleanly — one file, multiple targets, each with its own env file.
Here's a realistic docker-bake.hcl for a two-environment setup:
`hcl
variable "REGISTRY" {
default = "registry.internal"
}
group "default" {
targets = ["staging", "prod"]
}
target "base" {
dockerfile = "Dockerfile"
context = "."
}
target "staging" {
inherits = ["base"]
# env file is read at bake time, not inside the container
args = {
BOOKMARK_TITLE = "Internal Tools (Staging)"
ENV_NAME = "staging"
}
tags = ["${REGISTRY}/bookmark-page:staging"]
}
target "prod" {
inherits = ["base"]
args = {
BOOKMARK_TITLE = "Internal Tools"
ENV_NAME = "prod"
}
tags = ["${REGISTRY}/bookmark-page:prod"]
}
`
Run docker buildx bake --push and both images build in parallel and push. Run docker buildx bake staging --push to push just staging. The inherits key is what makes this composable — your base target holds shared config like the Dockerfile path, platform targets (linux/amd64,linux/arm64), and build secrets.
On the build-time ARG vs runtime injection question: I default to runtime injection via envsubst for almost everything in internal tools. The argument for build-time ARG injection is reproducibility — the image is self-contained. But the actual day-to-day reality is that your bookmark URLs change, your team names change, someone gets a new Confluence space — and you don't want a full CI build cycle just to update a link. A startup script that runs envsubst < bookmarks.template.html > bookmarks.html at container launch means you can update the URL by restarting the container with new env vars, zero rebuild required. Build-time injection makes sense for things that genuinely define the artifact — like which binary gets compiled in — not for config data that drifts over time.
`shell
entrypoint.sh — runs at container start, not at build time
!/bin/sh
set -e
ENV_BOOKMARK_URLS, ENV_TITLE, etc. come from docker run -e or compose
envsubst '${BOOKMARK_TITLE} ${BOOKMARK_GROUPS} ${FOOTER_NOTE}' \
< /app/bookmarks.template.html \
/usr/share/nginx/html/index.html
exec nginx -g 'daemon off;'
`
The envsubst quoting matters here — pass only the variable names you actually want substituted, or it'll mangle CSS with ${color} variables and any other dollar signs in your HTML template. That's the gotcha that costs you 45 minutes if you haven't seen it before.
For tagging, I keep it simple: bookmark-page:prod and bookmark-page:staging as mutable tags, plus a datestamped immutable tag like bookmark-page:prod-20250614 generated in CI. The mutable tag is what your deployment pulls; the datestamped tag is what you roll back to when someone bulk-updates URLs and breaks something. One week of tags stored in your registry is enough history — past that, storage costs outweigh the rollback value for something this low-stakes. If you want a broader look at the kind of internal tooling this pairs well with, the Essential SaaS Tools for Small Business in 2026 guide covers several services that work alongside self-hosted dashboards like this.
Alternatives I Considered and Why I Skipped Them
I looked at four obvious alternatives before writing a single line of code, and each one had a dealbreaker that showed up within about ten minutes of reading the docs.
Homer Dashboard
Homer is genuinely well-built and I'd recommend it for a team that wants icons, service health checks, and a polished UI. But the config model works against you here. It reads from a config.yml file that you volume-mount into the container. That means you still need to solve the "inject private URLs at deploy time" problem — you're just doing it one layer earlier. You end up writing a Helm chart or a docker-compose override that templates the YAML before Homer even starts. You haven't eliminated the problem, you've moved it. The Homer image also pulls in a Vue SPA build pipeline, landing around 100MB+. For a page that renders a list of links, that's a lot of weight to carry.
Flame
Flame is Node.js-based and backs its bookmarks in a SQLite database. The intent is that you manage links through a web UI and they persist between restarts. That's a sensible design for an app where users are actively editing bookmarks. My use case is the opposite: links are defined once in config, almost never change, and need zero write path. Bringing in SQLite means you're now thinking about volume mounts for the DB file, backup strategy, and migration behavior on image upgrades. For a read-mostly link page that's deployed from a Git repo, a database is pure overhead with no upside.
Heimdall
The image size alone ended the conversation. Heimdall ships PHP + Laravel + SQLite in a single image that clocks in at 200MB+. Our generated Nginx image with the baked-in HTML sits at roughly 23MB — that's not a rounding error, it's nearly a 10x difference. Heimdall does have a nicer UI and app tiles with health indicators, but if you're running this on a small VPS or a constrained cluster node, pulling and storing 200MB for a links page is hard to justify. The PHP runtime also adds a non-trivial attack surface for something that has no business logic whatsoever.
GitHub Pages Static Site
This one's actually the right answer if all your links point at public services. Push an index.html to a repo, flip on Pages, done. The problem is the moment you have internal services — your Grafana instance at http://10.0.1.15:3000, your private Gitea, your home lab router — a public static site means those URLs are sitting in a public repo or served over a public URL. Even if the services themselves are firewalled, leaking the internal IP scheme and service map is a real operational security concern. The env-var-driven build keeps all of that inside your deployment environment. The HTML never touches a public CDN and the links never appear in a repo that gets pushed anywhere.
Disclaimer: This article is for informational purposes only. The views and opinions expressed are those of the author(s) and do not necessarily reflect the official policy or position of Sonic Rocket or its affiliates. Always consult with a certified professional before making any financial or technical decisions based on this content.
Originally published on techdigestor.com. Follow for more developer-focused tooling reviews and productivity guides.
Top comments (0)