100% free and open source · every tool, every host, no licence key · read the licence
Secronyx

Deploy

Docker and Compose

Running Secronyx in a container — the shipped Dockerfile and Compose services, the mandatory token, read-only rootfs and dropped capabilities, and what a container can and cannot see of its host.

The repository ships a Dockerfile, a Dockerfile.token-server and a docker-compose.yml with five services. This page describes them exactly as they are, including the parts that constrain what Secronyx can observe from inside a container — which is the single most important thing to understand before you deploy it this way.

The image

Dockerfile is a two-stage build. The builder is golang:1.22-alpine; it copies go.mod/go.sum, runs go mod download, then builds with CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o secronyx ./cmd/secronyx.

The runtime stage is alpine:3.19 with ca-certificates, procps, lsof, iproute2, util-linux and coreutils installed. Those are there for the collectors that shell out: every external binary is resolved through cmdexec.LookPath, and a collector that cannot find its tool degrades rather than failing — get_routes, for instance, prefers ip route show and falls back to parsing /proc/net/route. The core Linux collectors need none of them: CPU, memory, process and open-file data is read straight out of /proc. It creates an unprivileged user with adduser -D -u 1000 mcp, switches to it, and sets ENTRYPOINT ["/usr/local/bin/secronyx"], so every command: in Compose is a list of flags rather than a full command line.

One thing to check before you build: go.mod declares go 1.23.0 with toolchain go1.24.4, while the Dockerfile pins golang:1.22-alpine. The builder's own Go is therefore older than the version go.mod requires, so the build depends on Go's toolchain switching fetching go1.24.4 during go mod download/go build. If your build breaks on the Go version, bump the builder image rather than editing go.mod.

Dockerfile.token-server is the same shape for ./cmd/secronyx-token-server, on alpine:3.19 with only ca-certificates, also running as uid 1000.

The token is mandatory

The Compose file opens with a YAML anchor that refuses to start without a secret:

x-token: &token "${SECRONYX_TOKEN:?set SECRONYX_TOKEN to a random secret of at least 32 characters (openssl rand -base64 32)}"

So the first step is always:

export SECRONYX_TOKEN="$(openssl rand -base64 32)"
docker compose up secronyx-http

The 32-character minimum is not advice: HTTPConfig.Validate rejects a shorter static bearer token outright. The token reaches the process as the SECRONYX_TOKEN environment variable, which main.go reads into BearerToken when --token is absent. That means the listener is authenticated, and every call needs a header:

curl -X POST http://127.0.0.1:8080/ \
  -H "Authorization: Bearer $SECRONYX_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"get_uptime"}}'

Why --allow-unauthenticated is in the file

Inside a container the listener has to bind 0.0.0.0 to be reachable through a published port, and 0.0.0.0 is not loopback. HTTPConfig.validate then refuses a non-loopback listener twice over: once when no authentication is configured, and once when authentication is configured but TLS is not — "refusing to listen on … with authentication but without TLS: credentials would cross the network in plaintext". With a token configured and no certificate mounted, the shipped services hit the second refusal. --allow-unauthenticated is present purely to suppress it (the server then logs a SECURITY WARNING and serves anyway), and the header comment says so: the token crosses the Docker bridge in plaintext, and that bridge does not leave the host, which is also why every port is published on 127.0.0.1 rather than on all interfaces.

To expose the service beyond the host, do the opposite of the shipped default: remove --allow-unauthenticated, mount a certificate, and add --tls-cert/--tls-key, or put a TLS-terminating proxy in front and leave the port bound to loopback. See Remote access over HTTP.

The services

secronyx-http

The default. JSON-RPC on 127.0.0.1:8080, bearer token auth, with --redact, --audit and --audit-output /dev/stdout so the audit trail goes to the container log rather than a file — necessary, because the container runs with read_only: true and could not create /var/log/secronyx/audit.jsonl. Hardening: read_only: true, security_opt: [no-new-privileges:true], cap_drop: [ALL]. Health is a wget -q --spider http://localhost:8080/health every 30s with a 10s timeout and 3 retries.

secronyx-stdio

The same image with command: ["--redact"], stdin_open and tty set. No network listener exists at all, so access control is simply who can attach to the container's stdin. This is the right service to attach an MCP client to.

secronyx-privileged (profile privileged)

docker compose --profile privileged up secronyx-privileged

Published on 127.0.0.1:8081. It runs user: root, privileged: true and pid: host, and adds two read-only host mounts: /var/log:/var/log:ro and /etc:/host/etc:ro. pid: host is what makes process-level tools (get_processes, get_open_files, get_capabilities) see the host rather than the container. Host networking is deliberately absent — it is not needed for the listener and would undo the loopback-only port publication. Everything this profile can read, the token can read; keep both the token and the port local.

secronyx-auth and token-server (profile auth)

export MCP_CLIENT_SECRET=...   # the example-app client secret
docker compose --profile auth up

secronyx-auth is published on 127.0.0.1:8082 and authenticates by OAuth 2.1 token introspection against --auth-server http://token-server:8444, with --client-id example-app and --introspection-audience secronyx. The secret never appears on the command line: Compose passes it in the environment as SECRONYX_CLIENT_SECRET, filled from MCP_CLIENT_SECRET on the host and defaulting to example-secret. The token server runs serve --listen 0.0.0.0:8444 --issuer http://token-server:8444 --audience secronyx --clients /etc/mcp/clients.json, with ./config/clients.json bind-mounted read-only. The two secronyx values have to match, because that is the aud claim the issued tokens carry; without --introspection-audience the server expects its own URL instead and rejects every token as "invalid token audience".

The shipped config/clients.json contains that one client, example-app, with allowed_scopes of core, logs and hooks, and stores only the SHA-256 of example-secret. It is a demo credential: register your own before this stack is anything but local, writing into the file Compose bind-mounts. Do it on the host: inside the container /etc/mcp/clients.json is mounted read-only, and client add writes to clients.json in the working directory unless --clients says otherwise. Flags must come before the id and name, because flag parsing stops at the first positional argument:

go build -o secronyx-token-server ./cmd/secronyx-token-server
./secronyx-token-server client add --clients config/clients.json --scopes core,logs,hooks \
  diagnostics "Diagnostics client"

The secret is printed once — "Save the secret now - it cannot be retrieved later" — and that is the value for MCP_CLIENT_SECRET; point Compose's --client-id at the new id. The token server's own help lists GET /.well-known/jwks.json, POST /token and GET /health; it also serves POST /introspect (RFC 7662), which is the endpoint secronyx-auth calls, and OAuth/OIDC metadata at /.well-known/oauth-authorization-server and /.well-known/openid-configuration. /token accepts only the client_credentials grant, so a token request looks like:

curl -X POST http://localhost:8444/token \
  -u <id>:<secret> \
  -d "grant_type=client_credentials" \
  -d "scope=core logs"

What the container can actually see

This is where container deployments most often mislead. The Compose file mounts /proc:/host/proc:ro and /sys:/host/sys:ro and sets HOST_PROC=/host/proc and HOST_SYS=/host/sys. No Go code in the repository reads HOST_PROC or HOST_SYS. The Linux collectors use absolute paths — internal/cpu/cpu_linux.go, for example, declares procStat = "/proc/stat", procCPUInfo = "/proc/cpuinfo" and procLoadAvg = "/proc/loadavg". Those mounts and variables are inert with the current collectors.

What follows from that:

  • CPU, memory and kernel facts are host-wide anyway. /proc/cpuinfo, /proc/stat, /proc/meminfo and /proc/loadavg are not namespaced, so a container reads the host's values (not the container's cgroup limits).
  • Process and network views are namespaced. Without pid: host the process tools list only the container's own processes; without host networking the socket and interface tools describe the container's namespace. The privileged profile fixes the process side.
  • Log and config tools need the files. get_journal_logs looks up journalctl and returns an empty result when it is absent, as it is in the Alpine runtime image; get_syslog, get_cron_jobs and get_systemd_services read host paths such as /etc/crontab and /etc/cron.d that the image does not have unless you mount them. The privileged profile mounts /var/log read-only; /etc is mounted at /host/etc, which no collector consults, so mount it at /etc if you need host configuration read.
  • Package inventory describes the image, not the host. get_system_packages tries dpkg-query, then rpm, then apk; inside the Alpine image apk is present, so it answers with the container's own packages rather than returning nothing. Read that result as inventory of the image.

If your goal is to diagnose the host, run the binary on the host (see Installation) or use hybrid mode (Hybrid and SaaS mode). If your goal is to diagnose containers and images, get_docker_images and get_docker_containers speak the Docker/Podman API over a Unix socket — /run/user/1000/podman/podman.sock, /run/podman/podman.sock or /var/run/docker.sock, whichever answers first — and the shipped Compose file deliberately mounts none of them, so those tools return a result carrying a connection error. get_kubernetes_node_info needs no socket access: it detects kubelet, kubeadm, k3s and kubectl binaries and the presence of container runtime sockets, so it too reports on the container's filesystem unless the host's is mounted.

Hardening notes

  • Keep read_only: true, cap_drop: [ALL] and no-new-privileges:true. With a read-only rootfs, always set --audit-output /dev/stdout (or mount a writable volume) or the audit provider falls back to stderr.
  • Keep ports published on 127.0.0.1. A bare 8080:8080 publishes on every interface, and because --allow-unauthenticated is already in the shipped command the binary will not stop you: it logs the warning and serves the token over plaintext HTTP to anyone who can reach the port.
  • Narrow the tool surface with --scopes. The shipped services do not set it, and with no --scopes and no --enable-sensitive the server registers every scope except sensitive. See Scopes and authorization.
  • --redact is on by default and is set explicitly in each of the four secronyx services; do not remove it to "see more". See Redaction.
  • Collect the container log if you rely on the audit trail — /dev/stdout means the hash chain lives in your log pipeline. See Audit logging.

For Kubernetes, the chart applies the same rules through values rather than flags: Kubernetes and Helm.

Built 2026-09-19. Source: levantar-ai/secronyx. Found a mistake? Tell us.