Docker

Run Pepe as a container, and install the tools the agent needs inside it.

Every release publishes a container image alongside the binaries, for amd64 and arm64. docker pull selects the right architecture automatically, whether you are on an M-series Mac or a server.

docker run -d --name pepe \
  -p 4000:4000 \
  -v pepe-data:/data \
  -v pepe-tools:/tools \
  -e PEPE_DASHBOARD_PASSWORD=a-strong-password \
  ghcr.io/pepe-agent/pepe

Open http://localhost:4000, sign in, and complete the setup from the dashboard.

Requirements

Two settings are mandatory, and omitting either one fails silently.

Volumes

There are two, and they hold different kinds of thing.

/data (PEPE_HOME) is state: configuration, agents, conversations, workspaces and Mnesia. This is the volume you back up. Without it, docker rm deletes the entire installation.

/tools is cache: everything the agent installs for itself. It is on the PATH, and it is also where the agent’s home directory lives, at /tools/home. That second detail is what makes “install it once” actually hold, and it has a section of its own below.

/tools is kept out of /data on purpose. A backup should carry state, not tens of megabytes of binaries and model files that can be downloaded again, and those files are architecture-specific: a /data backed up on an arm64 machine and restored on an amd64 one would put executables on the PATH that cannot run there.

-v pepe-data:/data -v pepe-tools:/tools

Dashboard password

A container does not count as your own machine: Pepe treats its network as public and, without a password, refuses every request (HTTP 403). The dashboard will not serve.

-e PEPE_DASHBOARD_PASSWORD=...

This is a deliberate policy, not a Docker limitation. Pepe refuses to expose an unauthenticated dashboard on a network it cannot vouch for. The policy came out of a real incident, where an exposed service with no authentication was scanned and abused.

Secrets

Do not put API keys in the image or in the configuration file. Keep only the reference in the configuration and supply the real value at run time. Pepe resolves the reference when reading and never stores the expanded value.

# the configuration holds only:  "api_key": "${OPENROUTER_API_KEY}"
docker run -d ... -e OPENROUTER_API_KEY=sk-... ghcr.io/pepe-agent/pepe

Installing tools for the agent

The agent runs as an unprivileged user and cannot run apt install. This is intentional: the commands it executes are chosen by a language model, and granting that root is not a decision to make on your behalf.

The restriction costs less than it appears, because root is not the missing key:

Anything apt installs dies with the container. apt writes to /usr and /etc, which belong to the container’s writable layer, not to a volume. Root grants permission, not persistence. What it installs is gone on docker rm even when you do run as root.

The question is never how to become root. It is where a tool has to live in order to survive. There are two answers, and the first one now covers most cases on its own.

Anything the agent installs for itself persists

The agent’s HOME is /tools/home, which puts it inside the /tools volume. This is the whole trick. Installers do not ask where your volume is: they write to ~/.local/bin and ~/.cache, and nowhere else. With HOME in the container layer, everything the agent sets up for itself is downloaded again in the next container. With HOME on the volume, it installs once.

The difference is easy to measure. An agent that transcribes a voice message installs uv and pulls down a Whisper model, about 75 MB of it. The first run takes 27 seconds. In a brand new container, the same transcription takes 1.2 seconds, because the cache survived.

So uv, a pip install --user, a Whisper model, a language toolchain, or a plain download:

curl -sL <url> -o /tools/op && chmod +x /tools/op

all survive docker rm and a Pepe upgrade, with no root and no rebuild. /tools is on the PATH, so a single binary dropped there is callable from the agent’s shell straight away. The 1Password CLI (op), gh, kubectl and terraform are all single files and need nothing more than this.

System packages go in the image

Some tools are genuine system packages. psql, imagemagick and their kind scatter files and shared libraries across the filesystem, and a volume cannot hold that. They have to be part of an image.

A build argument installs extra packages without you writing a Dockerfile at all:

docker build --build-arg PEPE_IMAGE_APT_PACKAGES="postgresql-client imagemagick" .

If you would rather keep a Dockerfile of your own, deriving from the image works just as well and stays a perfectly good option:

FROM ghcr.io/pepe-agent/pepe
USER root
RUN apt-get update && apt-get install -y --no-install-recommends \
      postgresql-client \
  && rm -rf /var/lib/apt/lists/*
USER pepe
docker build -t my-pepe .
docker run -d -p 4000:4000 -v pepe-data:/data -v pepe-tools:/tools \
  -e PEPE_DASHBOARD_PASSWORD=... my-pepe

Either route carries the same trade-off: every new Pepe release means rebuilding the image.

Why ffmpeg is not in the image

ffmpeg looks like the obvious system package for this image, since Telegram sends voice as OGG/Opus and a transcript has to come from somewhere. Neither of the two routes that actually transcribe needs it. A transcription API takes the .ogg exactly as it arrives, with no conversion at all, and faster-whisper decodes through PyAV, which carries its own codecs inside the wheel. That was measured rather than assumed: an OGG/Opus file transcribed on a clean Debian with no ffmpeg installed anywhere. Only the whisper.cpp CLI shells out to ffmpeg, and that route is opt-in.

Shipping it anyway cost far more than it was worth. Debian’s ffmpeg package drags in 204 packages and 121 MB of archives (LLVM, Mesa, a speech synthesizer, a theorem prover), all to serve a GPU video acceleration stack that a headless container will never touch. Dropping it took the image from 945 MB to 408 MB, roughly 84 MB compressed, which is what you actually pull per architecture.

If you do want ffmpeg, for the whisper.cpp CLI or for anything else, install it with the build argument above, or drop a single-file static build into /tools, which is on the PATH and lives on a volume.

Trying a tool out

docker exec -u root pepe apt-get update
docker exec -u root pepe apt-get install -y jq

This works, and it is discarded on the next docker rm. Use it to confirm a tool solves your problem, then decide where it belongs: the agent’s own home if it can install it itself, the image if it is a system package.

Running the container as root (docker run --user root) is opt-in and never the default. It is worth repeating that it buys nothing durable: what apt writes still dies with the container, so you end up back at the two answers above.

Compose

Pepe ships a docker-compose.yml, so there is nothing to write:

curl -O https://raw.githubusercontent.com/pepe-agent/pepe/master/docker-compose.yml

It is the docker run above, with the two volumes and the password already in place:

services:
  pepe:
    image: ghcr.io/pepe-agent/pepe:latest
    restart: unless-stopped
    ports:
      - "4000:4000"
    volumes:
      - pepe-data:/data # state: config, agents, conversations. Back this up.
      - pepe-tools:/tools # the agent's home and anything it installs for itself.
    environment:
      # The `:?` is deliberate: with no password the dashboard would 403 every request
      # (a container is not loopback), so compose refuses to start rather than hand you
      # a container that runs and serves nothing.
      PEPE_DASHBOARD_PASSWORD: ${PEPE_DASHBOARD_PASSWORD:?set this in a .env file}
      OPENROUTER_API_KEY: ${OPENROUTER_API_KEY}
      TZ: UTC

volumes:
  pepe-data:
  pepe-tools:

Secrets go in a .env file next to it, never in the compose file and never in the image. Pepe’s configuration refers to them by name ("api_key": "${OPENROUTER_API_KEY}") and resolves them when reading, so the real value only ever exists in the environment:

# .env
PEPE_DASHBOARD_PASSWORD=a-strong-password
OPENROUTER_API_KEY=sk-...

Every secret needs both halves: the value in .env, and a line under environment: naming it. Compose reads .env to fill in the ${...} in the compose file itself, not to populate the container, so a key that is only in .env never reaches Pepe, and you get a “no model configured” with no clue as to why. Add a line for each one you use.

Then, from that directory:

docker compose up -d
docker compose logs -f          # follow the log
docker compose exec pepe bin/pepe remote   # an IEx shell into the running node

Putting it on a server

Everything above runs on one machine, reachable at localhost. Behind a domain, with TLS and a reverse proxy, four more things matter and none of them are Docker’s doing. See Deploying to a server for Compose behind Caddy, Docker Swarm behind Traefik, and Kamal.

Upgrading

With Compose:

docker compose pull
docker compose up -d

Without it:

docker pull ghcr.io/pepe-agent/pepe
docker rm -f pepe
docker run -d ... ghcr.io/pepe-agent/pepe   # same volumes, same flags

Configuration, agents and conversations come back with /data. The agent’s tools, its home directory and every cache in it come back with /tools, so it does not reinstall anything on the first message. Packages installed with apt do not come back, which is why the image exists for those.

One thing to know: docker compose down stops the containers and leaves the volumes alone, which is what you want. docker compose down -v also deletes them, and with them the entire installation.

A shell into the node

docker exec -it pepe bin/pepe remote            # or: docker compose exec pepe bin/pepe remote

Opens an IEx shell attached to the running release, for inspecting the system from the inside.

For a single pepe CLI command instead of a full shell, use bin/pepe rpc with dispatch_attached/1, the same commands pepe runs anywhere else, made safe to run against a node that’s already serving:

docker exec pepe bin/pepe rpc 'Mix.Tasks.Pepe.dispatch_attached(["agent", "list"])'

bin/pepe <command> on its own does not work here: the container is a plain release, and its CLI dispatch only activates for the separately-distributed Burrito binary. bin/pepe rpc/remote is the way in either way. dispatch_attached/1 exists because plenty of commands (plugin install, eval, doctor, cron, and others, not just run/chat/tui) otherwise flip global settings meant to be decided once, at boot. That’s harmless for a fresh CLI process that exits right after, but not for a node that’s already up and staying up.