# Homepage on Docker: a static dashboard that proxies every service API key

> Homepage is a self-hosted dashboard for Docker containers and self-hosted services, configured in YAML, that renders at build time and proxies service calls at runtime. It holds credentials for everything it displays, mounts the Docker socket for container widgets, and its image label and repository licence disagree.

**gethomepage/homepage** — A highly customizable homepage (or startpage / application dashboard) with Docker and service API integrations.

- Repository: https://github.com/gethomepage/homepage
- Website: https://gethomepage.dev
- Stars: 32,886 · Forks: 2,137
- Language: JavaScript
- License: GPL-3.0
- Published: 2026-08-04 · Updated: 2026-08-18 · Language: en
- Canonical page: https://hysenlabs.com/projects/gethomepage-homepage

## The page is generated at build time, the proxy is not

The readme calls the project fully static and says the site is statically generated at build time for instant load times. Half of that holds in a way that matters for deployment, and the other half does not.

Three scripts in package.json decide the shape. Build is `next build --webpack`, start is `next start`, and dev is `next dev`. The Dockerfile takes the standalone output and the static chunk directory from a node:22-slim builder and drops them into a node:22-alpine runtime, then sets PORT to 3000 and exposes it. Generated HTML still needs a running Node process, and that process is the half that will not fit a CDN.

It exists because of the proxying. All API requests to backend services are proxied, so widget data is fetched at request time by the server rather than baked into the generated page. Statically generated shell, live server-side proxy. If your plan was to drop this on any static host or object storage, that is the line to stop at, and no amount of configuration changes it.

One detail sits in the build script. `next build --webpack` picks the webpack bundler explicitly instead of leaving the choice to the framework, and the project runs Next 16.3.3 with React 19.2.8. Builds are pinned to a path that is not the newest one, which costs build time and buys a known toolchain.

## A read-only Docker socket mount is still the Docker API

The compose file mounts one host path: `/var/run/docker.sock:/var/run/docker.sock:ro`, commented as optional and for docker integrations. That single line enables two things, container status and stats, plus automatic service discovery from container labels. The client behind it is dockerode at 5.0.0, called from the server process rather than the browser.

The `:ro` suffix is where readers misjudge the exposure. Read-only stops the container from writing to the socket. It does not narrow which API calls the client may make, and a status widget has to enumerate containers across the host to draw anything at all. For a dashboard that already holds a credential for every service it watches, the socket is a second and broader one.

Removing the line is cheap. The app still starts, container status and label discovery stop working, and every other widget keeps going, which makes this the easiest thing to drop from the whole file. No narrower mode is offered: the project documentation does not describe a socket proxy restricted to read endpoints, nor a way to aim discovery at a remote Docker daemon.

Container status is not Docker-only, either. `@kubernetes/client-node` is a runtime dependency, and kubernetes.md sits at the repository root next to a k3d/ directory and a Dockerfile-tilt, so the same status view is being built out for Kubernetes too.

## HOMEPAGE_ALLOWED_HOSTS is the one variable the compose file calls required

Start from the compose file the project ships. Port 3000 is published, the config directory is bind-mounted to /app/config, and exactly one variable is commented as required.

```yaml
services:
  homepage:
    image: ghcr.io/gethomepage/homepage:latest
    container_name: homepage
    environment:
      HOMEPAGE_ALLOWED_HOSTS: gethomepage.dev # required, may need port. See gethomepage.dev/installation/#homepage_allowed_hosts
      PUID: 1000 # optional, your user id
      PGID: 1000 # optional, your group id
    ports:
      - 3000:3000
    volumes:
      - /path/to/config:/app/config # Make sure your local config directory exists
      - /var/run/docker.sock:/var/run/docker.sock:ro # optional, for docker integrations
    restart: unless-stopped
```

The single-container form carries the same values as flags.

```bash
docker run --name homepage \
  -e HOMEPAGE_ALLOWED_HOSTS=gethomepage.dev \
  -e PUID=1000 \
  -e PGID=1000 \
  -p 3000:3000 \
  -v /path/to/config:/app/config \
  -v /var/run/docker.sock:/var/run/docker.sock:ro \
  --restart unless-stopped \
  ghcr.io/gethomepage/homepage:latest
```

Building it yourself takes a clone, a production bundle, and a start command that sets the same variable with a port appended.

```bash
git clone https://github.com/gethomepage/homepage.git
```

```bash
pnpm install
pnpm build
```

```bash
HOMEPAGE_ALLOWED_HOSTS=gethomepage.dev:1234 pnpm start
```

On a first run you copy the `src/skeleton` directory to `config/` to get example files, which is where the YAML for services and widgets lands. Note the two documented shapes for the same variable: a bare hostname in both container examples, a hostname with `:1234` appended in the source path. The exact rule is deferred to the documentation site, and no default or error text is given, so if the page refuses to load, compare that value against the address you are actually using before looking anywhere else. It is a Host header allowlist, which is the same subject as the first requirement in the security notice. It is not authentication.

## The built-in login is opt-in and answers one question

The security notice has two numbered requirements. The first: if Homepage is reachable from any untrusted network it must sit behind a reverse proxy, or a VPN, enforcing authentication, TLS and strict validation of Host headers. The second is optional, a built-in OIDC flow or a simple password login, marked opt-in and described as a simple authenticated or not guard.

That wording is precise, and the limits are real. Authenticated or not is one bit, not an account system. There are no per-user accounts, no roles, no per-widget permissions, so a household or a shared team either shares one credential or needs a real identity provider in front of the app. next-auth 4.24.15 backs both paths, and the OIDC route assumes the provider already exists somewhere else.

Two consequences follow. Because the login is opt-in, an instance with no extra configuration has none at all, and the only thing between a browser and your home automation data is whatever the reverse proxy does. And because the guard is binary, it cannot express the difference between someone who should see the media server and a guest who should see nothing.

## Every widget turns the container into a credential holder

The security claim in the features list is that all API requests to backend services are proxied, keeping API keys hidden. That is a statement about the browser, and in that direction it holds: the key never ships to the client. What it leaves out is that the server process now holds a key for everything on the page.

Integration runs past 100 services, with Radarr, Sonarr, Lidarr, Plex, Jellyfin, Transmission and qBittorrent named in the readme. The dependency list describes that catalogue better than the prose does. ical.js points at calendar feeds, gamedig and minecraftstatuspinger at game server queries, systeminformation with ping and pretty-bytes at host metrics, urbackup-server-api at a backup server, js-yaml at the configuration format itself. Recharts draws the charts and winston writes the logs. Information providers cover weather, time, date, search and glances.

What that produces is concentration rather than a leak. One exposed container hands over credentials for every service it watches, and the same container may hold the socket described above. Those keys belong to services with their own authentication, and pooled into one volume they become one incident instead of several.

Customization widens the same surface from a different angle. Custom CSS and custom JS are supported, so whoever can write the config directory can run script in the browser of everyone who loads the page. No review step around that is described.

## pnpm is enforced in preinstall, and only Docker freezes the lockfile

package.json opens with a preinstall script, `npx only-allow pnpm`, so an npm install aborts before anything is fetched. The development section says the same in plain words, and the root carries pnpm-lock.yaml and pnpm-workspace.yaml. The effect on anyone whose habits formed around npm is loud rather than subtle.

The image takes a stricter route than the source instructions do. The builder enables corepack, prepares pnpm@latest, and installs with `--frozen-lockfile --prefer-offline`. The from-source path says only `pnpm install`. That difference has teeth: inside the image the lockfile is authoritative and a mismatch fails the build, while a local install may resolve something the lockfile never pinned. Two builds of the same commit can differ, and only one of them is the artifact maintainers publish.

The package manager version is the other half of that gap, since corepack prepares whatever pnpm is current when the image is built. The dependency graph is locked, the tool installing it is not.

The rest of the developer surface is conventional. `pnpm dev` starts the server and the readme points at http://localhost:3000, tests run on vitest with a setup file and a v8 coverage target, and lint is `eslint .` against a flat config at the root.

## The image label says Apache-2.0 and the repository says GPL-3.0

The repository is published under GPL-3.0. The Dockerfile labels the image it builds with `org.opencontainers.image.licenses='Apache-2.0'`. Both strings sit in the source you would read before shipping, and they do not agree.

Image metadata is not decoration. Registry scanners and SBOM tools read these labels, so a wrong licence string propagates into compliance reports for everything built downstream, and the fix means rebuilding and republishing rather than editing a file. Nothing in the project documentation mentions the conflict.

The same label block carries a second mismatch. The documentation label points at the GitHub wiki for this repository, while the readme sends every reader to gethomepage.dev and keeps all configuration answers there. Those are two different places, and the readme never mentions the wiki.

Treat this as a metadata conflict to raise with whoever owns the release, not as a licence question answerable from a Dockerfile. The LICENSE file and the documentation site are where to check before relying on either string.

## The runtime stage starts as root and overwrites HOSTNAME

The runtime stage rewards a close read. It is node:22-alpine, it copies the standalone build and static chunks, and then it adds three packages: su-exec, iputils-ping and shadow. shadow manages users and groups, su-exec switches privileges, and the compose file passes PUID and PGID as environment values. The stage also declares `USER root`. So the container begins as root and the supplied ids are applied afterwards by docker-entrypoint.sh, copied to /usr/local/bin with mode 755. What that script does with the values is not described anywhere in the project documentation.

iputils-ping puts a ping binary in the image. It is not part of the Node runtime, and nothing in the readme says which widget shells out to it, but it would not be installed without a reason.

Then there is `ENV HOSTNAME=::`, which overwrites the hostname Docker injects into every container. Code in the Node process that reads the HOSTNAME variable receives a double colon instead of the container id, which quietly breaks the id-based lookups people write against it. The compose file never sets HOSTNAME, so the image value is what you get.

Two smaller facts. NEXT_TELEMETRY_DISABLED=1 is set in the builder stage, and package.json carries a telemetry script running next telemetry disable. The public version values NEXT_PUBLIC_VERSION, NEXT_PUBLIC_REVISION and NEXT_PUBLIC_BUILDTIME are filled from build arguments inside the image, so a from-source pnpm build leaves them unset. And images are published for AMD64 and ARM64 only, which excludes 32-bit ARM hardware no matter how the rest goes.

## Conclusion

Run it as a container if you want one page showing services you already operate, and back up the config volume, because the whole dashboard is rebuilt from it. Two things to settle before exposing it. First, the image label org.opencontainers.image.licenses says Apache-2.0 while the repository is GPL-3.0, so confirm which terms your distribution needs against the LICENSE file rather than the label. Second, decide about the socket: the compose file mounts /var/run/docker.sock read-only, and a read-only mount is still the Docker API, so drop that line if you do not need container status or label discovery. Anyone needing per-user accounts should put an identity provider in front, because the built-in OIDC and password options are opt-in and answer only authenticated or not.

## FAQ

### how to install homepage

Two paths are documented: a docker compose file and a docker run command, both pulling ghcr.io/gethomepage/homepage:latest with the config directory bind-mounted to /app/config and port 3000 published. From source, the sequence is git clone, pnpm install, pnpm build, copying src/skeleton to config/, then HOMEPAGE_ALLOWED_HOSTS=gethomepage.dev:1234 pnpm start.

### how to install homepage on docker

The compose file marks one variable required, HOMEPAGE_ALLOWED_HOSTS, with a note that it may need a port. The Docker socket line mounting /var/run/docker.sock:/var/run/docker.sock:ro is commented as optional and only needed for the container integrations.

### how to install homepage dashboard

Services and widgets are configured in YAML files under the mounted config directory, and on a first run you populate it by copying the src/skeleton directory to config/. Docker label discovery is the alternative to writing those files by hand. Custom themes, custom CSS and JS, custom layouts, formatting and localization are all supported.

### how to install homepage on synology

Published images cover AMD64 and ARM64, and the readme gives no Synology-specific instructions, so the compose file or the docker run command is the documented route. Nothing in the project documentation covers 32-bit ARM hardware, which older models in that class use.

### how to install homepage on portainer

The readme documents compose and docker run, not Portainer stacks, so the same values carry over: the image, the config volume, port 3000, the optional Docker socket mount and the required HOMEPAGE_ALLOWED_HOSTS variable.

## Sources

- [Official documentation](https://gethomepage.dev)
- [Official README](https://github.com/gethomepage/homepage#readme)
- [Project repository](https://github.com/gethomepage/homepage)
- [Release notes](https://github.com/gethomepage/homepage/releases)

---

Hysen Labs editorial analysis, written from the project's own repository and release notes. Cite the canonical page: https://hysenlabs.com/projects/gethomepage-homepage
