VPS Tutorials
Deploy Beszel on a VPS (2026): Lightweight Server Monitoring, Docker Container Metrics, Alerts and HTTPS
Deploy the lightweight Beszel monitoring panel on a VPS, configure the Hub, Agent, Caddy HTTPS, Docker container metrics and alerts, and complete multi-server onboarding, backup, upgrades and common troubleshooting.

Deploy Beszel on a VPS (2026): Lightweight Server Monitoring, Docker Container Metrics, Alerts and HTTPS
If you just want to see the CPU, memory, disk and Docker container status of a few VPS instances, Prometheus + Grafana often feels a bit heavy; and if you only install Uptime Kuma, you can't see why the host is slowing down. Beszel sits right in between: one Hub management panel plus one Agent per server, and within minutes you can see historical metrics and alerts.
This guide deploys the stable v0.18.7 release from July 2026. The examples use fixed image tags instead of latest, and cover HTTPS, Agent connection direction, backup and troubleshooting all in one go.
The Beszel Hub stores systems, users, historical data and alerts; the Agent collects the CPU, memory, load, disk, network, temperature and Docker/Podman container metrics of the server it runs on.
It suits home labs, small teams and quick monitoring of up to a dozen or so VPS instances. If you need complex PromQL, long-term metric aggregation or custom business metrics, see the VPS Prometheus + Grafana tutorial. If you only care whether a website opens and about a public status page, Uptime Kuma is more direct.
There is also a practical issue: if the Hub and the monitored services run on the same VPS, then when that machine goes fully offline, the Hub can't send notifications either. For important environments, it's best to run the Hub independently, or enable Beszel's external Heartbeat to push status to another monitoring service.
Beszel doesn't officially set a hard minimum configuration. For personal use you can start with 1 core, 1GB of memory and 10GB of free disk; what really affects disk usage is the number of nodes and the history retention time, so don't treat this as a guaranteed value.
Prepare a domain, for example monitor.example.com, with A/AAAA records pointing to the Hub VPS. Open ports according to the connection method:
| Direction | Port | Purpose |
|---|---|---|
| Public to Hub | TCP 80/443 | Caddy certificates and Web management page |
| Hub local | TCP 8090 | Beszel Hub, bound to loopback address only |
| Hub to Agent | TCP 45876 | Only needed for SSH pull mode |
| Agent to Hub | TCP 443 | WebSocket push mode |
For remote VPS instances, WebSocket is recommended by default: the Agent actively connects to the Hub's HTTPS address, so there's no need to expose 45876 to the public internet. If you choose SSH mode, only allow the Agent's 45876 from the Hub IP. Before changing the firewall, you can read the UFW guide to prevent SSH lockout.
Record a baseline before deploying so you can tell from the later curves whether something is abnormal:
free -h
df -h
uptime
docker system df
timedatectl status
If the disk is already nearly full, don't expect the monitoring container to save the day; deal with logs and backups first. The system time must also be accurate, otherwise charts and alert times across multiple VPS instances won't line up. When preparing to monitor more than twenty servers, run a small number of nodes for a week first, then decide disk and retention policy based on the actual growth of beszel_data; that's more reliable than copying someone else's capacity numbers.
First install Docker Engine and the Compose plugin, then create the directory:
sudo mkdir -p /opt/beszel
sudo chown "$USER":"$USER" /opt/beszel
cd /opt/beszel
mkdir -p beszel_data beszel_agent_data beszel_socket
Create a new docker-compose.yml. Keep placeholder values for the Agent for now and only start the Hub the first time:
services:
beszel:
image: henrygd/beszel:0.18.7
container_name: beszel
restart: unless-stopped
environment:
APP_URL: https://monitor.example.com
ports:
- 127.0.0.1:8090:8090
volumes:
- ./beszel_data:/beszel_data
- ./beszel_socket:/beszel_socket
beszel-agent:
image: henrygd/beszel-agent:0.18.7
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: YOUR_SYSTEM_TOKEN
KEY: "YOUR_SYSTEM_PUBLIC_KEY"
Check the configuration and start only the Hub:
docker compose config
docker compose up -d beszel
docker compose ps
docker compose logs --tail=100 beszel
curl -I http://127.0.0.1:8090
You can verify the image version from the official Releases. Change the tag when upgrading; don't let a single rebuild silently cross versions. For production Compose health checks, logging and resource limits, see the Docker Compose production configuration checklist.
The Beszel Agent can connect to the Hub via WebSocket, so the reverse proxy must allow long-lived connections. The Caddy configuration can't just be the shortest one-liner; the official example also sets the request body size and a 360-second read timeout:
monitor.example.com {
request_body {
max_size 10MB
}
reverse_proxy 127.0.0.1:8090 {
transport http {
read_timeout 360s
}
}
}
Validate and reload:
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
curl -I https://monitor.example.com
sudo ss -lntup | grep -E ':443|:8090|:45876'
8090 should only listen on 127.0.0.1. For the full installation process, see the Caddy reverse proxy guide. APP_URL must be the final HTTPS address; it is used for notification links and Agent configuration generation.
Open https://monitor.example.com and create the first account. Beszel is based on PocketBase, and the initial setup creates both a Hub user and a PocketBase superuser; changing the password for the two accounts later does not sync automatically, so it's best to label them separately in your password manager.
Click Add System in the top right, fill in the system name, and set Host/IP to:
/beszel_socket/beszel.sock
The interface will show a Public Key and Token. Replace KEY and TOKEN in the Compose file with them respectively, then start the local Agent:
docker compose up -d beszel-agent
docker compose logs --tail=100 beszel-agent
docker compose ps
Only when the system changes from Pending to Up and the first set of CPU and memory data appears is the connection complete. Don't commit real Keys, Tokens or notification URLs to a Git repository.
The easiest way for remote nodes is to copy the generated Compose from the Hub's Add System page. In WebSocket mode the Agent actively accesses the Hub, and you can use the following structure:
services:
beszel-agent:
image: henrygd/beszel-agent:0.18.7
container_name: beszel-agent
restart: unless-stopped
network_mode: host
volumes:
- ./beszel_agent_data:/var/lib/beszel-agent
- /var/run/docker.sock:/var/run/docker.sock:ro
environment:
HUB_URL: https://monitor.example.com
TOKEN: YOUR_SYSTEM_TOKEN
KEY: "YOUR_SYSTEM_PUBLIC_KEY"
DISABLE_SSH: "true"
DISABLE_SSH=true means using WebSocket only and no longer starting the Agent's SSH service. If you switch to SSH mode, the connection direction is reversed: the Hub actively accesses the Agent's 45876; a strict iptables FORWARD DROP may also block the Hub container's outbound traffic, in which case switching back to WebSocket is easier to troubleshoot.
Each node uses its own system Token; don't copy the same Agent data directory across multiple machines. If the fingerprint changes after a server migration, confirm the target node in the Hub and re-register rather than restarting repeatedly and hoping for the best.
The Agent reads the container list and statistics through the Docker Socket; first compare the output on both sides:
docker ps
docker stats --no-stream
docker compose logs --tail=150 beszel-agent
ls -l /var/run/docker.sock
Common causes are the wrong Socket path, Podman using a different Socket, the Agent not being recreated, or the container being excluded by EXCLUDE_CONTAINERS. After changing the mount, run:
docker compose up -d --force-recreate beszel-agent
:ro only restricts how the filesystem is mounted inside the container; it does not turn the Docker Socket into a low-privilege interface. A process that can access the Socket usually has very high host control capability. Don't expose the Docker API on 0.0.0.0:2375, and don't give the Agent privileged: true just to see metrics.
Open alerts in the system list and first set offline, disk, memory or CPU thresholds. Don't copy thresholds blindly: a database that sits at 80% memory long-term without Swap thrashing is not the same as a web service that suddenly jumps from 30% to 80%.
The notification entry is under Settings > Notifications, and Beszel uses Shoutrrr URLs. For example, a self-hosted ntfy can be written as:
ntfy://:[email protected]/beszel-alerts
After saving, send a test notification first, then briefly lower the threshold of a non-production node to confirm that both alert triggering and recovery are received. The notification URL itself contains credentials, so mask it before sharing screenshots and logs. If the Hub and the notification service are also on the same failing VPS, alerts still can't get out.
Beszel also supports periodically sending a Heartbeat to an external address. Point HEARTBEAT_URL at another Uptime Kuma, Healthchecks.io or similar service, and even if the Hub goes completely offline, the external service can detect the interrupted heartbeat. It is not a replacement for resource alerts, but specifically covers the blind spot of "the monitoring system itself died and nobody knows". After configuring it, stop the Hub in a test environment once to confirm the external alert actually triggers, then restore the service.
The core data is in /opt/beszel/beszel_data. The admin panel supports saving to disk or S3-compatible storage, but you should still regularly do a recoverability test. A simple offline backup can be done like this:
cd /opt/beszel
backup_date="$(date +%F)"
docker compose stop beszel
sudo tar -czf "/var/backups/beszel-${backup_date}.tar.gz" \
beszel_data docker-compose.yml
docker compose start beszel
sudo tar -tzf "/var/backups/beszel-${backup_date}.tar.gz" | head
curl -I https://monitor.example.com
The archive should also be copied to another machine or object storage. For formal operations, you can combine this with the VPS backup and restore drill.
Check Releases before upgrading, and after the backup is complete, change the same version tag for both the Hub and the Agent:
docker compose pull
docker compose up -d
docker compose ps
docker compose logs --tail=100 beszel
docker compose logs --tail=100 beszel-agent
Only end maintenance after the page, one local Agent and one remote Agent are all working normally. If it fails, restore the original image tag; if the new version has already modified the data structure, restore the same pre-upgrade backup rather than just downgrading the image and forcing it.
Do one more round of acceptance before going live: after restarting Docker, the Hub and Agent should recover automatically; shut down one test Agent and the offline alert should arrive as expected; after recovery the status should turn green again; extract the backup on another machine and you should at least see the PocketBase data and Compose file. Just seeing curves on the homepage doesn't mean alerts, persistence and the recovery chain all work.
curl -Iv https://monitor.example.com
sudo journalctl -u caddy -n 100 --no-pager
docker compose logs --tail=150 beszel
Focus on checking DNS, certificates, APP_URL, Caddy's read_timeout, and whether 8090 is still bound to the loopback address.
docker compose logs --tail=200 beszel-agent
curl -I https://monitor.example.com
sudo ss -lntup | grep 45876
In WebSocket mode, check HUB_URL, the Token, the Key and the node fingerprint; in SSH mode, also check the 45876 from Hub to Agent and the forwarding firewall. Don't mix the two modes when troubleshooting.
cd /opt/beszel
docker compose config
du -sh beszel_data
df -h
If the Compose file doesn't mount the host's ./beszel_data to /beszel_data, the historical data is lost when the container is deleted. Before restoring, keep the current state and confirm the backup contents; don't overwrite the only copy directly.
- Want to quickly see multiple servers and Docker resources: choose Beszel;
- Want to monitor URLs, certificate expiry and a public status page: choose Uptime Kuma;
- Want to collect business metrics, write complex queries and scale long-term: choose Prometheus + Grafana.
Beszel's strengths are lightweight deployment and a quick start; its weakness is that custom queries and the ecosystem are not as good as Prometheus. Connect one non-critical VPS first, trigger one test alert, then make an off-machine backup that can be extracted; only after all three steps pass should you add production nodes in bulk.