docker-umbrella
Skill1 starsChanged 3 months agoMITView on GitHub
Use when the user runs several local web UIs, dashboards, doc sites, or media servers and wants one address instead of a row of ports, or says "one page for all my apps", "group my containers", "declutter Docker Desktop", or "a landing page that links my services". Do not use for stateful runtimes that must be operated directly — databases, game servers, bots — link those from the hub instead of proxying them.
- Commits and pushes
In this repository58 other files it gives its agents.
- AGENTS.mdAGENTS.md
- CLAUDE.mdCLAUDE.md
- api-test-suiteSkill
- ascii-artSkill
- caddy-reverse-proxySkill
- changelog-generatorSkill
- color-palette-generatorSkill
- cron-taskSkill
- csv-toolkitSkill
- discord-bot-buildSkill
- dotfiles-manageSkill
- email-sendSkill
- env-config-managerSkill
- excalidraw-diagramSkill
- file-organizerSkill
- forgejo-self-hostSkill
- frontend-design-toolkitSkill
- generate-dockerfileSkill
- gif-searchSkill
- git-backupSkill
- github-actions-ciSkill
- hallmark-readmeSkill
- http-api-testerSkill
- invoice-generatorSkill
- json-formatterSkill
- log-analyzerSkill
- markdown-linterSkill
- markdown-to-pdfSkill
- markdown-to-slidesSkill
- mcp-server-buildSkill
- media-id-mappingSkill
- movie-catalogue-siteSkill
- movie-night-calendarSkill
- ntfy-notifierSkill
- ocr-documentsSkill
- ollama-localSkill
- openapi-generatorSkill
- password-generatorSkill
- pdf-extractSkill
- portfolio-upkeepSkill
- qr-code-generatorSkill
- regex-testerSkill
- resume-builderSkill
- rss-monitorSkill
- searxng-self-hostSkill
- skill-publishSkill
- skill-registry-catalogSkill
- skills-portfolio-scaffoldSkill
- snippet-managerSkill
- sqlite-dashboardSkill
- streaming-provider-embedsSkill
- tailscale-deploySkill
- telegram-bot-buildSkill
- tmdb-metadataSkill
- uptime-kuma-self-hostSkill
- watchlist-syncSkill
- webhook-receiverSkill
- youtube-transcriptSkill
The file
We split this skill into the rules it gives an agent
11 rules. About 0 are common, 3 are distinctive, and 8 are very specific to this project.
≈0
Common · estimate
Many files like this have a rule of this kind.
≈3
Distinctive · estimate
This author's own choices. Worth a look.
8
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Distinctive: line 25
Always correct, zero proxy risk.
- Distinctive: line 43
Do NOT use this for stateful runtimes you must operate directly — databases, game servers, bots.
- Very specific: line 70
The bind-mount sources must exist before
up -d: If ./default.conf does not exist, Docker creates a *directory* with that name and nginx … - Very specific: line 85
Write default.conf — a landing location / plus one block per path-mounted app.
- Very specific: line 86
Write hub/index.html (themed.
- Very specific: line 88
Verify by asserting on content and content-type, not status codes — a misrouted path returns 200 with the wrong body.
- Very specific: line 119
If the proxied apps live in the same compose file, skip host.docker.internal and proxy_pass to the compose service name instead (e.g.
- Very specific: line 181
A. Tell the app it lives under a prefix: Always prefer this. Most apps have a setting (ROOT_URL, base href, --base-path, SUBURL).
- Distinctive: line 185
B. Rewrite the HTML on the way out: when the app has no such setting:
- Very specific: line 290
Healthcheck on a redirecting root: If / 302-redirects, curl -f fails the check.
- Very specific: line 304
sub_filtersilently does nothing on compressed responses: If the upstream gzips, the filter never matches.
≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
docker-umbrella
Overview
Set up one Docker container that fronts every local service: a themed landing page plus routing to each app, optional TLS at the edge, and a health check. The user gets one address instead of a row of ports.
The decision that determines whether this works is how each app is mounted, and it is made per app, not once for the whole hub:
- Link-only — the hub renders a card that links to the app on its own port. Always correct, zero proxy risk. Default to this.
- Own-port proxy — a small nginx that proxies the app at
/on a dedicated port. Correct for any app, including ones with hardcoded absolute paths. - Path-mounted (
/dash/) — one port covers everything, but only works if the app can be told it lives under a prefix. Path-mounting an app that emits root-relative links produces a page that returns200and renders completely unstyled. See "Path mounting" below before choosing this.
Most self-hosted apps fail path mounting. Reach for it only when the app has a
base href / ROOT_URL / --base-path setting, or when you are willing to
rewrite its HTML with sub_filter.
When to Use
- The user runs several local web UIs, dashboards, doc sites, or media servers and wants one address instead of a row of ports.
- "One page for all my apps", "group my containers", "declutter Docker Desktop", "a landing page that links my services".
- They want TLS at the edge or a single health-checked entry point.
- Do NOT use this for stateful runtimes you must operate directly — databases, game servers, bots. Link those from the hub; don't proxy them.
Architecture
- One stock
nginx:alpinecontainer is the front-end. No custom baked image, so edits to the HTML or config show on reload with no rebuild. - The landing page (
/) is bind-mounted HTML that lists every service as a card. - Proxied services are reached by
proxy_passtohost.docker.internal:<port>(host network) or to a compose service name on a shared network. - One published host port replaces one port per service.
- Optional TLS: a Caddy sidecar that terminates and proxies to the umbrella on
80.
:8090 (the hub's own port)
browser ──────────────► umbrella (nginx:alpine)
│ / → themed landing (bind-mounted html)
│ /docs/ → proxy_pass host.docker.internal:9000/
└ /media/ → proxy_pass host.docker.internal:8096/
the hub's port and every backend port must be DIFFERENT — see Pitfall 1Prerequisites
- Docker Engine + Compose v2 (
docker compose version— the v2 space form). - A free host port for the hub, distinct from every backend port:
- Linux/macOS:
ss -ltnp | grep LISTEN - Windows:
netstat -an | findstr LISTENING
- Linux/macOS:
- The bind-mount sources must exist before
up -d. If./default.confdoes not exist, Docker creates a directory with that name and nginx fails to start withis a directory. Createhub/index.htmlanddefault.conffirst. - Backends reachable from the container — either published on the host (reach
them via
host.docker.internal) or on a shared compose network (reach them by service name). - On Docker Desktop,
host.docker.internalresolves automatically. On Linux Engine it does not; add theextra_hostsentry shown below. Including it on Docker Desktop is harmless, so include it always.
Workflow
- Inventory services:
docker ps -a --format '{{.Names}}\t{{.Ports}}'. - Confirm a free host port for the hub that is not one of the backend ports.
- Decide per app: link-only, own-port proxy, or path-mounted.
- Write
docker-compose.yml. - Write
default.conf— a landinglocation /plus one block per path-mounted app. - Write
hub/index.html(themed; cards link to each app). docker compose configto validate, thendocker compose up -d.- Verify by asserting on content and content-type, not status codes — a
misrouted path returns
200with the wrong body. See Verification.
Configuration
docker-compose.yml:
name: umbrella
services:
umbrella:
image: nginx:alpine
container_name: umbrella
ports:
# Host port must not collide with any backend this proxies.
- "8090:80"
volumes:
- ./hub:/usr/share/nginx/html:ro
- ./default.conf:/etc/nginx/conf.d/default.conf:ro
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-fsS", "http://localhost/index.html"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
# Required on Linux Engine; harmless on Docker Desktop.
extra_hosts:
- "host.docker.internal:host-gateway"No version: key — Compose v2 warns the attribute 'version' is obsolete.
If the proxied apps live in the same compose file, skip host.docker.internal
and proxy_pass to the compose service name instead (e.g. http://dashboard:8080).
A service name only resolves if both containers share a network — a backend in a
different compose project is not reachable by name unless you attach the
umbrella to that project's network with a top-level networks: { external: true }.
Routing
Own-port proxy (default, always works)
One small nginx per app, each on its own host port, proxying the app at /.
Nothing rewrites paths, so hardcoded absolute URLs, /login redirects and
WebSocket upgrades all behave exactly as they do direct:
server {
listen 80;
server_name _;
client_max_body_size 0; # large uploads / git push
location / {
proxy_pass http://host.docker.internal:3000; # no trailing slash
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
}$connection_upgrade needs a map at the http level — put it in a file
mounted at /etc/nginx/conf.d/upgrade.conf:
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}Hardcoding proxy_set_header Connection "upgrade" instead sends an upgrade
header on every ordinary request, which breaks keepalive and confuses some
backends. The map sends it only when the client actually asked to upgrade.
Path mounting
location /dash/ + proxy_pass .../ rewrites /dash/foo → backend/foo. The
request reaches the app correctly. The response is the problem: the app emits
links relative to its root — href="/assets/app.css", src="/api/..." — which
the browser resolves against the hub's root, not /dash/. Those URLs miss the
location /dash/ block entirely and fall through to the landing page.
With a typical SPA-style landing (try_files $uri $uri/ /index.html) they do not
even 404: every missing asset returns 200 text/html containing the landing
page. The CSS request receives HTML, so the page renders unstyled and its scripts
throw — while every curl check reports 200.
Two ways to make it work:
A. Tell the app it lives under a prefix. Always prefer this. Most apps have a
setting (ROOT_URL, base href, --base-path, SUBURL). Set it to /dash/,
and the app emits correct links on its own.
B. Rewrite the HTML on the way out when the app has no such setting:
location ^~ /dash/ {
proxy_pass http://host.docker.internal:9000/;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# sub_filter cannot patch a compressed body — force plain text upstream.
proxy_set_header Accept-Encoding "";
sub_filter_once off;
sub_filter 'href="/' 'href="/dash/';
sub_filter 'src="/' 'src="/dash/';
sub_filter "href='/" "href='/dash/";
sub_filter "src='/" "src='/dash/";
}^~ matters: it stops nginx from letting a later regex location win. And drop
the try_files ... /index.html fallback from location / (use index
index.html;) so a genuinely missing asset returns 404 instead of a misleading
200.
This is a patch, not a fix — it only touches HTML bodies, so absolute paths built in JavaScript at runtime still escape. Apps that do that need option A or an own-port proxy.
Subdomain-based
One hostname per service — needs DNS or a wildcard record. Behaves like the
own-port pattern (the app is at /), so it avoids the prefix problem entirely:
server {
listen 80;
server_name dash.example.com;
location / {
proxy_pass http://host.docker.internal:9000;
# same proxy_set_header block as the own-port example
}
}Theming
Set data-theme on <html> and swap CSS custom properties. Ship light / dark
plus one custom palette; persist the choice in localStorage so a refresh keeps
it. A two-button switcher is enough:
<html data-theme="dark">
<head>
<style>
:root, [data-theme="dark"] { --bg:#0e1116; --fg:#e6e6e6; --accent:#6ea8fe; --card:#161b22; }
[data-theme="light"] { --bg:#ffffff; --fg:#1a1a1a; --accent:#2563eb; --card:#f3f4f6; }
[data-theme="custom"] { --bg:#1a1423; --fg:#f3e8ff; --accent:#c084fc; --card:#241a30; }
body { background:var(--bg); color:var(--fg); }
.card { background:var(--card); border-left:3px solid var(--accent); }
</style>
</head>
<body>
<button onclick="setTheme('light')">Light</button>
<button onclick="setTheme('dark')">Dark</button>
<button onclick="setTheme('custom')">Custom</button>
<script>
const saved = localStorage.getItem('theme') || 'dark';
document.documentElement.setAttribute('data-theme', saved);
function setTheme(n){ localStorage.setItem('theme', n); document.documentElement.setAttribute('data-theme', n); }
</script>
</body>Define tokens once (--bg, --fg, --accent, --card) so every card inherits
them. Cards are plain links: <a class="card" href="/dash/">Dashboard</a>.
Common Pitfalls
- The hub proxying itself. Publishing the umbrella on
8080:80and also writingproxy_pass http://host.docker.internal:8080/points the route back at the hub's own published port./dash/then returns200with the landing page — the route looks fine tocurland never reaches the app. Keep the hub's host port distinct from every backend port, and re-read the composeports:line against everyproxy_passbefore starting. - Root-relative links break path mounts. The dominant failure. A path-mounted
app's assets resolve against the hub root and are answered by the landing page
with
200 text/html. See "Path mounting" — fix with the app's own prefix setting,sub_filter, or an own-port proxy. try_files $uri $uri/ /index.htmlmasks every 404. The SPA fallback turns missing assets into200landing-page HTML, which is what makes pitfall 2 so hard to spot. Useindex index.html;on a static hub and reserve the fallback for an actual SPA.nginx:alpineships bothcurlandwget. (Verified onnginx:alpine1.31.3:/usr/bin/curland/usr/bin/wget.) The real trap is thatwgetis the BusyBox applet, not GNU wget — it rejects GNU-only flags such as--versionand--spidersemantics differ, so a healthcheck copied from a Debian example reportsunhealthywhile nginx serves200. Oldernginx:alpinetags shipped nocurlat all, so pin the tag you tested.- Trailing-slash mismatch.
location /dash/withproxy_pass http://host:9000(no slash) preserves/dash/; with a slash it strips it. Match the two deliberately and confirm withcurl -i. - Port conflict on
up -d. A dead container holding the port blocks the bind ("address already in use"). Check listeners first; free the port or pick another. - Stale container holds the name. An exited container with the same
container_nameblocksup -d("Conflict ... already in use").docker rm -f umbrella, then retry. - Missing bind-mount source becomes a directory.
up -dbefore creatingdefault.confmakes Docker create a directory of that name; nginx then fails withis a directory. Delete the directory, create the file, restart. - Healthcheck on a redirecting root. If
/302-redirects,curl -ffails the check. Hit a known-200 path instead (/index.html,/healthz, or an app's/user/login). - Scheme-downgrading redirects behind TLS.
return 301 /foo/;and nginx's automatic directory redirect emithttp://even when the user arrived over HTTPS, bouncing them out of TLS. Write redirects scheme-aware:return 301 $scheme://$http_host/foo/; - Binding
127.0.0.1locks out the LAN."8090:80"publishes on all interfaces;"127.0.0.1:8090:80"does not. Choose deliberately — loopback-only is the right default for anything unauthenticated. - Hot edits to bind-mounts don't reload.
docker compose up -dwill not reload a changeddefault.conf. Always test before reloading, or a syntax error takes the hub down:docker exec umbrella nginx -t && docker exec umbrella nginx -s reload sub_filtersilently does nothing on compressed responses. If the upstream gzips, the filter never matches. Sendproxy_set_header Accept-Encoding "";in any block usingsub_filter.up -dhangs in an agent shell. Some shells trip a long-running-server guard. If it does, run it as a bounded background task instead.
Verification Checklist
A 200 proves almost nothing here — pitfalls 1, 2 and 3 all return 200 with
the wrong body. Assert on what came back.
docker compose configparses with noversionobsolete warningdocker psshowsumbrellaashealthy, notstartingorunhealthy- The hub's published port appears in no
proxy_passline:grep proxy_pass default.confchecked against the composeports:entry - Each route returns the backend's content, not the hub's — e.g.
curl -s http://localhost:8090/dash/ | grep -q "<title>Dashboard", not just a status check - For every path-mounted app, one of its own assets returns the right
content-type:
curl -s -o /dev/null -w '%{http_code} %{content_type}\n' http://localhost:8090/dash/assets/app.cssshows200 text/css—200 text/htmlmeans the landing page answered it - A deliberately bogus path returns
404, not200(proves notry_filesmask) - Landing page renders in an actual browser with every card link working — open the devtools console and confirm no 404s or MIME-type errors
- Config edits were applied with
nginx -t && nginx -s reload, and the reload was confirmed by re-checking a changed route - If TLS terminates at the edge, redirects stay on
https://(pitfall 10)
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
No reviews yet
People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.
- It worked
- Partly
- It didn't
Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

