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-httpThe 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-privilegedPublished 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 upsecronyx-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/meminfoand/proc/loadavgare not namespaced, so a container reads the host's values (not the container's cgroup limits). - Process and network views are namespaced. Without
pid: hostthe process tools list only the container's own processes; without host networking the socket and interface tools describe the container's namespace. Theprivilegedprofile fixes the process side. - Log and config tools need the files.
get_journal_logslooks upjournalctland returns an empty result when it is absent, as it is in the Alpine runtime image;get_syslog,get_cron_jobsandget_systemd_servicesread host paths such as/etc/crontaband/etc/cron.dthat the image does not have unless you mount them. Theprivilegedprofile mounts/var/logread-only;/etcis mounted at/host/etc, which no collector consults, so mount it at/etcif you need host configuration read. - Package inventory describes the image, not the host.
get_system_packagestriesdpkg-query, thenrpm, thenapk; inside the Alpine imageapkis 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]andno-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 bare8080:8080publishes on every interface, and because--allow-unauthenticatedis 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--scopesand no--enable-sensitivethe server registers every scope exceptsensitive. See Scopes and authorization. --redactis 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/stdoutmeans 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.