DEV Community

Cover image for Hardening a Beszel Hub Without Giving It the Docker Socket
Hannah Schmidt
Hannah Schmidt

Posted on

Hardening a Beszel Hub Without Giving It the Docker Socket

Hardening a Beszel Hub Without Giving It the Docker Socket

It's 3:00 AM. Your monitoring dashboard just got compromised through an unpatched dependency. Five minutes later, the attacker has root on every Docker host because you mounted /var/run/docker.sock into the hub container six months ago to "make container metrics work." This is not a theoretical exercise—it's the inevitable outcome of treating monitoring infrastructure as low-risk simply because it's internal.

Evaluating henrygd/beszel, the architectural boundary I care about is straightforward: the hub stores history and serves the dashboard; agents collect measurements from monitored machines. Docker statistics belong at the collection boundary, not inside the dashboard container. The developer pain point is granting host-level authority simply to display metrics. A tutorial that mounts the Docker socket into every monitoring component may work immediately, but it creates dangerous coupling between an internet-facing application and the container runtime.

The Failure Is Authority, Not Just UID

A root process inside a container does not automatically have unrestricted host access. The serious failure occurs when excessive privileges, writable host mounts, runtime vulnerabilities, or Docker API access turn an application compromise into host control.

The Docker socket is particularly deceptive. Mounting it with :ro protects the mount from filesystem modification; it does not make Docker API requests read-only. An attacker with sufficient API access can create a privileged container mounting the host filesystem.

For Beszel, I keep the hub entirely separate from that authority. Run collectors on monitored hosts, restrict their listening ports to the hub, and grant Docker access only where container statistics are actually required. A host-native agent remains privileged infrastructure if its account can access Docker; moving it outside a container does not remove that risk.

The following configuration hardens the hub. It deliberately does not install or enroll agents.

A Reproducible Hub Boundary

This single shell block creates a multi-stage Go build, a non-root runtime, and docker-compose.yml. It requires an explicitly selected upstream image digest rather than silently following latest.

The tiny Go binary supplies an HTTP healthcheck without adding a shell or package manager to the runtime. BuildKit caches module metadata and compiler outputs independently of application layers.

# Export a verified upstream image reference before running:
# export BESZEL_IMAGE=henrygd/beszel@sha256:<verified-digest>
: "${BESZEL_IMAGE:?Set BESZEL_IMAGE to a verified Beszel image digest}"
export BESZEL_IMAGE

mkdir -p beszel-hardened/empty-data
cd beszel-hardened

cat > go.mod <<'EOF'
module local/beszel-health
go 1.24
EOF

cat > health.go <<'EOF'
package main

import (
    "net/http"
    "os"
    "time"
)

func main() {
    client := &http.Client{Timeout: 3 * time.Second}
    response, err := client.Get("http://127.0.0.1:8090/api/health")
    if err != nil {
        os.Exit(1)
    }
    defer response.Body.Close()
    if response.StatusCode != http.StatusOK {
        os.Exit(1)
    }
}
EOF

cat > Dockerfile <<'EOF'
# syntax=docker/dockerfile:1.7
ARG BESZEL_IMAGE

FROM golang:1.24-alpine AS health-build
WORKDIR /src
COPY go.mod ./
RUN --mount=type=cache,target=/go/pkg/mod go mod download
COPY health.go ./
RUN --mount=type=cache,target=/root/.cache/go-build \
    --mount=type=cache,target=/go/pkg/mod \
    CGO_ENABLED=0 go build -trimpath \
    -ldflags="-s -w" -o /out/healthcheck .

FROM ${BESZEL_IMAGE}
USER 0
COPY --from=health-build --chmod=0555 \
    /out/healthcheck /usr/local/bin/healthcheck
COPY --chown=10001:10001 empty-data/ /beszel_data/
USER 10001:10001
EOF

cat > docker-compose.yml <<'EOF'
services:
  hub:
    build:
      context: .
      args:
        BESZEL_IMAGE: "${BESZEL_IMAGE:?Set a verified image digest}"
    image: local/beszel-hub:hardened
    user: "10001:10001"
    restart: unless-stopped
    init: true
    read_only: true
    cap_drop:
      - ALL
    security_opt:
      - no-new-privileges:true
    ports:
      - "127.0.0.1:8090:8090"
    volumes:
      - hub-data:/beszel_data
    tmpfs:
      - /tmp:rw,noexec,nosuid,size=64m,mode=1777
    mem_limit: 512m
    cpus: 1.0
    pids_limit: 128
    healthcheck:
      test: ["CMD", "/usr/local/bin/healthcheck"]
      interval: 30s
      timeout: 5s
      retries: 3
      start_period: 30s
    networks:
      - backend
      - agent-egress
    logging:
      driver: json-file
      options:
        max-size: "10m"
        max-file: "3"

volumes:
  hub-data:

networks:
  backend:
    driver: bridge
    internal: true
  agent-egress:
    driver: bridge
EOF

docker compose config --quiet
docker compose up --build -d
docker compose ps
Enter fullscreen mode Exit fullscreen mode

What This Protects, and What It Does Not

The upstream hub entrypoint remains intact. Its writable state lives in /beszel_data; the root filesystem is read-only. Docker initializes a fresh named volume from that image directory, including its ownership. An existing volume does not receive that initialization again: migrating an installation requires backing it up and correcting ownership before switching users.

The dashboard binds only to host loopback. Use an SSH tunnel for administration or place an authenticated TLS reverse proxy in front of it. Do not replace the loopback binding with a public listener merely to make remote access easier.

The internal bridge is a private attachment point, not a firewall around the entire hub. The separate egress bridge is intentional: the hub must reach remote agents. Restrict destinations with host firewall policy, and restrict agent ingress to the hub's actual source address.

After creating the administrator account, register each agent using the selected release's enrollment instructions. Verify historical samples, not just dashboard availability. The /api/health probe confirms HTTP health; it cannot prove collection freshness.

The Operational Trade-Off

These limits are starting budgets, not benchmark results. Long retention and many monitored systems can require more memory or CPU. An unhealthy container is not automatically restarted by Compose; health status needs external observation.

Pin the Go builder digest in maintained deployments. Updating either upstream image should trigger a rebuild, health verification, and backup-restore exercise.

The useful security outcome is modest but concrete: compromising the dashboard should not immediately provide a Docker control channel. Non-root execution reduces damage, but removing unnecessary authority is the stronger boundary.


The hardest operational dilemma here is not technical—it's deciding where to draw the privilege boundary when every monitoring vendor pitches frictionless deployment. Do you grant the hub Docker access because it makes the initial setup smoother, or do you accept the complexity of distributed agents to keep authority partitioned?

How do you architect privilege boundaries in your monitoring stack? I'm curious whether others have hit similar trade-offs between deployment convenience and blast radius.

Top comments (0)