Self-hosted deployment of the Ghost Agent Platform via docker compose.
The stack runs the gateway, credential proxy, worker fleet, in-stack updater, MongoDB, and a Caddy reverse proxy as docker containers on a single host.
- Host: Linux with a public IPv4 and SSH access. Reference sizing: 4 GB RAM / 2 vCPUs / 120 GB disk, Ubuntu 24.04 (LTS) x64.
- Docker: Engine 24+ with the
docker composev5.x plugin. The major must be v5.x because in-stack upgrades run Compose v5.x and the host's plugin must match;setup.shchecks and refuses to run otherwise. Install step 2 below has a pinned plugin install - do not use Ubuntu'sdocker-compose-v2package, which ships a different major. - Docker Hub access token (OAT), issued by Ghost during onboarding.
- Outbound HTTPS to
docker.io(and its Cloudflare-backed CDN atproduction.cloudflare.docker.com) for image pulls, and togithub.com/objects.githubusercontent.comfor the compose plugin download. - TLS, one of:
- Automatic Let's Encrypt (most common): ports 80/443 reachable
from the internet. No domain required - the install defaults to
<dashed-public-ip>.nip.io(e.g.203-0-113-45.nip.io), which resolves to that IP and works fine with Let's Encrypt. - Bring your own cert: an existing TLS cert + private key plus DNS pointing at this host. Required when DNS is private, the host can't accept inbound traffic from the LE servers, or your org uses an internal CA.
- Automatic Let's Encrypt (most common): ports 80/443 reachable
from the internet. No domain required - the install defaults to
- Deploy directory must be
/opt/exo. In-stack upgrades rundocker composewith the project directory fixed at/opt/exo, so the compose file's relative bind mounts (./config.toml,./Caddyfile, ...) must resolve there on the host;setup.shrefuses to run anywhere else.
Clone into /opt/exo:
git clone https://github.com/ghostsecurity/ghost-agent-docker.git /opt/exo
cd /opt/exoOr, if you don't want to install git on the host, copy a local clone
with rsync:
rsync -avz -e "ssh -i ~/.ssh/<ssh-key>" \
~/Path/to/cloned/ghost-agent-docker/ \
root@<instance-ip>:/opt/exo/On a stock Ubuntu image:
apt-get update
apt-get install -y docker.io curl
COMPOSE_VERSION=v5.1.3
COMPOSE_SHA256=a0298760c9772d2c06888fc8703a487c94c3c3b0134adeef830742a2fc7647b4
mkdir -p /usr/local/lib/docker/cli-plugins
curl -fsSL "https://github.com/docker/compose/releases/download/${COMPOSE_VERSION}/docker-compose-linux-x86_64" \
-o /usr/local/lib/docker/cli-plugins/docker-compose
echo "${COMPOSE_SHA256} /usr/local/lib/docker/cli-plugins/docker-compose" | sha256sum -c -
chmod +x /usr/local/lib/docker/cli-plugins/docker-composeRun as the same user that will run docker compose:
docker login -u ghostsecurityhq
# paste the OAT when promptedThe host login is needed for the initial docker compose pull (step 5)
and any manual pulls you run from the shell later. The in-stack updater
authenticates itself separately at container start using the OAT in
.env.
Run the interactive script:
./setup.shIt prompts for the per-deployment inputs (release tag, public domain -
auto-detected as nip.io, admin email + password, Docker Hub OAT, TLS
flavor), auto-generates ENCRYPTION_KEY and jwt_secret, and writes
.env, config.toml, config.proxy.toml, and Caddyfile from their
.example templates. It also fetches the docker-compose.yml for the
chosen release tag (the compose file is versioned with each release,
not shipped in this repo). It refuses to overwrite existing config
files - delete them and re-run to regenerate.
Or, to configure by hand: copy each *.example to its target name and
replace every empty REQUIRED value and TODO comment (inline comments
document each one). For BYO-cert, also mkdir -p certs/ and place
fullchain.pem + privkey.pem there. Then fetch the compose file for
your tag: oras pull docker.io/ghostsecurityhq/exo-stack:<TAG> -o ..
docker compose pull
docker compose up -dThe first up takes a minute or two: MongoDB initializes its replica
set, the credential proxy generates its CA, the UI bundle is copied
into the shared volume, and Caddy provisions a cert (LE flavor only).
docker compose psAll services should be running (with database showing healthy).
Open https://<your-domain> in a browser, log in with the seed admin
credentials from step 4, and rotate the password from the UI.
The in-stack updater polls Docker Hub every 10 minutes for new release
tags. When a newer vX.Y.Z is available, the "Upgrade" button in the
UI's System view lights up; click it to upgrade the running stack in
place. A release can add, remove, or reconfigure containers - not just
bump image tags - and each upgrade overwrites the local
docker-compose.yml with the release's, so don't edit it in place.
To upgrade out of band (or to bump the updater image itself, which the in-UI upgrade deliberately doesn't touch):
sed -i 's/^TAG=.*/TAG=vX.Y.Z/' .env
docker compose pull
docker compose up -ddocker compose logs -f --tail=100 gateway
docker compose logs -f --tail=100 credential-proxy
docker compose logs -f --tail=100 exo-updater
docker compose logs -f --tail=100 workerbackup.sh takes an online, zero-downtime backup of everything
needed to rebuild the deployment - no stop, no write-blocking:
- MongoDB via
mongodump --oplog(a consistent point-in-time online dump) - the other named volumes (TLS CA private keys, runner identities, Caddy certs, artifacts) via a read-only live tar
- the runtime config (
.env,config.toml,config.proxy.toml,Caddyfile,certs/) and the resolveddocker-compose.yml
cd /opt/exo
./backup.sh # -> ./backups/<timestamp>
./backup.sh --out=/mnt/bkp # write under a different directoryThe stack keeps serving throughout. The archive includes .env, whose
ENCRYPTION_KEY and EXO_JWT_SECRET are what make a restore usable
(stored credentials and sessions are unrecoverable without them).
Because it contains secrets, store the backup securely and off the
host.
restore.sh rebuilds from a backup - for rolling an instance back, or
for moving to a fresh host. It is destructive: it replaces the current
volumes and config with the backup's, brings the database up on a fresh
volume, and replays the dump into it.
docker login -u ghostsecurityhq # so images for the backed-up tag can be pulled
cd /opt/exo
./restore.sh ./backups/<timestamp>On a fresh host, clone this repo to /opt/exo and run restore.sh
directly - do not run setup.sh first (it would mint new secrets;
the restore brings back the original .env). Verify with
docker compose ps afterward.
| Goal | Where |
|---|---|
| Scale worker replicas | WORKER_REPLICAS in .env, then docker compose up -d worker |
| Bump the updater image only | UPDATER_TAG in .env, then docker compose up -d exo-updater |
| Run behind an existing reverse proxy | Switch the Caddyfile to plain HTTP on another host port (see below) |
| Use named volumes on a specific disk | Configure Docker volume storage out of band (see below) |
| Switch the registry | REGISTRY in .env (must mirror the ghostsecurityhq/exo-* layout) |
| Cap container log size + auto-prune old images | Optional final step in setup.sh (see below) |
Existing reverse proxy: keep Caddy in the stack - it serves the static UI bundle as well as proxying the API. Switch its Caddyfile to plain HTTP on a different host port, then point your external proxy at that port.
Volume placement: the docker-compose.yml is fetched from the
release bundle and overwritten on every upgrade, so don't edit it in
place. Configure the Docker volume's storage out of band instead (e.g.
a local volume driver_opts device, or relocating /var/lib/docker).
Log caps and image pruning: the optional setup.sh step caps each
container's logs at 10MB x 3 rotation (json-file driver) and installs
a daily systemd timer running docker image prune -a --filter until=168h.
Answer 'n' at the prompt to skip.
docker compose down # stop containers, keep volumes
docker compose down -v # also delete volumes (DESTRUCTIVE)down -v removes the MongoDB data volume, Caddy's cert state, the
credential proxy's CA material, and all runner identities. Treat it
like dropping a database - everything has to be reseeded/recreated after.
