Docker Runtime¶
This document describes the Docker image and runtime behavior implemented in the repository.
Source of truth:
Dockerfileentrypoint.sh.github/workflows/release-to-docker-ci.yml.github/workflows/rebuild_supported_tags.yml.github/workflows/development_image_ci.yml
Supported Model¶
Docker and Docker Compose are the only supported deployment models.
Published image:
orenlab/pytmbot
Stable public tags:
0.5.4for an exact release image0.5for the current supported stable linestableas the stable-channel aliaslatestas an alias ofstable
Additional tags:
0.5-rYYYYMMDDfor dated weekly stable-line rebuildsedge-<branch>andedge-sha-<gitsha>for development images fromfeat/*andfix/*branches
See release_policy.md for the full contract.
Supported image architectures:
linux/amd64linux/arm64
Runtime Defaults¶
- base image: Ubuntu (
26.04in the currentDockerfile), without the base image'spebbleservice manager;tiniruns the bot - container user:
pytmbot - user/group id:
1001:1001 - working directory:
/opt/app - default timezone env:
TZ=UTC - built-in Docker
HEALTHCHECKcalls./entrypoint.sh --health_check, which judges the health snapshot the running bot writes to its state directory (see Health) - development images (
edge-*) track unreleased branch work; production fleets should use the0.5stable tags
Required And Optional Mounts¶
Required for normal bot startup:
- config file mounted to
/opt/app/pytmbot.yaml
Required for Docker-management features:
/var/run/docker.sock:/var/run/docker.sock:ro
Optional:
- certificate files referenced by
webhook_config.certandwebhook_config.cert_key
Docker Socket Access¶
The bot talks to the Docker Engine API through /var/run/docker.sock. Access to that socket is equivalent to root on
the host, so grant it deliberately.
How access is granted:
- the socket is usually
root:dockerwith mode660, so the bot needs the socket's group ID; find it withstat -c %g /var/run/docker.sockand pass it with--group-add <gid>(Compose:group_add: ["<gid>"]) - for compatibility, the image user is also a member of
root(gid0), gid1000, and the image'sdockergroup; sockets owned by one of these groups (for example rootless Docker or Docker Desktop, where the socket appears asroot:root) work withoutgroup_add --check-docker(and every startup) logs the socket's group ID and the exact--group-addvalue when access is missing;--check-dockerthen pings the Docker API and exits with0only if it answered- mounting the socket with
:rodoes not limit the API: a read-only bind mount still allows connecting to the socket
Least-privilege user: run with --user 1001:1001 --group-add <gid> (Compose: user: "1001:1001" plus group_add).
An explicit uid:gid drops the image's compatibility groups, so the process keeps only its own group and the socket
group.
Restricting the Docker API (recommended)¶
To limit what a compromised bot could do through Docker, put a filtering socket proxy between the bot and the socket. The bot uses only these Engine API endpoints:
GET:/version,/_ping,/containers/json,/containers/{id}/json,/containers/{id}/logs,/containers/{id}/stats,/images/json,/images/{name}/json,/images/{name}/historyPOST:/containers/{id}/start,/containers/{id}/stop,/containers/{id}/restart
Example with wollomatic/socket-proxy, which allows exactly these
requests and refuses everything else (container create, exec, kill, remove, file copy, image pulls, /info):
services:
socket-proxy:
image: wollomatic/socket-proxy:1
restart: unless-stopped
read_only: true
cap_drop: [ALL]
security_opt: [no-new-privileges:true]
group_add:
# Required: the proxy runs unprivileged and needs the socket's group.
- "${DOCKER_GID:?set DOCKER_GID to the group id of /var/run/docker.sock}"
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
command:
- -listenip=0.0.0.0
- -allowfrom=pytmbot
- '-allowGET=/(v1\.[0-9]+/)?(version|_ping|containers/json|containers/[a-zA-Z0-9_.-]+/(json|logs|stats)|images/json|images/.+/(json|history))'
- '-allowPOST=/(v1\.[0-9]+/)?containers/[a-zA-Z0-9_.-]+/(start|stop|restart)'
networks: [docker-proxy]
pytmbot:
image: orenlab/pytmbot:stable
container_name: pytmbot
restart: on-failure
user: "1001:1001"
read_only: true
cap_drop: [ALL]
security_opt: [no-new-privileges:true]
environment:
PYTMBOT_STATE_DIR: /run/pytmbot
tmpfs:
- /run/pytmbot:noexec,nosuid,nodev,size=10m,mode=0700,uid=1001,gid=1001
volumes:
- ./pytmbot.yaml:/opt/app/pytmbot.yaml:ro
networks: [docker-proxy, default]
command: --mode prod
networks:
docker-proxy:
internal: true
Set DOCKER_GID once, next to the compose file (the proxy needs the socket's group; Compose refuses to start
without it):
Then point the bot at the proxy in pytmbot.yaml:
Notes:
-allowfrom=pytmbotaccepts requests only from the container namedpytmbot; other containers on the network get403- the
docker-proxynetwork is internal; the bot also joinsdefaultto reach Telegram - the bot logs a
tcp_without_tlswarning for this setup; traffic stays on the internal network - for a monitoring-only bot, drop the
-allowPOSTline: start/stop/restart are then refused
Environment Variables¶
Current runtime-relevant environment variables:
| Variable | Default | Purpose |
|---|---|---|
TZ |
UTC |
Container timezone |
STRICT_DOCKER_ACCESS |
False |
Fails startup when Docker access is unavailable if set truthy |
PYTMBOT_CONFIG_PATH |
unset | Overrides the config path when needed |
PYTMBOT_STATE_DIR |
~/.local/state/pytmbot |
Overrides runtime state path (see Runtime State) |
XDG_STATE_HOME |
unset | Base directory for runtime state when PYTMBOT_STATE_DIR is unset |
Runtime State¶
The runtime state directory holds only data that may be lost on restart; the bot token and settings never go there:
| File | Content | Size |
|---|---|---|
health.json |
health snapshot for the healthcheck | under 1 KiB |
totp_replay_state.json |
2FA codes already used in the last ~2 minutes | up to 64 codes per admin |
webhook_ratelimit/<port>/main.json, 404.json |
banned client IPs, webhook mode only | at most 5120 entries |
Together this stays well under 1 MiB. Files are written atomically with mode 0600 in directories with mode 0700.
State path resolution:
PYTMBOT_STATE_DIRwhen set- otherwise
$XDG_STATE_HOME/pytmbotwhenXDG_STATE_HOMEis set - otherwise
~/.local/state/pytmbot - when that cannot be written (a read-only root filesystem without
PYTMBOT_STATE_DIR),/dev/shm/pytmbot-<uid>; startup logsbot.launcher.state.dir.fallback.info
A read-only container therefore works without extra settings. The /dev/shm fallback is used only when it is safe:
/dev/shmbelongs to the container's own IPC namespace. Withipc: host, or outside a container, it is shared with the host and the fallback is off. Containers that share an IPC namespace (ipc: container:<name>, a Kubernetes pod) also share/dev/shm; setPYTMBOT_STATE_DIRthere.- The directory is created with mode
0700. An existing one is used only if it is a real directory (not a symlink), owned by the bot user and closed to others; otherwise the fallback is off.
To choose the location, set PYTMBOT_STATE_DIR to a writable private tmpfs or volume, for example /run/pytmbot as in
the official Compose file. Give a tmpfs mode=0700 and the bot's uid/gid: without mode, Docker creates it with
mode 1777 (the bot closes it to 0700 only when it first writes there). A directory set this way is never replaced
by the fallback: if it is not writable, startup logs bot.launcher.state.dir.unwritable.fail, and the healthcheck has
no data and reports the container unhealthy.
A tmpfs, /dev/shm included, is wiped when the container stops and counts towards the container's memory. That is
fine: after a restart without saved TOTP replay state, codes issued before the restart are refused, so wait for the
next code (up to a minute), and webhook bans start over.
Minimal Run¶
docker run -d \
--name pytmbot \
--restart on-failure \
-v /path/to/pytmbot.yaml:/opt/app/pytmbot.yaml:ro \
-v /var/run/docker.sock:/var/run/docker.sock:ro \
--group-add "$(stat -c %g /var/run/docker.sock)" \
orenlab/pytmbot:stable --mode prod
Minimal Compose¶
services:
pytmbot:
image: orenlab/pytmbot:stable
container_name: pytmbot
restart: on-failure
environment:
TZ: UTC
group_add:
- "${DOCKER_GID:-1001}"
volumes:
- ./pytmbot.yaml:/opt/app/pytmbot.yaml:ro
- /var/run/docker.sock:/var/run/docker.sock:ro
command: --mode prod
To grant access to the Docker socket, set DOCKER_GID once, next to the compose file (without it, group_add
falls back to the bot's own group 1001 and changes nothing):
What the Numbers Mean in Docker¶
Inside a container, the bot reads the container's view of /proc and /sys. Some kernel counters are not
namespaced and always describe the whole host; others describe only the container. When the bot runs in Docker, the
CPU, Memory, Processes, File system, Disk I/O, Network and Quick view screens end with a one-line note saying whose
numbers they show (for example "Disks: only filesystems mounted into this container."). Outside Docker there are no
notes: everything is the host.
| Screen | Read from | Official Compose (pid: host) |
Without pid: host |
With network_mode: host |
|---|---|---|---|---|
| CPU, load average, uptime | /proc/stat, /proc/loadavg |
Host | Host | Host |
| Memory and swap | /proc/meminfo |
Host | Host | Host |
| Processes, top 10, Monitor alert lists | /proc/<pid> |
Host | This container only | As set by pid |
| File system | container mount table, statvfs | Filesystems mounted into the container | Same | Same |
| Disk I/O | /proc/diskstats |
Host block devices | Host | Host |
| Network traffic, interfaces, connections | /proc/net, interface ioctls |
This container | This container | Host |
| Sensors | /sys/class/hwmon |
Host hardware | Host hardware | Host hardware |
Notes:
- CPU and memory ignore the container's limits (
cpus,mem_limit): a container capped at 256 MB still shows the host's total RAM and the host's CPU usage - the File system screen lists only the filesystems visible in the container, usually the host filesystem behind
Docker-managed files such as
/etc/hostsplus your bind mounts; sizes are those of the backing host filesystem, but host partitions that are not mounted into the container are missing and mount points are container paths - the Monitor plugin uses the same sources: CPU, memory and temperature alerts use host numbers, disk alerts cover only filesystems mounted into the container, and CPU/memory alerts add a scope line when their process list may not cover the whole host
How the bot tells the scope (no extra privileges needed; the result is logged at startup as Metric scopes):
- processes: PID 2 is the kernel thread
kthreaddonly in the host PID namespace; if PID 1 shares the bot's mount table, it is the container's own init. Anything else (for example/procmounted withhidepid, or Docker nested in an LXC container) is reported as "could not tell" instead of guessing - network: compared with the host's init process, which is visible only with
pid: host; without it the note reads "this container's interfaces, unless network_mode: host"
Host Disk Numbers¶
Disk usage is reported for the whole filesystem that contains a mount point, so an empty directory is enough. Create one on each host filesystem you want to watch and bind-mount it read-only:
The File system screen and the Monitor plugin's disk alerts then include that filesystem (device, size, used, free).
Trade-off: a mounted directory exposes its contents to the bot, so keep probe directories empty. Avoid mounting the host
root (/:/host:ro): it makes the whole host file tree readable to the bot, and :ro does not prevent reads.
Host Network and Processes¶
network_mode: hostmakes the Network screens show the host's interfaces, traffic and connections. Trade-off: the bot shares the host network stack, so it can reach services bound to the host's127.0.0.1, any port it opens is opened on the host, and Composenetworks:(including the socket proxy setup above) cannot be combined with itpid: host(set in the officialdocker-compose.yml) makes Processes show host processes. Trade-off: the bot can read the name, owner and command line of every host process, and command lines sometimes carry secrets; withcap_drop: ALLit cannot signal or trace other users' processes. Without it, Processes and alert process lists cover only the bot's container
Webhook Deployment Notes¶
- enable with
--webhook trueor bare--webhookin the container entrypoint - set
--socket_host 0.0.0.0when the webhook listener must bind outside localhost webhook_config.local_portmust be>= 1024- direct public exposure is not the intended model; use a reverse proxy with TLS
Diagnostics¶
Generate a TOTP salt:
Check Docker socket access:
docker run --rm \
-v /var/run/docker.sock:/var/run/docker.sock:ro \
--group-add "$(stat -c %g /var/run/docker.sock)" \
orenlab/pytmbot:stable --check-docker
Read container logs:
Hardening Guidance¶
Recommended runtime flags for production:
--read-only--security-opt no-new-privileges--cap-drop ALL--user 1001:1001 --group-add <socket gid>instead of the image's compatibility groups- a filtering socket proxy instead of the raw socket (see Restricting the Docker API)
- writable private tmpfs or volume for
PYTMBOT_STATE_DIR
Important:
- Docker socket access is privileged host control; a
:rosocket mount does not make it read-only. - If Docker access must be mandatory, set
STRICT_DOCKER_ACCESS=True.