DEV Community

우병수
우병수

Posted on • Originally published at techdigestor.com

Building a Local Dashboard with Homepage: One Config File to Rule Your Self-Hosted Stack

TL;DR: The moment you cross about a dozen self-hosted services, browser bookmarks stop working as an ops strategy. You end up with a mental map that looks something like this: Ollama is on :11434, n8n is on :5678, Grafana on :3000, Portainer on :9000, your WordPress instance so

📖 Reading time: ~22 min

What's in this article

  1. The Problem: Thirty Services, Zero Visibility
  2. What Homepage Actually Is (and What It Isn't)
  3. Getting It Running: Docker Compose and Directory Layout
  4. Configuring Services, Widgets, and Integrations
  5. Putting It Behind a Reverse Proxy with Auth
  6. Non-Obvious Behaviors That Will Cost You Time
  7. When Homepage Earns Its Place (and When It Doesn't)

The Problem: Thirty Services, Zero Visibility

The moment you cross about a dozen self-hosted services, browser bookmarks stop working as an ops strategy. You end up with a mental map that looks something like this: Ollama is on :11434, n8n is on :5678, Grafana on :3000, Portainer on :9000, your WordPress instance somewhere behind an nginx proxy, and three other things you added last month and have already half-forgotten. None of those tabs tell you whether the service is actually responding. A bookmark to http://localhost:8080 is equally useful whether the container is healthy or crashed three hours ago.

That gap between "has a port" and "is actually running" is where silent failures live. My n8n flows pull from a local embedding API, send results to a Postgres instance, and occasionally hit Ollama for inference. If any one of those services dies quietly — no alert, no log you happened to be watching — the pipeline just stops producing output. Without a dashboard showing live health checks, the failure mode is: you notice something downstream is wrong, you start manually curling endpoints, you eventually find the dead container. That whole process takes ten to thirty minutes. A dashboard with a working health probe turns it into a ten-second glance.

The alternatives are real options worth knowing. Heimdall is polished and has a decent app library, but it stores all its state in a SQLite database you have to back up and restore manually on a rebuild. Flame is minimal and fast but the integration ecosystem is thin — you get links and icons, not live service metrics. Dasherr is clean for simple setups. Homepage wins the comparison specifically because your entire configuration lives in YAML files that you can version-control and copy verbatim onto a new machine. There's no GUI state to recreate. Rebuilding the host means docker compose up -d plus your config directory, and you're back to exactly where you were. That property matters more the more services you're running, because the dashboard itself becomes infrastructure, not a convenience.

Homepage also ships with first-party integrations for the services that actually matter in a self-hosted stack — Portainer, Sonarr, Radarr, Proxmox, various *arr apps, and generic API widgets for anything custom. Those integrations pull live data, not just link out. The difference between a link tile and a widget showing current queue depth or CPU utilization is the difference between a bookmark manager and an actual operator panel. For context on how a dashboard fits into a broader self-hosted automation workflow, see our guide on Workflow Automation in 2026: n8n, Zapier, and Self-Hosted Pipelines.

What Homepage Actually Is (and What It Isn't)

Homepage reads YAML files off disk and renders a dashboard — no database, no migrations, no state to back up beyond the config directory. That sounds like a minor implementation detail until you realize it means your entire dashboard is just a folder you can commit to git, rsync to a new host, or roll back with git checkout. The "static-feeling" part of the description is apt: the Next.js app does server-side rendering but feels like a static page to the user because there's no session, no login wall (by default), and no write path through the UI. You configure it entirely by editing files.

The integration model is polling, not proxying. When you add a Sonarr widget, Homepage makes an authenticated GET request from the server side to your Sonarr API using the key you supply in the config. It renders the result into the widget. That's it. No traffic flows through Homepage to reach Sonarr — a browser tab to Sonarr still goes directly to Sonarr. This matters for two reasons: widget data can be stale by a few seconds (the poll interval is configurable but defaults are conservative), and if your Sonarr instance is on an internal network segment unreachable from the Homepage container, the widget will silently fail rather than route around it. The network path is container → target service, not browser → Homepage → target service.

The most common setup mistake I see documented in forums: people deploy Homepage and then expect it to handle reverse proxying or authentication. It does neither. Homepage has no concept of protecting a downstream service, issuing tokens, or acting as an ingress point. If you want sonarr.yourdomain.com to require a login, that's a job for Nginx Proxy Manager, Traefik, or Authelia — Homepage just shows you a link and a widget. Conflating these tools leads to configs where Homepage is exposed to the internet with no auth layer in front of it, which is a real exposure if you're embedding API keys in the widget data that gets rendered client-side. Keep Homepage on an internal interface or behind an auth-aware reverse proxy.

Resource footprint is genuinely light. The container sits at roughly 80–120 MB RSS at idle on my workstation, CPU is near zero between renders, and the image pulls at around 200 MB compressed. You can co-locate it comfortably on a host running heavier workloads — it won't compete with Ollama for RAM or a Postgres instance for I/O. The one thing that can spike memory is loading a dashboard with many widgets that all poll simultaneously on startup; that settles within a few seconds. For a Raspberry Pi 4 or a low-spec VPS, this is one of the few dashboard options that won't feel punishing.

Getting It Running: Docker Compose and Directory Layout

The first surprise for most people deploying Homepage is that there's no database, no migration step, and no admin UI to configure. Everything is flat YAML files in a single config directory. That's a feature, not a limitation — it means your entire dashboard is version-controllable and reproducible from scratch in under a minute. But it also means if you skip the directory layout step, Homepage silently renders a blank page with zero indication of what went wrong.

Pin the image. ghcr.io/gethomepage/homepage:latest will work, but a specific tag like v0.9.2 means you won't wake up to a broken dashboard after an unattended pull. Here's a compose file that covers the essentials:

services:
  homepage:
    image: ghcr.io/gethomepage/homepage:v0.9.2
    container_name: homepage
    ports:
      - "3000:3000"
    volumes:
      # config directory must exist on host before first boot
      - /opt/homepage/config:/app/config
      # optional: drop custom PNG/SVG icons here, reference as /icons/myapp.png
      - /opt/homepage/icons:/app/public/icons
      # socket mount — read the trade-off discussion below before enabling this
      - /var/run/docker.sock:/var/run/docker.sock:ro
    environment:
      # "stdout" keeps logs in docker logs output; "file" writes to /app/config/logs/
      LOG_TARGETS: stdout
      # set your local timezone or widget times will be UTC
      TZ: America/New_York
    restart: unless-stopped
Enter fullscreen mode Exit fullscreen mode

Before docker compose up does anything useful, the five config files need to exist. Homepage won't create them. Each one owns a distinct slice of the UI:

  • services.yaml — the main grid of service cards, grouped into named sections. This is where your Jellyfin, Grafana, and Gitea entries live.
  • bookmarks.yaml — a separate column of plain links with no status checks. Good for external URLs you want nearby without the overhead of a widget.
  • widgets.yaml — top-bar info widgets: system stats, weather, search bar, date/time. Configured independently from service cards.
  • settings.yaml — global layout options: color theme, column count, background image, favicon, custom CSS injection point.
  • docker.yaml — connection definitions for Docker hosts (local socket or remote TCP). Homepage references these by name inside services.yaml when doing container auto-discovery.

Create all five as empty files before first boot: touch /opt/homepage/config/{services,bookmarks,widgets,settings,docker}.yaml. Homepage parses them on startup and on every file change via a filesystem watcher — no restart required when you edit. If any file has a YAML syntax error, the watcher logs the parse failure to stdout but continues serving the last valid state. That's why checking logs immediately after first boot matters: docker logs homepage --follow for the first 30 seconds will surface any parse errors before you start adding real entries and wondering why nothing appears.

The Docker socket mount is the sharpest edge in this setup. Mounting /var/run/docker.sock read-only gives Homepage the ability to query container metadata, which powers the label-based auto-discovery (homepage.name, homepage.href, etc.). Read-only helps, but the socket itself has no concept of read-only enforcement at the API level — a process with socket access can issue any Docker API call regardless of the mount flag. That's root-equivalent on the host. For a homelab running on a single machine where you trust everything in the stack, the risk is contained. For anything exposed to the network or running untrusted containers, the mitigation worth deploying is tecnativa/docker-socket-proxy. It runs a slim HAProxy instance that sits between Homepage and the real socket, whitelisting only the API endpoints Homepage actually needs (GET /containers, essentially). You'd replace the socket mount with a TCP connection to the proxy container in docker.yaml. The proxy itself gets the socket mount and runs with network_mode: none so it can't initiate outbound connections. It's two extra lines in compose and meaningfully reduces the blast radius if Homepage ever has a supply-chain issue.

First-boot verification is a three-step check: hit http://localhost:3000 and confirm the default Homepage layout renders (it should show empty sections if your YAML files are valid but empty), run docker logs homepage 2>&1 | grep -i error to confirm zero parse failures, and if you mounted the socket, run docker logs homepage 2>&1 | grep -i docker to confirm it connected to the Docker API successfully. If the page loads but is completely blank rather than showing an empty layout, the most common cause is a missing config directory or a permissions mismatch — Homepage runs as UID 1000 by default, so chown -R 1000:1000 /opt/homepage/config fixes it without needing to add user: overrides to the compose file.

Configuring Services, Widgets, and Integrations

The gap between Homepage looking like a browser start page and actually functioning as a live operations panel is almost entirely in services.yaml. Most guides stop at showing icons in a grid. This one doesn't.

services.yaml Anatomy

The file is a YAML list of groups. Each group is a named key containing a list of service objects. That hierarchy — group → services — is the whole structure. Here's a concrete example with n8n and Portainer that actually works:

- Automation:
    - n8n:
        href: http://192.168.1.10:5678
        description: "Workflow automation engine"
        icon: n8n.png
        server: my-docker
        container: n8n

    - Portainer:
        href: http://192.168.1.10:9000
        description: "Docker management UI"
        icon: portainer.png
        widget:
          type: portainer
          url: http://portainer:9000
          env: 1
          # env refers to the Portainer environment ID, not an env var
          key: "{{HOMEPAGE_VAR_PORTAINER_KEY}}"
Enter fullscreen mode Exit fullscreen mode

The icon field accepts either a slug from the dashboard-icons repo (just the filename like n8n.png) or a path like /icons/custom-thing.png if you've mounted a local directory. Homepage resolves the slug against its bundled icon CDN automatically. The server and container fields on the n8n entry enable the Docker integration — if you've configured a Docker socket in docker.yaml, the tile will show the container's running/stopped state without any widget block.

Uptime Kuma Widget Deep-Dive

The Uptime Kuma widget is one of the cleaner first-party integrations. It hits the Kuma API and surfaces up/down monitor counts plus average response time across all monitors in a given status page slug. The config looks like this:

- Monitoring:
    - Uptime Kuma:
        href: http://192.168.1.10:3001
        description: "Service uptime monitoring"
        icon: uptime-kuma.png
        widget:
          type: uptimekuma
          url: http://uptime-kuma:3001
          slug: homelab
          # "slug" must match the status page slug you created in Kuma's UI
          # under Status Pages → your page → the URL segment after /status/
Enter fullscreen mode Exit fullscreen mode

There is no API key field on this widget — Kuma's status page endpoint is intentionally public if you've created a status page. The slug field is the part people get wrong most often: it's not the monitor name, it's the URL slug of a status page you've set up inside Kuma. Go to Status Pages in the Kuma UI, create one, add your monitors to it, and use whatever you put in the "path" field. The widget will then display total monitors, how many are up, how many are down, and aggregate response time. If it shows zeros or errors, the slug doesn't match or the status page is set to private.

Connecting Homepage to Ollama via Custom API Widget

There is no first-party Ollama widget in Homepage as of early 2026. The right move is the customapi widget type, which does a GET against any JSON endpoint and lets you extract fields with a selector. Ollama's /api/tags endpoint returns a JSON object with a models array — one entry per model currently pulled. You can surface the count like this:

- AI:
    - Ollama:
        href: http://192.168.1.10:11434
        description: "Local LLM runtime"
        icon: ollama.png
        widget:
          type: customapi
          url: http://ollama:11434/api/tags
          refreshInterval: 30000
          # milliseconds — 30s is fine, model list doesn't change constantly
          mappings:
            - field:
                models: length
              label: Models Loaded
              format: number
Enter fullscreen mode Exit fullscreen mode

The field block navigates the JSON response. models: length tells Homepage to take the models array and return its length rather than trying to display the array itself. If you want to go deeper — say, show the name of the first loaded model — you'd use models: 0 as a nested accessor and then name under it, but the length display is the most useful at a glance. The refreshInterval is in milliseconds; the default without it is around 10 seconds, which hammers Ollama's API needlessly.

Environment Variable Substitution

Homepage supports {{HOMEPAGE_VAR_*}} substitution in all YAML config files. Any variable prefixed exactly with HOMEPAGE_VAR_ in the environment gets injected at parse time. This keeps API keys out of committed config. The pattern in practice:

# .env file alongside your docker-compose.yaml
HOMEPAGE_VAR_PORTAINER_KEY=ptr_abc123yourkeyhere
HOMEPAGE_VAR_UPTIME_KUMA_TOKEN=uk1_notneededforthiswidgetbutshownforpattern
Enter fullscreen mode Exit fullscreen mode
# docker-compose.yaml snippet — the volume mount is required
services:
  homepage:
    image: ghcr.io/gethomepage/homepage:latest
    env_file:
      - .env
    volumes:
      - ./config:/app/config
      # Homepage reads HOMEPAGE_VAR_* from the process environment,
      # NOT from a mounted .env — the env_file directive loads them
      # into the container's environment at startup
Enter fullscreen mode Exit fullscreen mode

A common failure mode here: people mount the .env file as a volume file instead of using env_file, then wonder why substitution doesn't work. Homepage's variable substitution reads from process environment variables, not from files on disk. The env_file key in Compose handles the injection correctly. Also worth knowing: if the variable is missing at startup, Homepage renders the literal string {{HOMEPAGE_VAR_PORTAINER_KEY}} in the config — which will cause the widget to fail silently with an auth error rather than a parse error, so check your container logs rather than the browser console when debugging.

Putting It Behind a Reverse Proxy with Auth

Homepage ships with zero authentication. That's not a criticism — it's a deliberate design choice that puts the auth problem exactly where it belongs: at the proxy layer. The problem is that a lot of people skip that step because it's on a "trusted" home network. Your LAN is not as flat as you think. Any device on it — a smart TV, a guest phone, a compromised IoT gadget — can hit an unauthenticated Homepage instance and immediately read your entire service topology, plus whatever API tokens you've embedded in widget configs. Those tokens are served inline in the page HTML. They're not hidden behind a JS variable or an env lookup at runtime; they're in the source. One curl command from any device on your subnet and someone has your Portainer token, your Grafana service account key, and your Sonarr API key.

Nginx Proxy Manager is the path of least resistance here. The proxy host setup for Homepage is standard except for one toggle that catches people off guard: WebSockets Support must be enabled. Homepage uses WebSocket connections to stream live widget data — CPU graphs, download speeds, container states. Without it, the page loads but widgets silently fail to update and you end up staring at stale numbers with no error message explaining why. Set the proxy host to forward to your Homepage container on port 3000, flip the WebSockets toggle, and set your custom locations to /. For basic protection without a full SSO stack, NPM's Access List feature lets you allowlist specific IP ranges — useful if you want to lock the dashboard to a single machine's IP or a narrow subnet:

# NPM Access List entry — blocks everything except your admin workstation
# and the local subnet you actually use for management
Allow: 192.168.1.50    # your main workstation
Allow: 10.0.0.0/24    # management VLAN if you have one
Deny: all
Enter fullscreen mode Exit fullscreen mode

IP allowlisting is a speed bump, not a wall — it fails the moment you're on the wrong device or VLAN. For real auth, Authelia and Authentik both work cleanly with Homepage, and the integration requires exactly zero Homepage-side configuration. Both systems operate as forward-auth middleware at the proxy level. In Nginx Proxy Manager with Authelia, you add a single snippet to the Advanced tab of the proxy host:

# NPM Advanced tab — Authelia forward auth for Homepage
# Authelia must be reachable at this internal URL from the proxy container
auth_request /authelia;
auth_request_set $target_url $scheme://$http_host$request_uri;
error_page 401 =302 https://auth.home.arpa/?rd=$target_url;

location = /authelia {
    internal;
    proxy_pass http://authelia:9091/api/verify;
    proxy_pass_request_body off;
    proxy_set_header Content-Length "";
    proxy_set_header X-Original-URL $scheme://$http_host$request_uri;
    proxy_set_header X-Forwarded-Method $request_method;
}
Enter fullscreen mode Exit fullscreen mode

One thing worth being explicit about: neither Authelia nor Authentik gives you per-user views inside Homepage. The dashboard has no user model. Auth here means "authenticated users see everything or nothing" — there's no concept of showing the media stack to one user and the infrastructure widgets to another. If that's a hard requirement, you'd need to run separate Homepage instances with separate configs. Most home lab operators don't need that granularity, but it's a gap to know about before you build around it.

Completing the setup properly means getting TLS onto an internal domain without opening port 80 to the internet. The correct approach is a DNS-01 ACME challenge. If your domain is managed through Cloudflare, NPM has native Cloudflare DNS challenge support — you provide an API token scoped to DNS edit permissions for the target zone, and NPM requests a wildcard cert from Let's Encrypt entirely over DNS. No inbound HTTP required. Use a real registered domain with an internal subdomain, or a home.arpa subdomain if you're comfortable managing your own CA for it. A domain like dashboard.home.arpa resolves to an RFC 8375-compliant private address — but Let's Encrypt won't issue for .arpa, so in practice most operators use something like dashboard.yourdomain.com with a private A record pointing to an internal IP. The DNS-01 flow handles cert issuance and renewal without the domain ever needing to be publicly reachable, and you end up with a valid browser-trusted cert on a dashboard that never leaves your network.

Non-Obvious Behaviors That Will Cost You Time

The most expensive mistake you'll make with Homepage isn't misconfiguring a widget — it's trusting the UI to tell you something went wrong. It won't. Drop a tab character into a YAML block, nest a widget section at the wrong depth, forget a space after a colon — Homepage silently discards the entire widget block and renders the card as a plain label with no data. The page looks fine. Nothing is red. The only way to catch it is docker logs homepage after every config change, not just a browser refresh. The parser errors do show up in the container logs, just not anywhere a user would think to look first.

# After any services.yaml or widgets.yaml edit, run this immediately
docker logs homepage --tail=50 2>&1 | grep -i "error\|warn\|failed\|parse"

# If you see something like:
# Error: bad indentation of a mapping entry at line 14, column 5
# that's your discarded widget block — the card above it in the UI looks fine
Enter fullscreen mode Exit fullscreen mode

The polling interval is fixed at approximately 30 seconds and is not adjustable on a per-widget basis in the current release. This is a deliberate architectural choice, not an oversight, and there's an open GitHub discussion tracking the demand for configurable intervals (github.com/gethomepage/homepage/discussions/1599). If you're building a panel to watch a transcoding queue or monitor a replication lag, Homepage will feel sluggish — you'll be staring at 29-second-old numbers and not know it. For anything requiring sub-minute freshness, route that widget to Grafana or a custom status page. Homepage is the right tool for ambient awareness, not operational monitoring.

Icon resolution has a specific lookup order that bites air-gapped installs hard: Homepage checks your local /icons/ mount first, then falls back to the dashboard-icons CDN at https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons. If your instance sits behind a strict egress firewall or has no outbound access at all, every icon that isn't locally present silently 404s — no broken image indicator, just an empty space where the icon should be. The fix is to pre-populate the local mount before deployment. The dashboard-icons repo is public and mirrors cleanly.

# Clone the icon pack locally and mount it into the container
git clone --depth=1 https://github.com/walkxcode/dashboard-icons.git /opt/homepage/icons

# In your docker-compose.yml:
volumes:
  - /opt/homepage/icons/png:/app/public/icons  # Homepage expects flat PNGs here

# Reference in services.yaml:
- Sonarr:
    icon: sonarr.png  # resolves from local mount, never hits CDN
Enter fullscreen mode Exit fullscreen mode

The Docker label auto-discovery capitalisation trap is subtle and will produce confusing duplicate groups that look intentional. The homepage.group label is case-sensitive, so Media and media produce two separate sidebar groups — Homepage doesn't normalize them. If you're running more than a handful of Compose stacks written at different times, drift happens. The only real fix is a convention enforced at the Compose file level from the start: pick Title Case or lowercase and document it in a comment at the top of every stack file. Once you have duplicate groups in the UI, you can't merge them from the dashboard — you have to hunt down every label mismatch across all your Compose files manually.

# Enforce this pattern across all Compose stacks — pick one, never mix
labels:
  homepage.group: Media          # Title Case — pick this OR lowercase, not both
  homepage.name: Sonarr
  homepage.icon: sonarr.png
  homepage.href: http://sonarr:8989

# Quick audit command to surface capitalisation drift across stacks:
grep -r "homepage.group" /opt/stacks/ | awk -F'=' '{print $2}' | sort | uniq -c | sort -rn
# Any group with count > 1 in different cases = a split group in your UI
Enter fullscreen mode Exit fullscreen mode

When Homepage Earns Its Place (and When It Doesn't)

The clearest signal that Homepage fits your setup is when you've survived a host rebuild and realized how much time you wasted re-entering service URLs, icons, and widget configs by hand. If your dashboard state lives in a browser's local storage or a SQLite file you forgot to back up, the next disk failure takes everything with it. Homepage stores every piece of configuration — services, bookmarks, widget credentials, layout — in plain YAML files that you can commit to a private Git repo and never think about again. That's not a feature worth burying in a bullet list; it's the architectural decision that makes everything else about Homepage worth tolerating.

Homepage makes sense as your primary dashboard when these conditions are true for your setup:

  • Config portability matters more than features: All state lives in services.yaml, bookmarks.yaml, docker.yaml, widgets.yaml, and settings.yaml. Version-control those five files and a full restore is literally git clone followed by docker compose up. No re-entering 30 service URLs, no lost icon customizations.
  • You want native integrations without glue code: Homepage ships with built-in widgets for Sonarr, Radarr, Jellyfin, Proxmox, Portainer, Nextcloud, Adguard Home, Unifi, and a few dozen others. Each one talks directly to the service's API using credentials you drop into services.yaml. The alternative is building API polling yourself — which you don't want to do for 15 different services.
  • You're the only operator: Homepage has no concept of users, roles, or visibility rules. It renders the same dashboard to anyone who can reach the URL. For a single person running a home lab behind Tailscale or a VPN, that's fine — it's actually a simplification. For anything else, it's a hard wall.

The situations where Homepage actively gets in your way are just as specific. If you need per-user views — say, a family member should see Jellyfin and nothing else while you see the whole stack — Homepage cannot do that. Grafana with its folder permissions and dashboard-level auth is the better fit there. If you need real-time metric graphs with sub-second resolution, Homepage's widget polling (which runs on a configurable interval, minimum a few seconds) will feel sluggish and incomplete compared to a Prometheus scrape feeding into Grafana panels. And if your goal is to put an authentication wall in front of all your services from one place, that's Authelia or Authentik's job — Homepage has no SSO, no OAuth proxy, no session management. Treating it as an auth layer is a mistake that'll bite you the moment you expose anything to the public internet.

The config-as-code payoff is concrete enough to show directly. My full working dashboard state is five files totaling under 300 lines of YAML, sitting in a private repo. After a fresh Debian install on a replacement drive, the restore sequence is:

git clone git@github.com:youruser/homelab-configs.git ~/configs
cd ~/configs/homepage
docker compose up -d
Enter fullscreen mode Exit fullscreen mode

That's it. Every service tile, every widget credential, every custom icon path, every group label — back in under two minutes, with zero manual re-entry. The compose file mounts the cloned directory directly into the container, so there's no import step and no database to restore. Compare that to any dashboard that stores config in a browser session or an embedded database you have to remember to snapshot, and the trade-off becomes obvious.


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)