Most monitoring stacks eat more resources than the services they're meant to watch. Prometheus, Grafana, a pile of exporters: for a homelab that's often a sledgehammer to crack a nut. Beszel goes the other way. One container for the dashboard, one small agent (~10 MB RAM) per host. It covers CPU, RAM, disk, network and Docker containers in a single view.
In this post we'll deploy Beszel step by step as a Portainer stack, attach a second host, and set up alerts. By the end you'll have a dashboard that shows what's going on across your servers.
One thing up front, because it shapes the whole setup: I run Beszel on the local network only. No reverse proxy, no public DNS record, no port forward to the outside. I'll explain why, and how I still reach it when I'm away, later in the post.
Prerequisites
- A host running Docker (mine is a NAS that's on 24/7 anyway). A mini PC or Raspberry Pi works just as well.
- Portainer to manage the stacks. If you prefer the command line, you can start every YAML block here directly with
docker compose up -d; the result is the same. - Five quiet minutes.
Step 1: Deploy the hub as a Portainer stack
The hub is the web interface. In Portainer, create a new stack (Stacks → Add stack), give it a name like beszel, and paste in the following Compose:
services:
beszel:
image: henrygd/beszel:latest
container_name: beszel
restart: unless-stopped
environment:
APP_URL: http://192.168.1.10:8090 # internal IP:port where you'll reach the hub
ports:
- 8090:8090
volumes:
- ./beszel_data:/beszel_data
- ./beszel_socket:/beszel_socket
beszel-agent:
image: henrygd/beszel-agent:latest
container_name: beszel-agent
restart: unless-stopped
network_mode: host
volumes:
- ./beszel_agent_data:/var/lib/beszel-agent
- ./beszel_socket:/beszel_socket
- /var/run/docker.sock:/var/run/docker.sock:ro
environment:
LISTEN: /beszel_socket/beszel.sock
HUB_URL: http://localhost:8090
TOKEN: <token>
KEY: "<key>"
Replace 192.168.1.10 with your host's internal IP. Leave the <token> and <key> placeholders for now. The hub generates those values in a moment, and we'll paste them in and redeploy once.
Click Deploy the stack. Portainer pulls the images and starts both containers. Then open http://192.168.1.10:8090 in your browser. On first launch you create an admin account (email and a strong password). That account owns the hub and its settings.
For advanced users: why an agent is already in this stack
Beszel has two parts: the hub (the dashboard) and one agent per monitored system. The agent collects the metrics, the hub displays them. So the hub can see its own host, an agent runs alongside it in the same stack.
The connection here goes over a Unix socket (
/beszel_socket/beszel.sock), notlocalhost. The hub and agent sit in separate container networks, solocalhostwould point nowhere; the shared socket (./beszel_socket, mounted into both containers) gets around that. When you add the system in the web UI, you enter this socket path as the Host / IP.The agent also needs
network_mode: hostto read real network-interface stats, and the read-only Docker socket mount to see per-container CPU and RAM.
Step 2: Connect your first host
After logging in, click Add System in the top right. The dialog shows everything the agent needs: a name, host/port fields, the hub's public key, and a token.
For the local agent from Step 1:
- Copy the public key and the token from the dialog into the
KEYandTOKENvariables of your stack (in Portainer: edit the stack, insert the values). - As Host / IP, enter
/beszel_socket/beszel.sock. - Redeploy the stack.
Within seconds your first host appears in the dashboard, with live graphs for CPU, RAM, disk and network.
Step 3: Monitor a second host
The real payoff comes once several machines share one dashboard. In my case, the hub on the NAS also monitors a second machine (a Linux host that's running anyway).
On the second host you only need the agent, not a second hub. The Compose for it:
services:
beszel-agent:
image: henrygd/beszel-agent:latest
container_name: beszel-agent
restart: unless-stopped
network_mode: host
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
environment:
LISTEN: 45876
KEY: "<key>"
HUB_URL: http://192.168.1.10:8090 # internal address of your hub
TOKEN: <token>
The agent makes an outgoing connection to the hub. That matters: on the monitored host, nothing needs to be opened to the outside. The agent connects to the hub, not the other way around.
For advanced users: universal token vs. per-agent registration
Adding each agent by hand is fine for one or two hosts. Once you run a small fleet, a universal token pays off: one token that lets any new agent self-register on its first connection. Enable it under Settings → Tokens & Fingerprints.
The three agent variables that matter:
KEY: the public key the hub shows.TOKEN: authenticates the agent (from/settings/tokens).HUB_URL: the target of the outgoing WebSocket connection.
LISTEN: 45876is the port for the older direct-connection method (hub → agent). In the token flow (agent → hub) you don't need it open. If the agent log showsunexpected status code: 401, the token is wrong or the universal token isn't enabled. Re-copy it and restart the agent.
Step 4: Set up alerts
A dashboard you have to actively look at won't help you at 3 a.m. Beszel can notify you when thresholds are crossed. For each system you set alerts on CPU, RAM, disk and status (host offline). You can require the value to stay above a threshold for a set duration, so brief load spikes don't page you.
For delivery, Beszel supports plenty of channels (email via SMTP, ntfy, Telegram, Discord and more). That's a topic for its own post. To get started, set the thresholds and add one channel.
Why I keep Beszel off the public internet
Plenty of guides end by putting every service behind a reverse proxy and exposing it on a public subdomain. I don't do that with Beszel, and it's a deliberate choice rather than an oversight.
Monitoring is the inside view of your infrastructure: which hosts exist, how loaded they are, which containers run, when something breaks. That's the kind of information you don't want to hand an attacker for free. The upside of making it public is small, and the added exposure isn't worth it.
So for Beszel: no reverse-proxy entry, no public DNS record, no port forward. The hub listens on the LAN only.
When I want to check it while I'm away, I connect over a VPN into my own network (I use WireGuard) instead of exposing an endpoint. I bring myself onto the network instead of putting the service out on the internet. The external attack surface stays at zero, and I can still reach the dashboard from anywhere.
Not every service belongs behind the public proxy. Before you expose something, ask whether it really needs to be reachable from outside, or whether LAN plus VPN covers it.
For advanced users: if you do want to publish it
Say you want to put Beszel behind your reverse proxy anyway, for example for teammates without VPN access. There's a common gotcha: the dashboard updates live over WebSockets. If you forget to enable WebSocket support on the proxy host, the interface loads halfway and then freezes, with no obvious error.
So WebSocket forwarding is mandatory in the proxy config, and you'll want to secure access on top of it: authentication in front, an IP or geo restriction, a bot or intrusion-protection layer. Even so, for pure self-monitoring I think LAN plus VPN is the cleaner path.
Conclusion
In a few minutes you've got a lightweight monitoring setup: a hub as a Portainer stack, one agent per host, and alerts on the metrics that matter. The whole thing stays on the local network, with VPN access instead of a public door.
You've also seen the basic pattern for deploying any Docker service: container as a stack, configuration through environment variables, and a deliberate decision about reachability. In the next post I'll take a service that should face the outside and show the full path with a reverse proxy and DNS, using the blog you're reading as the example.