🔧 AI Nachrichten How I’m using Codex and ChatGPT on my Mac(01.09.2026 um 00:00 Uhr)
🕵️ SicherheitslückenProFTPD mod_sql post-authentication SQLi RCE(06.09.2026 um 18:21 Uhr)
🕵️ Sicherheitslücken[remote] CVE-2026-42167 - ProFTPD mod_sql post-authentication SQLi - RCE(25.08.2026 um 02:00 Uhr)
🕵️ Sicherheitslücken[webapps] C-MOR 6.0104 - Cross-Site Scripting (XSS)(31.08.2026 um 02:00 Uhr)
🕵️ Sicherheitslücken[webapps] CubeCart 6.7.4 - Stored XSS(31.08.2026 um 02:00 Uhr)
🕵️ Sicherheitslücken[webapps] CubeCart 6.7.4 - Cross-Site Scripting(31.08.2026 um 02:00 Uhr)
🕵️ Sicherheitslücken[webapps] Langflow 1.8.4 - Path Traversal to Remote Code Execution(31.08.2026 um 02:00 Uhr)
🕵️ Sicherheitslücken[webapps] miniOrange 5.4.3 - Unauthenticated Auth Bypass(01.09.2026 um 02:00 Uhr)
🕵️ Sicherheitslücken[webapps] Wolf CMS 0.8.3.1 - RCE v(01.09.2026 um 02:00 Uhr)
🔧 AI Nachrichten How I’m using Codex and ChatGPT on my Mac(01.09.2026 um 00:00 Uhr)
🕵️ SicherheitslückenProFTPD mod_sql post-authentication SQLi RCE(06.09.2026 um 18:21 Uhr)
🕵️ Sicherheitslücken[remote] CVE-2026-42167 - ProFTPD mod_sql post-authentication SQLi - RCE(25.08.2026 um 02:00 Uhr)
🕵️ Sicherheitslücken[webapps] C-MOR 6.0104 - Cross-Site Scripting (XSS)(31.08.2026 um 02:00 Uhr)
🕵️ Sicherheitslücken[webapps] CubeCart 6.7.4 - Stored XSS(31.08.2026 um 02:00 Uhr)
🕵️ Sicherheitslücken[webapps] CubeCart 6.7.4 - Cross-Site Scripting(31.08.2026 um 02:00 Uhr)
🕵️ Sicherheitslücken[webapps] Langflow 1.8.4 - Path Traversal to Remote Code Execution(31.08.2026 um 02:00 Uhr)
🕵️ Sicherheitslücken[webapps] miniOrange 5.4.3 - Unauthenticated Auth Bypass(01.09.2026 um 02:00 Uhr)
🕵️ Sicherheitslücken[webapps] Wolf CMS 0.8.3.1 - RCE v(01.09.2026 um 02:00 Uhr)

🔧 Programmierung 🕛 vor 3 Monaten 25 Min Lesezeit
0

I Built a Self-Contained Bookmarks Page from Environment Variables — No Database Needed

↗ Quelle (dev.to)
🗣️ Stimme:
📑 Inhaltsübersicht

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




  1. The Problem: Bookmarks That Break When You Move Servers

  2. The Approach: envsubst + a Static HTML Template

  3. Step 1: Write the HTML Template

  4. Step 2: The Entrypoint Script That Wires It Together

  5. Step 3: The Dockerfile — Keep It Small

  6. Step 4: Docker Compose Setup for Real Usage

  7. The Rough Edges I Hit

  8. 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="" \

-e BOOKMARK_VAULT=""

export PROMETHEUS_URL="${PROMETHEUS_URL:-"

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 _;



CODE
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

BOOKMARK_SECTION_INFRA=Grafana:

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;



CODE
# 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 . Follow for more developer-focused tooling reviews and productivity guides.

Vollständiger Original-Bericht
Ausführliche Details, Code-Beispiele & Hersteller-Stellungnahme auf dev.to.
↗ Original-Artikel auf dev.to lesen
Wie bewertest du diesen Beitrag?
1 Klick Feedback
Teilen mit Netzwerk & Team:

Community-Analysen & Experten-Meinungen 0

Verfasse deine eigene Analyse, teile Workarounds oder diskutiere diesen Vorfall im Blog.
Noch keine Community-Analyse verfasst. Markiere einen Textabschnitt oder klicke oben auf Eigene Analyse verfassen“!
Community Pulse: Relevanz-Einschätzung
1 Klick Experten-Votum
🔴 Akute Relevanz 0%
🟡 In Evaluierung 0%
🟢 Keine Auswirkung 0%
Spannende Innovation 0%
Verwandte Story-Cluster & Quellen (Vektor-KI)
Port 8095 Engine
2 Quellen
Hands-On with ChatGPT Work’s New Cloud Browser Feature
1 Quelle
iPhone Duo design & MagSafe problems on the AppleInsider Podcast
1 Quelle
Evernote 11.30.6
Ähnliche Beiträge
🔍 Verwandte News

Auch interessante Nachrichten I Built a Self-Contained Bookmarks Page from Environment Variables — No Database Needed

Thematisch verwandte Begriffe: Built, SelfContained, Bookmarks, Page · 6 Treffer

Laden...

Videos werden geladen ...

Laden...

Beiträge werden geladen ...

Laden...

Videos werden geladen ...

Laden...

Beiträge werden geladen ...

Laden...

Videos werden geladen ...

Laden...

Beiträge werden geladen ...

Laden...

Videos werden geladen ...

Laden...

Beiträge werden geladen ...

Laden...

Videos werden geladen ...