# Install the agent

> The full lumin-agent Compose service, the docker run equivalent, environment variables, networking, and operational notes.

Canonical: https://docs.getlumin.dev/agent/install/

The Quickstart shows the minimum compose snippet. This page is the complete reference: every environment variable, the Docker proxy sidecar, and the operational details that matter on a real host.

## The full compose service

```yaml
services:
  lumin-docker-proxy:
    image: wollomatic/socket-proxy:1.13.1
    restart: unless-stopped
    user: "0:0"
    environment:
      SP_LISTENIP: 0.0.0.0
      SP_ALLOWFROM: lumin-agent
      SP_ALLOW_HEAD: /_ping
      SP_ALLOW_GET: '/v1\.[0-9]+/containers/(json|[0-9a-f]{64}/(json|stats|logs))'
      SP_ALLOWHEALTHCHECK: "true"
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock:ro
    read_only: true
    cap_drop: [ALL]
    security_opt: ["no-new-privileges:true"]
    healthcheck:
      test: ["CMD", "/healthcheck"]
      interval: 5s
      timeout: 3s
      retries: 5
      start_period: 5s

  lumin-agent:
    image: luminmonitor/lumin-agent:latest
    restart: unless-stopped
    environment:
      LUMIN_API_KEY: ${LUMIN_AGENT_API_KEY}
      LUMIN_ENDPOINT: https://api.getlumin.dev
      LUMIN_HOSTNAME: ${HOSTNAME:-prod-1}
      LUMIN_INTERVAL: 15s
      DOCKER_HOST: tcp://lumin-docker-proxy:2375
      HOST_PROC: /host/proc
      HOST_SYS: /host/sys
    volumes:
      - /proc:/host/proc:ro
      - /sys:/host/sys:ro
    read_only: true
    tmpfs: [/tmp]
    cap_drop: [ALL]
    security_opt: ["no-new-privileges:true"]
    mem_limit: 256m
    pids_limit: 256
    cpus: "0.5"
    healthcheck:
      test: ["CMD", "/usr/local/bin/lumin-agent", "--healthcheck"]
      interval: 30s
      timeout: 5s
      retries: 3
      start_period: 30s
    depends_on:
      lumin-docker-proxy:
        condition: service_healthy
```

The proxy forwards only the five requests that the agent makes, and it accepts them only from the container named in `SP_ALLOWFROM`. If you give the agent service another name, change `SP_ALLOWFROM` to the same name. Otherwise the proxy refuses the agent with `403 Forbidden`. Keep `user: "0:0"`: the proxy cannot open the Docker socket without it. [Security model](https://docs.getlumin.dev/agent/security/) explains each setting.

## Image tags and platforms

The image is published to Docker Hub as [`luminmonitor/lumin-agent`](https://hub.docker.com/r/luminmonitor/lumin-agent). It is public — no `docker login` needed.

| Tag | Points at |
|---|---|
| `latest` | The newest release. |
| `0.1.0`, `0.1`, `0` | A pinned release. Prefer this in production, so an agent restart cannot change the version under you. |
| `edge` | The latest build from `main`. Not a release. |
| `sha-abc1234` | One exact build. |

Both `linux/amd64` and `linux/arm64` are published in one manifest, so an Arm host (Hetzner CAX, Oracle Ampere, Raspberry Pi) pulls the same tag as an x86 host.

## Environment variables

| Variable | Required | Default | Notes |
|---|---|---|---|
| `LUMIN_API_KEY` | yes | — | A `lmn_priv_…` key from **Settings → API keys** (kind: Server / agent — the default). The agent posts logs, metrics, and snapshots; the server rejects browser-kind (`lmn_pub_*`) keys on these endpoints. Scoped to one project. |
| `LUMIN_ENDPOINT` | yes | — | `https://api.getlumin.dev` for SaaS. Must be `https://` unless the host is loopback; the agent exits with a config error otherwise. |
| `LUMIN_INSECURE` | no | `false` | Set to `true` to allow a cleartext `http://` endpoint on a non-loopback host. The API key then crosses the network unencrypted, so only use it on a trusted network. |
| `LUMIN_HOSTNAME` | no | container hostname | Stamped on every row. Pick something stable per VM. |
| `LUMIN_INTERVAL` | no | `15s` | Snapshot tick. Lower = more chart resolution + more ingest cost. |
| `LUMIN_DISK_MOUNTS` | no | `/` | Comma-separated mount paths to report disk usage for. Each path must be visible inside the agent container — see [Monitor attached volumes](#monitor-attached-volumes). |
| `DOCKER_HOST` | for containers | — | Point at the proxy sidecar, not the host socket. Unset means the agent skips Docker entirely and ships host metrics only. |
| `HOST_PROC` | yes | — | `/host/proc`. Tells `gopsutil` to read the host's `/proc` mount. |
| `HOST_SYS` | yes | — | `/host/sys`. Same idea for `/sys`. |

`HOST_ETC` is intentionally **not** set. The agent only reads `Hostname`, `KernelArch`, `KernelVersion`, and `BootTime` from the host — none of which need `/etc`. Mounting all of `/etc` would expose host files the agent never reads (sudoers, ssh host keys, cloud-init data, network configs).

## Run without Compose

Compose is a convenience, not a requirement. The agent never reads a compose
file. It needs four things, and any tool that starts a Docker container can
provide them:

1. **A reachable Docker Engine API.** The agent follows `DOCKER_HOST`, so point
   it at the proxy sidecar.
2. **Docker labels** on the containers you want tailed or scraped. Labels are a
   Docker feature, not a Compose feature.
3. **`/proc` and `/sys` mounted read-only**, with `HOST_PROC` and `HOST_SYS` set.
4. **A user-defined bridge network**, for metric scraping only.

The fourth point is the one that bites. The agent builds each scrape URL from
the target's **container name**, and Docker's embedded DNS resolves container
names only on a user-defined network. The default bridge has no DNS, so scraping
fails there. Log tailing and container metrics are unaffected, because both go
through the Docker API rather than the network.

### With plain `docker run`

Create the network first, so both containers and every scrape target share it:

```sh
docker network create lumin
```

Start the proxy sidecar:

```sh
docker run -d --name lumin-docker-proxy --network lumin \
  --restart unless-stopped --user 0:0 \
  -e SP_LISTENIP=0.0.0.0 -e SP_ALLOWFROM=lumin-agent -e SP_ALLOW_HEAD=/_ping \
  -e 'SP_ALLOW_GET=/v1\.[0-9]+/containers/(json|[0-9a-f]{64}/(json|stats|logs))' \
  -v /var/run/docker.sock:/var/run/docker.sock:ro \
  --read-only --cap-drop ALL --security-opt no-new-privileges:true \
  wollomatic/socket-proxy:1.13.1
```

Start the agent with the name `lumin-agent`, because the proxy accepts requests only from that name:

```sh
docker run -d --name lumin-agent --network lumin \
  --restart unless-stopped \
  -e LUMIN_API_KEY=lmn_priv_... \
  -e LUMIN_ENDPOINT=https://api.getlumin.dev \
  -e LUMIN_HOSTNAME=prod-1 \
  -e LUMIN_INTERVAL=15s \
  -e DOCKER_HOST=tcp://lumin-docker-proxy:2375 \
  -e HOST_PROC=/host/proc -e HOST_SYS=/host/sys \
  -v /proc:/host/proc:ro -v /sys:/host/sys:ro \
  --read-only --tmpfs /tmp \
  --cap-drop ALL --security-opt no-new-privileges:true \
  --memory 256m --pids-limit 256 --cpus 0.5 \
  luminmonitor/lumin-agent:latest
```

Opt your own containers in with the same labels the compose examples use:

```sh
docker run -d --name api --network lumin \
  --label lumin.logs=true \
  --label lumin.service=api \
  --label lumin.metrics=true \
  --label lumin.metrics.port=8080 \
  --label lumin.metrics.framework=gin \
  my-api:latest
```

### Start order does not matter

There is no `depends_on` to replace. The agent does not dial the Docker API at
startup, and it retries the connection on every discovery pass. An agent that
starts before the proxy runs in host-only mode and picks up container work
within 30 seconds for logs, and within one `LUMIN_INTERVAL` for scrapes.

### The healthcheck does not carry over

Do **not** pass `--health-cmd`. The image is distroless and has no shell, but
the `docker run` CLI always wraps the flag in `CMD-SHELL`:

```sh
docker inspect -f '{{json .Config.Healthcheck}}' lumin-agent
{"Test":["CMD-SHELL","/usr/local/bin/lumin-agent --healthcheck"]}
```

That test cannot execute, so the container reports **unhealthy** forever. The
CLI offers no exec-form flag. Any tool that sets the Docker API field
`HealthConfig.Test` directly — Compose, Swarm, Portainer — can carry the
healthcheck over in the `["CMD", "/usr/local/bin/lumin-agent", "--healthcheck"]`
form. On the CLI, leave it unset and rely on `restart: unless-stopped`.

### What else works

Anything that produces a labeled Docker container on a Docker daemon:

- `docker run`, a systemd unit, or an Ansible play.
- Docker Swarm, and Nomad with the docker driver.
- A PaaS on top of Docker: Portainer, Coolify, Dokploy.

**Kubernetes is not supported.** Nodes run containerd through the CRI, so there
is no Docker Engine API to read, and pod labels are not Docker labels. Host
metrics would still flow; container metrics, log tailing, and metric scraping
would not.

**Podman is untested.** It exposes a Docker-compatible API socket and honours
container labels, so the agent should work against `DOCKER_HOST` pointed at
`podman.sock`. Nobody has verified it.

## Monitor attached volumes

The agent reports disk usage per mount path. The default is `/`. To add
an attached volume, list its path in `LUMIN_DISK_MOUNTS` and bind-mount
the path into the agent container. The agent reads usage with `statfs`,
so it sees only filesystems inside its own mount namespace.

Put the change in a `docker-compose.override.yml` next to your compose
file:

```yaml
services:
  lumin-agent:
    environment:
      LUMIN_DISK_MOUNTS: /,/mnt/data
    volumes:
      - /mnt/data:/mnt/data:ro
```

Mount the volume at the same path inside the container. The `mount`
label on the metric rows then matches the host path. A listed path that
is not mounted is skipped, so one override can serve hosts with
different volumes.

Each mount shows as its own row on the host card on the Infrastructure
page. The card's Disk percent takes the worst mount, so a volume that
fills up raises the warning even when the root disk is fine.

The server also derives a `host.disk.used_pct` gauge per mount from each
snapshot, so you can alert on disk utilization. Create a rule under
Alerts → New rule on `host.disk.used_pct` with aggregation `max`, for
example `> 90` over a 5 minute window. That rule fires when any mount
crosses the threshold. Add the filter `mount=/mnt/data` to watch one
volume with its own threshold.

## Networking

The agent makes outbound HTTPS to `LUMIN_ENDPOINT` and outbound HTTP to the socket proxy sidecar. No inbound ports. The sidecar hop needs a user-defined bridge network, which Compose creates for you and which you create by hand otherwise — see [Run without Compose](#run-without-compose).

If you run the agent on a network with `internal: true`, give it a way to reach the ingest host — typically by attaching it to a second bridge network that does have egress, or by routing through a reverse proxy you already operate.

## Health and readiness

The agent ships a `--healthcheck` subcommand that stat()s a sentinel file (`/tmp/lumin-agent.health`) the main loop touches after each successful snapshot send. Healthy = a successful snapshot within the last 3 tick intervals (45s at the 15s default). The image is distroless, so the healthcheck invokes the binary directly via `CMD`, not a shell.

Docker does **not** auto-restart an unhealthy container — `restart: unless-stopped` only fires on process exit. The healthcheck is here so operators (and any service that `depends_on: { condition: service_healthy }` the agent) can observe a wedged-mid-tick process distinct from a crashed one. If you want auto-heal on unhealthy, layer an [autoheal](https://github.com/willfarrell/docker-autoheal) sidecar — intentionally not bundled to keep the default surface minimal.

## Resource ceilings

```yaml
mem_limit: 256m
pids_limit: 256
cpus: "0.5"
```

Steady-state agent footprint is well under these numbers. The ceilings exist so a bug in a dependency (memory leak, goroutine fan-out) can't take the host down, not to throttle normal operation.

## Hardening

The compose snippet sets:

- `read_only: true` with `tmpfs: [/tmp]` — the only file the agent writes is the health sentinel on the tmpfs.
- `cap_drop: [ALL]` — the agent needs no Linux capabilities: it reads `/proc`/`/sys` (regular file reads as a non-root user) and dials TCP from unprivileged ports.
- `security_opt: ["no-new-privileges:true"]` — blocks setuid escalation inside the container.

See [Security model](https://docs.getlumin.dev/agent/security/) for the threat model and why these are not optional.

## Rotating the API key

1. **Settings → API keys** — mint a fresh key on the same project.
2. Edit `LUMIN_AGENT_API_KEY` in your `.env` with the new value.
3. `docker compose up -d lumin-agent` — Compose recreates the container with the new env. The resolver cache lives on the API side and is keyed by hash, so no API restart is needed.
4. **Settings → API keys** — revoke the old key. Revocation invalidates the resolver cache immediately; the old key stops working on its next request, not after a 60-second TTL window.
