# Container image

URL: https://shellz.dev/docs/install/container-image

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

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](https://shellz.dev/docs/install/kubernetes.md), [ECS, Fargate and Cloud Run](https://shellz.dev/docs/install/ecs-cloud-run.md), [Dev containers](https://shellz.dev/docs/install/dev-containers.md).

## 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 title="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`.

```dockerfile tab="amd64"
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"]
```

```dockerfile tab="arm64"
ADD --chmod=0755 --checksum=sha256:<sha256> https://releases.shellz.dev/agent/<version>/shellz-linux-arm64 /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](https://shellz.dev/docs/install/container-image.md#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.

> [!IMPORTANT]
> Prefer the file. An environment variable set on the container is visible to anyone who can exec into it, for example with `docker exec <container> env`. shellz removes the variable from your app's environment and from recorded sessions, but not from other execs.

With plain Docker:

```bash tab="docker run"
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
```

```yaml tab="docker compose"
services:
  app:
    image: ghcr.io/acme/app:1.4
    stop_grace_period: 30s
    secrets: [shellz-fleet-token]
secrets:
  shellz-fleet-token:
    file: ./shellz-fleet-token   # contains szf_<fleet token>
```

## 4. Check it's recording

Start a container and open a shell in it:

```bash
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:

| Status | Meaning |
|---|---|
| **recording** | Enrolled and sending heartbeats. |
| **waiting** | Enrolled, waiting for the first heartbeat. |
| **stopped** | The container stopped cleanly and reported its exit. |
| **lost** | No heartbeat for 3 minutes, and no clean stop. Raises a `member-lost` warning. |
| **tampered** | The 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](https://shellz.dev/docs/reference/container-settings.md) for every environment variable `shellz run` reads.
