Self-Hosted Dashboard on a VPS: Homepage, Homarr or Dashy

Published on

Run Homepage, Homarr or Dashy as a start page for your VPS services, open it through an SSH tunnel, and keep API keys and the Docker socket out of reach.

Two cards from a VPS panel: a disk usage bar and a bandwidth meter for incoming and outgoing traffic

Your VPS runs Portainer, n8n and a few more tools, and their ports live in a note somewhere. A start page such as Homepage, Homarr or Dashy puts them on one tab, with live status from each app. It is also a map of everything you run, and with widgets it holds API keys and often a line into Docker. Homepage's documentation warns that with widgets it "can access personal information". So build it as a private page from the first command.

Quick answer: Pick Homepage if you like YAML files, Homarr for drag-and-drop boards with user accounts, Dashy for one small config file. For all three, publish the port on 127.0.0.1 only and open it through an SSH tunnel. Give it Docker access through a read-only socket proxy, not the raw /var/run/docker.sock. Keep API keys where only the server reads them: a Docker secret for Homepage, Homarr's encrypted database, DASHY_ variables with useProxy: true for Dashy. Use a domain only with a login in front.

Homepage, Homarr and Dashy Compared

At the time of writing, the latest releases were Homepage 2.7.0 and Dashy 4.9.0 (both 23 October 2026) and Homarr 2.10.1 (30 October 2026). Each runs as one Docker container:

HomepageHomarrDashy
You configure it inYAML files, Docker labelsThe browser, drag and dropconf.yml or its in-page editor
LoginOptional password or OIDC, since 2.0Built-in users, LDAP, OIDCBuilt-in users, header auth, OIDC
Widget API calls come fromThe Homepage serverThe Homarr serverYour browser, unless the widget uses its proxy
API keys live inConfig files, or files via HOMEPAGE_FILE_Its database, encryptedconf.yml, or DASHY_ variables
Container port300075758080
Memory, per its docsNo figure given500 MBAbout 80 to 120 MB idle

This guide builds Homepage, whose widgets call APIs from the server and read keys from files; Homarr and Dashy follow after Step 4.

Step 1: Run Homepage on 127.0.0.1

Install Docker Engine from Docker's repository (Ubuntu, Debian). The commands here work the same on Ubuntu 24.04 and Debian 12, and a Space-Node VPS gives you KVM with full root access, so nothing here is blocked. Create a folder, plus a shared network for the apps the dashboard will query:

sudo mkdir -p /srv/apps/homepage/config
sudo docker network create dash

Save this as /srv/apps/homepage/compose.yaml:

services:
  dockerproxy:
    image: ghcr.io/tecnativa/docker-socket-proxy:latest
    container_name: dockerproxy
    environment:
      - CONTAINERS=1
      - POST=0
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock:ro
    restart: unless-stopped

  homepage:
    image: ghcr.io/gethomepage/homepage:latest
    container_name: homepage
    ports:
      - "127.0.0.1:3000:3000"
    volumes:
      - ./config:/app/config
    networks:
      - default
      - dash
    restart: unless-stopped

networks:
  dash:
    external: true

Start it from that folder:

cd /srv/apps/homepage
sudo docker compose up -d

The 127.0.0.1: prefix keeps Homepage off the internet. Docker diverts traffic to published ports "before it goes through the ufw firewall settings", so UFW can't close them, but with the loopback address "only the Docker host can access" the port (port publishing; see our Docker and UFW guide). The proxy publishes no port: Homepage reaches it by name on the project's own network, where nothing else runs, as the proxy's README asks. CONTAINERS=1 opens the API section that container status needs, and with POST=0 "only GET and HEAD operations are allowed".

Step 2: Open It Through an SSH Tunnel

On your computer:

ssh -N -L 3000:127.0.0.1:3000 user@203.0.113.10

Browse to http://localhost:3000. -L forwards your local port 3000 over SSH to 127.0.0.1:3000 on the server, and -N runs no remote command (ssh manual). Keep the local port at 3000: since version 1.0, Homepage only answers for hosts in HOMEPAGE_ALLOWED_HOSTS, where localhost:3000 and 127.0.0.1:3000 are always included (installation docs). For several dashboards in one tunnel, or WireGuard and Tailscale for a team, see our dashboard access guide.

Step 3: Add Services, Bookmarks and Container Status

When a config file is missing, Homepage copies in a sample, so ./config fills with services.yaml, bookmarks.yaml, docker.yaml and more. Point docker.yaml at the proxy:

my-docker:
  host: dockerproxy
  port: 2375

Then replace services.yaml:

- Admin:
    - Portainer:
        icon: portainer.png
        href: https://localhost:9443
        description: Docker management
        server: my-docker
        container: portainer

- Automation:
    - n8n:
        icon: n8n.png
        href: http://localhost:5678
        description: Workflows
        server: my-docker
        container: n8n

href is the link your browser opens, so through the tunnel it's a port you forward as well, such as -L 9443:127.0.0.1:9443. server and container add the container's state, read through the proxy, and a click on it shows CPU, memory and network use. container must match a name in the NAMES column of sudo docker ps. Icons load by name from the Dashboard Icons set, and bookmarks.yaml takes plain links in the same group format.

Containers can also list themselves with homepage.* labels, such as homepage.group=Automation (Docker integration). After an edit, the refresh icon at the bottom right clears Homepage's cached data and reloads the page.

Step 4: Add Widgets Without Leaking API Keys

A service widget shows live data from an app's API, such as running and stopped containers from Portainer. Homepage calls those APIs from its server and sends the browser only a whitelist of display fields, so key and url stay on the server (service-helpers.js). That protects the browser, not your config files: a key in services.yaml travels with every backup, Git commit or forum post of that file. Keep it in a root-only file.

In Portainer, click your user name, open My account and add a token under Access tokens. It's shown once and gives "the same level of access to Portainer functionality" as your account (API access), so treat it like your password:

sudo mkdir -m 700 /srv/apps/homepage/secrets
read -rs KEY && printf '%s' "$KEY" | sudo tee /srv/apps/homepage/secrets/portainer_key > /dev/null; unset KEY
sudo chmod 600 /srv/apps/homepage/secrets/portainer_key

Paste the token after the second line and press Enter. read -s keeps it off the screen and out of your shell history, and printf '%s' writes no trailing newline, which matters because Homepage pastes a file's contents into the YAML exactly as they are (config.js).

Add the secret to the homepage service, below container_name:

    environment:
      HOMEPAGE_FILE_PORTAINER_KEY: /run/secrets/portainer_key
    secrets:
      - portainer_key

And at the end of compose.yaml:

secrets:
  portainer_key:
    file: ./secrets/portainer_key

Compose mounts the file read-only at /run/secrets/portainer_key (secrets in Compose). Docker prefers this to environment variables, with which "you risk unintentional information exposure". Homepage replaces {{HOMEPAGE_FILE_PORTAINER_KEY}} in any config file with the file's contents (Docker installation).

Inside Homepage's container, 127.0.0.1 is Homepage itself, so the widget reaches Portainer by name on the shared network, where containers "can communicate using only another container's IP address or name" (docker network connect):

sudo docker network connect dash portainer
cd /srv/apps/homepage && sudo docker compose up -d

For an app that runs from its own compose file, add dash there as an external network. Then add the widget under Portainer in services.yaml:

        widget:
          type: portainer
          url: https://portainer:9443
          env: 1
          key: "{{HOMEPAGE_FILE_PORTAINER_KEY}}"

env is the environment ID at the end of the URL when you open the environment under Environments, such as #!/endpoints/1 (Portainer widget). Homepage doesn't verify certificates on these calls (http.js), so Portainer's self-signed one works. Some widgets need no key at all: the Uptime Kuma widget takes only its URL and a status page slug.

Homarr and Dashy: What Changes

Homarr. This is the compose file from its Docker install page, with the port on 127.0.0.1 and the Docker socket line removed:

services:
  homarr:
    container_name: homarr
    image: ghcr.io/homarr-labs/homarr:latest
    restart: unless-stopped
    volumes:
      - ./homarr/appdata:/appdata
    environment:
      - SECRET_ENCRYPTION_KEY=paste-the-output-of-openssl-rand-hex-32
    ports:
      - "127.0.0.1:7575:7575"

Generate the key with openssl rand -hex 32. It encrypts the secrets in Homarr's database and the container exits without it (environment variables), so back it up with ./homarr/appdata. Until onboarding is done, every visitor lands on it at /init, and it writes the administrator to the database (after the installation), so finish it through a tunnel right away. Integration credentials "are encrypted and used only by the server" (integrations), and "Public boards are readable without signing in", so make a board public only on purpose.

Homarr's Docker page says the socket "grants extensive host control" and to prefer "a least-privilege socket proxy when lifecycle access is not required". Add the same dockerproxy service to Homarr's compose file and set DOCKER_HOSTNAMES=dockerproxy and DOCKER_PORTS=2375. Start, stop and restart actions need POST=1.

Dashy. Everything in its user-data folder is served from the web root, conf.yml included (quick start). Without ENABLE_HTTP_AUTH=true, its login page is "a UI overlay" and "Anyone can fetch /conf.yml, /system-info, /cors-proxy, etc. directly" (built-in auth). So publish it as "127.0.0.1:8080:8080", and set ENABLE_HTTP_AUTH=true if you add users. Widgets call APIs from your browser unless they use Dashy's proxy, and "widget options are sent to the browser". For a key, give the name of a DASHY_ environment variable and turn the proxy on:

- type: weather
  useProxy: true
  options:
    apiKey: DASHY_WEATHER_TOKEN
    city: London

Then set DASHY_WEATHER_TOKEN in the container's environment. Dashy's server swaps in the value when it forwards the request; without useProxy, the placeholder reaches the API unchanged and the call fails (widgets).

If You Want a Domain

On a domain anyone can load the page, so put a login in front of it. With Caddy as the reverse proxy (set up as in several apps on one VPS), run caddy hash-password, type a password, and add a site block to /etc/caddy/Caddyfile:

dash.example.com {
    basicauth {
        admin <hash from caddy hash-password>
    }
    reverse_proxy 127.0.0.1:3000
}

Reload with sudo systemctl reload caddy. Caddy 2.8 renamed the directive to basic_auth (docs); basicauth still works, and it's the only name the Caddy 2.6.2 package in Ubuntu 24.04 and Debian 12 knows. Caddy passes the Host header through unchanged, so add HOMEPAGE_ALLOWED_HOSTS: dash.example.com to Homepage's environment and run sudo docker compose up -d.

Homepage 2.0 added its own password gate (HOMEPAGE_AUTH_ENABLED=true plus HOMEPAGE_AUTH_SECRET, HOMEPAGE_EXTERNAL_URL and HOMEPAGE_AUTH_PASSWORD), but its docs warn it has no rate limiting on password attempts, so use it only as a second lock. With Cloudflare Tunnel, Cloudflare Access can do the login (tunnel or reverse proxy).

Which One Fits

  • Homepage if you're happy editing YAML over SSH and want keys in root-only files, so the config itself can go into Git without them.
  • Homarr if several people use the dashboard or you'd rather build it in the browser. Its docs ask for 500 MB of RAM, and with the socket its admins control Docker.
  • Dashy for a small footprint and one file to back up, as long as every widget key goes through a DASHY_ variable.

If It Still Fails

The page only says "Host validation failed. See logs for more details." sudo docker logs homepage names the rejected host, such as localhost:13000. Tunnel to local port 3000, or add that exact value to HOMEPAGE_ALLOWED_HOSTS (comma separated, no spaces) and run sudo docker compose up -d.

A widget shows an API error. Click API Error Information in the widget. Often the url points at 127.0.0.1 or localhost, which inside the container is Homepage itself, or ends with a slash or an API path, which Homepage's troubleshooting page rules out. Test the name from inside with sudo docker exec homepage ping portainer (Ctrl+C stops it); if it doesn't resolve, connect the app to the dash network.

No container status. server: must match the name in docker.yaml (my-docker), and container: a name from sudo docker ps. The proxy answers blocked calls with "HTTP 403 Forbidden"; status needs CONTAINERS=1.

Homarr shows the onboarding again after a restart. Check that /appdata is mounted, writable and persisted, as its docs advise.

FAQ

Can I leave Homepage public without a login? No. Its docs say public deployments "should be secured via a reverse proxy, VPN, or similar", and the page lists every service and link you configured. Keep each app's own login too: the dashboard's login only guards the dashboard.

Why a socket proxy instead of mounting /var/run/docker.sock in Homepage? Homepage's docs call the direct socket "not the recommended method of integration", and the proxy's README warns that "Giving access to your Docker socket could mean giving root access to your host". Through the proxy with POST=0, Homepage reads container status, and no request through it can start, stop or create a container. Docker's security page is short on this: "only trusted users should be allowed to control your Docker daemon."

How much memory does a dashboard use? Dashy's docs say its server idles around 80 to 120 MB and 256 MB is comfortable; Homarr's ask for 500 MB; Homepage's give no figure. sudo docker stats --no-stream prints each container's memory use once.

How do I update Homepage? From /srv/apps/homepage, run sudo docker compose pull, then sudo docker compose up -d, which recreates the container on the new image. Your settings stay in ./config and ./secrets; to keep copies off the server, see our Docker volumes backup guide.

Sources

All opened on 31 October 2026: