Expose a Homelab Grafana Dashboard Without Port Forwarding using rstream

Publish a homelab Grafana dashboard through an outbound rstream tunnel while Prometheus stays private.


Once a homelab service works locally, the next problem is reaching it from outside the local network. A Grafana dashboard may need to open from a phone, a laptop away from home, or a teammate's browser. The safer boundary is an outbound tunnel from the homelab host rather than an inbound rule on the home router. Some homes sit behind carrier NAT or another upstream gateway; others have a usable public address and still prefer to keep the router closed.

rstream handles that reachability layer with an outbound-only connection from the homelab host. The host opens the tunnel to rstream, rstream provides a durable HTTPS entrypoint, and the homelab side only needs outbound connectivity.

This guide applies that model to a small monitoring stack. Grafana becomes the published browser surface, Prometheus stays private on the Docker network and local host, and Docker labels keep the rstream tunnel declaration next to the Compose service that owns the dashboard.

The companion sample keeps the monitoring stack and the tunnel declaration in the same Compose file, so the service that owns the browser UI also declares how it is exposed.

rstream examples / homelab-rstreamOpen the Grafana and Prometheus homelab sample.
git clone https://github.com/rstreamlabs/rstream-examples.git
cd rstream-examples/homelab-rstream

What You Will Build

The sample runs a provisioned Grafana instance with a Prometheus datasource and a starter dashboard. Prometheus scrapes itself and Grafana metrics, then remains reachable from the Docker network and from 127.0.0.1 on the homelab host.

Browser, phone, teammate
        |
        | HTTPS to the rstream endpoint
        v
rstream edge
        |
        | outbound agent session
        v
homelab host
        |
        | 127.0.0.1:13000
        v
Grafana container
        |
        | Docker network
        v
Prometheus container

The operating model matches a small monitoring host where the browser UI is reachable remotely, the metrics backend stays private, and the public access policy lives at the edge. Docker labels keep that model close to the service definition, and the same label vocabulary can later cover Home Assistant, a media admin UI, or a self-hosted app reconciled by the same rstream agent.

Prerequisites

Before starting, make sure the machine that will run the monitoring stack has:

  • Docker with Compose.
  • The rstream CLI.
  • A selected rstream project or a self-hosted engine context.

For hosted rstream projects, published HTTP tunnels work with the capabilities enabled on the project plan. rstream Auth, trusted IP policies, GeoIP policies, and challenge mode are advanced access features. Use them when the project supports them, but keep Grafana's own login enabled either way.

Log in and select the project.

rstream login
rstream project use <project-endpoint> --default

Run Grafana and Prometheus locally

Start by running the sample verification.

make verify

The verification starts an isolated Compose project, waits for Grafana and Prometheus, checks the provisioned datasource and dashboard, then confirms Prometheus can scrape both targets.

After verification, start the stack you will publish through rstream.

GRAFANA_ADMIN_PASSWORD="$(openssl rand -base64 32)" make start

Grafana is available locally at this address.

http://127.0.0.1:13000

Prometheus is available locally at this address.

http://127.0.0.1:19090

Check the local services.

curl -fsS http://127.0.0.1:13000/api/health
curl -fsS 'http://127.0.0.1:19090/api/v1/query?query=up'

Grafana loads the Homelab Monitoring dashboard from provisioning files, and the Prometheus datasource points to http://prometheus:9090 inside the Docker network.

Publish Grafana from Docker Labels

The Compose file runs rstream as a sidecar service and declares the Grafana tunnel on the Grafana container. The forward label points to Grafana's container port, not to the host port, so the tunnel follows the container even if the local bind changes.

services:
  rstream:
    image: ${RSTREAM_IMAGE:-rstream/rstream:latest}
    command: ["run", "--docker", "--watch"]
    restart: unless-stopped
    user: "0:0"
    profiles:
      - rstream
    environment:
      RSTREAM_AUTHENTICATION_TOKEN: ${RSTREAM_AUTHENTICATION_TOKEN:-}
      RSTREAM_ENGINE: ${RSTREAM_ENGINE:-}
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock
    depends_on:
      - grafana
  grafana:
    image: ${GRAFANA_IMAGE:-grafana/grafana-oss:13.0.1-security-01}
    labels:
      rstream.tunnel.grafana.forward: "3000"
      rstream.tunnel.grafana.publish: "true"
      rstream.tunnel.grafana.protocol: "http"
      rstream.tunnel.grafana.host: "${RSTREAM_GRAFANA_HOSTNAME:-}"
      rstream.tunnel.grafana.http.version: "http/1.1"
      rstream.tunnel.grafana.http.auth.rstream: "${RSTREAM_HTTP_AUTH_RSTREAM:-false}"
      rstream.tunnel.grafana.label.env: "homelab"
      rstream.tunnel.grafana.label.service: "grafana"
      rstream.tunnel.grafana.label.site: "${RSTREAM_SITE_LABEL:-home}"
      rstream.tunnel.grafana.label.stack: "monitoring"

Start the reconciler with an engine address and an agent token.

export RSTREAM_ENGINE="<engine-host>:443"
export RSTREAM_AUTHENTICATION_TOKEN="<agent-token>"
make run-docker

The rstream container watches Docker events, reads the labels from Grafana, and keeps the published tunnel aligned with the Compose state. It prints the public URL for Grafana; Prometheus stays reachable only from the Docker network and the local host unless you deliberately add a separate tunnel for an operator workflow.

The direct Docker socket mount gives the reconciler privileged visibility into the Docker host, which can be acceptable on a trusted homelab machine. On a hardened host, use a dedicated Docker access policy or put a restricted Docker socket proxy in front of the daemon.

When testing against an engine running on the Docker host, set RSTREAM_ENGINE_HOSTNAME to the hostname inside RSTREAM_ENGINE. The sample maps that hostname to the Docker host through host-gateway, so TLS keeps the expected server name while the container reaches the local engine.

Use a stable dashboard URL

Grafana uses its root URL for redirects, links, and bookmarks. If your rstream project has a stable domain for this dashboard, put the hostname in the tunnel label and give Grafana the same external origin before the container is created.

export RSTREAM_GRAFANA_HOSTNAME="<your-stable-domain>"
export GRAFANA_ROOT_URL="https://<your-stable-domain>/"
export GRAFANA_ADMIN_PASSWORD="$(openssl rand -base64 32)"
make run-docker

Set those values before Grafana starts. If you change the root URL after the first run, recreate the Grafana container so the provisioned server settings are applied consistently.

Choose the access policy

Grafana is a browser application, so the default protection should match browser behavior. Keep Grafana authentication enabled, disable anonymous access, use a strong admin password, and add rstream Auth at the edge when the project supports it.

Use the same engine and agent token as the reconciler, then enable rstream Auth on the Grafana tunnel label.

RSTREAM_ENGINE="<engine-host>:443" \
  RSTREAM_AUTHENTICATION_TOKEN="<agent-token>" \
  RSTREAM_HTTP_AUTH_RSTREAM=true \
  make run-docker

If RSTREAM_ENGINE and RSTREAM_AUTHENTICATION_TOKEN are already exported in the shell, only RSTREAM_HTTP_AUTH_RSTREAM=true changes the tunnel policy.

Token authentication fits APIs, automation, and machine clients. A Grafana browser session loads HTML, JavaScript, CSS, API calls, and websockets, so rstream Auth or Grafana login is a better fit for human browser access.

Trusted IP policies fit dashboards that should open only from an office, a CI network, or an admin subnet. Grafana still owns its users, teams, roles, sessions, and audit trail after traffic reaches the container.

Extend the pattern

The same label vocabulary works for other browser services.

labels:
  rstream.tunnel.home-assistant.forward: "8123"
  rstream.tunnel.home-assistant.publish: "true"
  rstream.tunnel.home-assistant.protocol: "http"
  rstream.tunnel.home-assistant.http.version: "http/1.1"
  rstream.tunnel.home-assistant.http.auth.rstream: "true"
  rstream.tunnel.home-assistant.label.env: "homelab"
  rstream.tunnel.home-assistant.label.service: "home-assistant"
  rstream.tunnel.home-assistant.label.site: "home"

Keep each application responsible for its own users and permissions, and use rstream for the public entrypoint, edge policy, tunnel inventory, and outbound-only connectivity.

Use labels as inventory

List homelab tunnels.

rstream tunnel list --filter 'labels.env=homelab' -o json |
  jq -r '.[] | [.name, .labels.site, .labels.stack, .labels.service, .status, (.hostname // "")] | @tsv'

List monitoring services only.

rstream tunnel list --filter 'labels.env=homelab,labels.stack=monitoring' -o json |
  jq -r '.[] | [.name, .labels.site, .labels.service, (.hostname // "")] | @tsv'

A compact label taxonomy makes the tunnel inventory practical.

LabelExampleUse
sitehome, office, lab-sfGroup services by physical or logical location.
stackmonitoring, home-automationGroup related services.
servicegrafana, home-assistantIdentify the browser surface or upstream service.
envhomelab, prod, stagingKeep personal, lab, and production views apart.
ownerops, platform, mediaRoute responsibility in dashboards and scripts.

These labels can feed cleanup jobs, access reviews, dashboards, or a small internal portal without hardcoding generated tunnel hostnames.

Clean up

When you are done with the local sample, clean up the stack.

make clean