~/docs

Container image

Add shellz to a container image so every exec into the container is recorded, on any platform.

↗ .mdmcp

When you can't install software on the host, add shellz to your container image instead. shellz runs as PID 1 in front of your app, and records every shell opened in the container by kubectl exec, docker exec, ECS Exec or a dev-container terminal. Each container that starts joins a fleet using a fleet token, and shows up in the dashboard as a fleet member.

This page covers the image and the fleet token. The platform guides add the deployment details: Kubernetes, ECS, Fargate and Cloud Run, Dev containers.

1. Create a fleet

In the dashboard, open ~/servers, find Container fleets and click Add fleet. Give it a name (for example api-prod), choose the platform, and optionally set a maximum number of members.

shellz shows the fleet token once, starting with szf_, along with setup snippets for your platform. Copy the token and store it in your secret manager. Unlike a server join token, a fleet token is reusable: every container started with it joins the fleet.

2. Add shellz to the image

Add two lines to your Dockerfile:

Dockerfile
RUN curl -fsSL https://get.shellz.dev/container | sh
ENTRYPOINT ["/usr/local/bin/shellz", "run", "--"]
CMD ["/app/server", "--port", "8080"]   # your original entrypoint and args

The install step runs as root, needs curl (or wget) and OpenSSL 3, and:

  1. downloads the shellz release for the image's architecture and verifies its Ed25519 signature and SHA-256 checksum;
  2. installs /usr/local/bin/shellz;
  3. runs shellz image-install, which puts a small recording shim in front of the image's shells (/bin/sh, /bin/bash, ash, dash, zsh, fish) and keeps the real shells alongside.

It never enrolls and never needs a token at build time. It refuses to run outside an image build or container, and refuses if you pass it a token.

Then make shellz run -- the entrypoint, with your original entrypoint and arguments as the command. If your image already has an ENTRYPOINT, move it into CMD after shellz run --.

Without curl, sh or OpenSSL in the image

For minimal and distroless images, use BuildKit's checksum-verified ADD. The dashboard's setup snippets fill in the current version and checksum for each architecture; you can also read them from https://releases.shellz.dev/stable/linux-amd64.json and linux-arm64.json.

ADD --chmod=0755 --checksum=sha256:<sha256> https://releases.shellz.dev/agent/<version>/shellz-linux-amd64 /usr/local/bin/shellz
RUN ["/usr/local/bin/shellz", "image-install"]
ENTRYPOINT ["/usr/local/bin/shellz", "run", "--"]
CMD ["/app/server", "--port", "8080"]

Images with no shell at all (distroless) have nothing for the shim to wrap. shellz still runs as PID 1 and watches for exec sessions, as described in How sessions are caught.

3. Pass the fleet token at runtime

Each container reads the token when it starts, from either:

  • the file /run/secrets/shellz-fleet-token (recommended), or
  • the environment variable SHELLZ_FLEET_TOKEN. If both are set, the variable wins.

With plain Docker:

printf '%s' 'szf_<fleet token>' > shellz-fleet-token && chmod 0444 shellz-fleet-token
docker run -d --stop-timeout 30 \
  -v "$PWD/shellz-fleet-token:/run/secrets/shellz-fleet-token:ro" \
  ghcr.io/acme/app:1.4

4. Check it's recording

Start a container and open a shell in it:

docker exec -it <container> bash

The fleet page lists the container as a member with the status recording, and the session appears in the dashboard.

Stopping gracefully

When the container is stopped, shellz forwards the signal to your app, stops accepting new sessions, waits for your app to exit, and uploads the rest of the recordings before exiting. This drain takes up to 20 seconds (SHELLZ_DRAIN_SECONDS).

Give containers a stop timeout of at least 30 seconds so the drain isn't cut short: --stop-timeout 30 for docker run, stop_grace_period: 30s in Compose. Docker's default is 10 seconds. Kubernetes' default of 30 seconds is fine.

How shellz runs your app

As PID 1, shellz run -- behaves like a minimal init:

  • It starts your command, forwards SIGTERM, SIGINT, SIGHUP, SIGQUIT, SIGUSR1, SIGUSR2 and SIGWINCH to it, reaps zombie processes, and exits with your app's exit code.
  • Your app's own shell use (sh -c from scripts, process managers and so on) is never recorded and adds about 1 ms per shell started.
  • If the token is missing or rejected, or shellz can't reach the API, your app still starts and runs. Interactive sessions are refused until the container is enrolled, and shellz retries in the background.

If something else is already PID 1, such as docker run --init or tini, shellz runs as its child and still works.

How sessions are caught

shellz uses two layers inside the container:

  1. The shell shim. When a shell starts, the shim checks whether it was started by an exec into the container rather than by your app. Exec sessions go to the recorder; everything else goes straight to the real shell.
  2. The exec watcher. Every 500 ms, PID 1 looks for processes that came from an exec and aren't being recorded, for example someone running the moved real shell or another interpreter directly. Under fail-closed, an unrecorded session with a terminal is killed and raises unrecorded-container-exec. Otherwise it's reported.

Commands without a terminal

Health checks and probes (kubectl exec pod -- cat /tmp/ready, sh -c 'test -f /ready') run without a terminal. shellz never blocks them, even when recording is unavailable, so a recording problem can't fail your probes or restart your pods:

  • They're recorded as command sessions when recording is available.
  • When the same command produces the same output and exit status as a run in the last 10 minutes, shellz drops the duplicate recording and counts it. The dashboard shows one row with a ×count badge instead of thousands of identical probe sessions.
  • If recording is unavailable, the command runs anyway, and shellz logs its command line, user, timing and exit status once it's back, with an unrecorded-noninteractive-exec warning.
  • Very short non-shell commands (under 500 ms) may be missed by the watcher.

Fleet members

Each running container is a member of its fleet. The fleet page shows each member's status:

StatusMeaning
recordingEnrolled and sending heartbeats.
waitingEnrolled, waiting for the first heartbeat.
stoppedThe container stopped cleanly and reported its exit.
lostNo heartbeat for 3 minutes, and no clean stop. Raises a member-lost warning.
tamperedThe member has an open critical alert, or isn't accepting sessions.

Members are named after the pod, task or container. On Kubernetes, the pod's namespace and node are shown too.

Managing the fleet token

From the fleet page, owners and admins can:

  • Rotate token: issue a new token. The old one stops working for new containers straight away; running members keep recording. Update your secret and roll your deployment.
  • Revoke: turn the fleet off. Running members stop accepting sessions and raise a member-disabled warning.
  • Delete fleet: revoke the fleet and remove it from the dashboard. Its recordings are kept.

Fleet tokens can't be used to read recordings; they only let a container join the fleet. A leaked token lets someone add members to the fleet, so rotate it if that happens. A maximum member count limits how many can be active at once; containers beyond it are refused and raise fleet-full.

Settings

See Container settings for every environment variable shellz run reads.