Skip to content

Bield in Docker

The image is ghcr.io/manasseh-fikadu/bield, for linux/amd64 and linux/arm64. Each release has a tag with its version, such as 0.1.0, and one with its minor version, 0.1. latest is the newest release, and a release candidate never moves latest or the minor tag. Pin a version in production, so that upgrades happen when you say.

The image holds the bield binary on distroless static, which is CA certificates, time zone data and nothing else: no shell and no package manager. It runs as the user 65532, keeps its data in /data, and starts bield serve.

There are two ways to run it, which are the two ways Bield joins your tailnet (Bield and Tailscale compares them).

Bield joins the tailnet as its own machine, so it works on any host with Docker. compose.yaml is all it takes:

Terminal window
curl -fsSLO https://raw.githubusercontent.com/manasseh-fikadu/bield/main/compose.yaml
docker compose up -d
docker compose logs bield

The first time, the log has a link to log Bield in to Tailscale. Open it, and Bield is at https://bield.<your-tailnet>.ts.net. To skip the link, create a .env file next to compose.yaml with an auth key, which Bield only uses the first time it starts:

Terminal window
TS_AUTHKEY=tskey-auth-...

A key for a tagged node means Bield has no owner to make its admin, so also set BIELD_ADMINS in .env (Admins and viewers). BIELD_HOSTNAME renames the node. With Headscale, set BIELD_CONTROL_URL to your server’s address in .env too, and use a pre-auth key for TS_AUTHKEY.

Bield also serves a login page inside the container, on 127.0.0.1:4700, which isn’t published, so the link in the log is the way in. If you do publish it, publish it on the host’s loopback only (127.0.0.1:4700:4700) and set BIELD_SETUP_LISTEN=0.0.0.0:4700: until Bield is logged in, anyone who can open that page can add it to their own tailnet.

If the host already runs Tailscale, Bield can use it, and show up as the host. compose.host.yaml does that:

Terminal window
curl -fsSLO https://raw.githubusercontent.com/manasseh-fikadu/bield/main/compose.host.yaml
docker compose -f compose.host.yaml up -d

Bield listens on the host’s tailnet IPs, so the container uses the host’s network (network_mode: host), and it talks to tailscaled through its socket. The file mounts the directory /var/run/tailscale, not the socket, because tailscaled makes a new socket when it restarts, and a mount of the old one would stay dead. Tailscale on the host has to use a TUN device (the default). Bield is then at port 4700 on the host’s tailnet IPs, or the port in BIELD_PORT.

The container runs as 65532, which tailscaled doesn’t let fetch the host’s HTTPS certificate, so Bield serves plain HTTP, which WireGuard still encrypts, and says so in its log. To get HTTPS, run the container as root with user: "0".

Both files keep Bield’s data in the volume bield-data: the database, the embedded node’s identity, and Bield’s SSH key. A new named volume copies the image’s /data, which belongs to 65532. A bind mount doesn’t, so give it to that user first:

Terminal window
mkdir data && sudo chown 65532:65532 data # then use ./data:/data instead of bield-data:/data

Upgrading covers backups and moving the volume.

To see and control the host’s containers, mount Docker’s socket in the volumes of the service, and give the container’s user the socket’s group, since the socket is only open to root and docker:

volumes:
- /var/run/docker.sock:/var/run/docker.sock
group_add:
- "999" # the number `stat -c %g /var/run/docker.sock` prints

Mounting the socket read-only (:ro) doesn’t make the Docker API read-only. The flag only stops the container from replacing the socket file; every request through it still works. Whoever can use the API can start a container that mounts the host’s /, so access to the socket is as good as root on the host, for Bield’s admins and for anything that takes over the container. Leave the mount out if Bield shouldn’t manage Docker on that machine.

Bield measures the machine it runs on from /proc and /sys. Inside a container, those are the container’s view. This is what the Hub page shows for the host, from a container with no extra setup:

Without setupWhat fixes it
CPU, memory, load, uptime and the disk that holds /The host’s, since the container shares its kernel
ProcessesOnly Bield’s ownThe host’s /proc
Host nameThe container’s IDhostname:
Network trafficThe container’s, not the host’snetwork_mode: host
Operating systemDistrolessThe host’s /etc/os-release
Names of the processes’ usersNumbersThe host’s /etc/passwd

Mounting the host’s /proc over the container’s isn’t allowed, so mount them somewhere else, read-only, and tell Bield where with BIELD_HOST_ROOT:

services:
bield:
hostname: my-server # the host's name
network_mode: host # for the host's network traffic; host mode already has it
environment:
BIELD_HOST_ROOT: /host
volumes:
- /proc:/host/proc:ro
- /sys:/host/sys:ro
- /etc/os-release:/host/etc/os-release:ro
- /etc/passwd:/host/etc/passwd:ro

Bield then reads /host/proc, /host/sys and /host/etc. It only reads them, and the mounts let it read what any user on the host can read. For the host’s processes alone, there is a shorter way: pid: host shows them in the container’s own /proc, with no mounts and no BIELD_HOST_ROOT.

The alternative is to leave the container alone and run the agent on the host. In embedded mode, the host is a device like any other, so it reports its metrics to Bield through the agent.

The image checks bield healthcheck every 30 seconds, which asks the login page inside the container whether Bield is up. Bield counts as healthy while it waits to be logged in, since restarting it wouldn’t help. The check doesn’t test the tailnet. Host mode has no login page, so compose.host.yaml turns the check off.

Everything the binary does works in the container, with /data as the data directory:

Terminal window
docker compose exec bield bield admin list
docker compose exec bield bield admin add alice@example.com
docker compose exec bield bield backup - > bield-backup.tar.gz
Terminal window
docker compose pull
docker compose up -d

The new version migrates the database when it starts. Save a backup first, as Upgrading shows, and to go back, pin the old tag and restore it.

The Dockerfile only copies in binaries that GoReleaser built. To build the same image from a checkout, with the web app and every release archive:

Terminal window
goreleaser release --snapshot --clean --skip=publish

It tags the images ghcr.io/manasseh-fikadu/bield:<version>-amd64 and -arm64 on your machine, and pushes nothing.