Building a Local Dashboard with Homepage: One Config File to Rule Your Self-Hosted Stack
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
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 insideservices.yamlwhen 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}}"
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/
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
Comments
No comments yet. Start the discussion.