Self-hosted deployment control panel for Docker-based services. Watches container registries for new images, runs your bash deploy scripts, and provides a dashboard to manage services.
- Docker socket access (
/var/run/docker.sock) - Go 1.25+ (for building from source)
- Bash and tmux (for the browser terminal)
For a local binary, set the application secrets yourself:
# Generate secrets
export DEPLOYBOT_KEY=$(head -c 32 /dev/urandom | base64)
export DEPLOYBOT_SESSION_KEY=$(head -c 32 /dev/urandom | base64)
export DEPLOYBOT_ADMIN_HASH=$(go run ./cmd/deploybot hash-password 'your-password')
make build
./bin/deploybotOpen http://localhost:8080 and log in with your password.
The published image talks to the host Docker daemon via a mounted socket. This is
intentional — deploybot orchestrates containers on the host by running your bash scripts
(which call docker). The socket mount makes deploybot root-equivalent on that host, so
keep auth enabled and put Cloudflare Access (or similar) in front.
export DEPLOYBOT_IMAGE=registry.example.com/your-org/deploybot:latest
docker pull "$DEPLOYBOT_IMAGE"docker compose run --rm -it launcher up \
--image "$DEPLOYBOT_IMAGE" \
--port "${DEPLOYBOT_PORT:-8080}:8080"The first run asks for an admin password, generates the encryption and session
keys, writes them to the deploybot-config volume, and creates the long-running
deploybot container. Later up invocations read that saved configuration and
recreate the container without prompting or changing any secrets.
For a scripted first boot, generate the admin password hash without a local Go
install and pass it to up:
export DEPLOYBOT_ADMIN_HASH=$(docker run --rm "$DEPLOYBOT_IMAGE" \
hash-password 'your-password')
docker compose run --rm launcher up \
--image "$DEPLOYBOT_IMAGE" \
--port "${DEPLOYBOT_PORT:-8080}:8080" \
--admin-password-hash "$DEPLOYBOT_ADMIN_HASH"export IMAGE=registry.example.com/your-org/deploybot:latest
docker run --rm -it \
-v /var/run/docker.sock:/var/run/docker.sock \
-v deploybot-config:/config \
"$IMAGE" \
up --image "$IMAGE"Use --data-volume, --config-volume, --container-name, repeat --port,
--no-port, --network, or repeat --env/--volume at first boot to change
the defaults. The port, network, volume, and environment options can also
intentionally update an existing launch configuration. The launcher stores a
human-editable run.json and deploybot.env on the config volume;
deploybot.env contains plaintext secrets, so it has the same sensitive trust
boundary as docker.sock.
Watching a private package needs registry credentials. Both the in-process
digest poll and your deploy script's docker pull run inside the deploybot
container, which does not see the host user's docker login. Give the container
access to those credentials by mounting your Docker config with --volume:
docker run --rm -it \
-v /var/run/docker.sock:/var/run/docker.sock \
-v deploybot-config:/config \
"$IMAGE" up \
--image "$IMAGE" \
--volume "$HOME/.docker/config.json:/root/.docker/config.json:ro"The mount is persisted in run.json and survives self-updates. This works when
~/.docker/config.json holds an inline auths token (the default after a docker login
on a Linux host). It does not work if your host uses a
credential helper (credsStore/credHelpers, e.g. Docker Desktop's
desktop), because the mounted file only references a helper binary that is
absent from the container — in that case generate a config with an inline token
and mount that instead.
If a Docker-aware reverse proxy routes containers using VIRTUAL_HOST and
VIRTUAL_PORT, let it own external port exposure. For an nginx-proxy
certificate companion, also set LETSENCRYPT_HOST. On first boot, omit the host
port mapping and persist the proxy variables with the launcher:
docker run --rm -it \
-v /var/run/docker.sock:/var/run/docker.sock \
-v deploybot-config:/config \
"$IMAGE" up \
--image "$IMAGE" \
--no-port \
--env VIRTUAL_HOST=deploybot.example.com \
--env VIRTUAL_PORT=8080 \
--env LETSENCRYPT_HOST=deploybot.example.comAdd --network your-proxy-network when the proxy requires deploybot to join a
specific Docker network. The --env, --port/--no-port, --network, and
--volume options also update an existing launcher configuration without
regenerating its secrets, so the same command repairs a first boot that failed
because port 8080 was already in use. These settings are retained for every
self-update.
The image ships bash, tmux, and the docker CLI so your deploy scripts,
launcher, and browser terminal can call Docker against the host daemon through
the mounted socket.
An existing container started directly with docker run has no launcher config
volume and therefore cannot safely self-update yet. Re-bootstrap through up
once, passing the old DEPLOYBOT_KEY, DEPLOYBOT_SESSION_KEY, and
DEPLOYBOT_ADMIN_HASH as --key, --session-key, and
--admin-password-hash on that first run if you need to keep the existing
encrypted service environment values. The migration is deliberate; the launcher
never guesses a container's run configuration.
The Terminal link opens an interactive Bash shell in the app container. It is backed
by one named tmux session, so closing the tab or losing the WebSocket connection only
detaches the browser: commands keep running and the next connection reattaches to the
same shell. Type exit when you intentionally want to end the session. Restarting or
replacing the container ends the shell process, while files written under /data remain
on the persistent volume.
The shell starts in DEPLOYBOT_TERMINAL_DIR (. by default and /data in the supplied
Docker image). Reverse proxies must support WebSocket upgrades for /terminal/ws.
The terminal is protected by the same admin session as the rest of the app and rejects cross-origin WebSocket connections. It can run arbitrary commands with the app's permissions, including use of the mounted Docker socket, so treat terminal access as root access to the Docker host.
Each service is configured with a watched image, one complete .env document, and a
Bash deployment script. The form uses code editors with syntax highlighting, line
numbers, and live validation. Invalid dotenv or Bash syntax cannot be saved, and Bash
is checked again immediately before every deployment.
The .env document is kept exactly as entered, including comments and blank lines. The
complete document is encrypted at rest in SQLite because any value may be sensitive.
Each service requires two declarations in your deploy script:
- Watched image — configured in the UI (e.g.
registry.example.com/you/app:latest). This image's digest drives "update available" and auto-deploy. - Container label — add
--label deploybot.service=$SERVICEto everydocker run.
The app injects these variables into your script's environment on every deploy:
| Variable | Value |
|---|---|
$SERVICE |
The service name. |
$IMAGE |
The watched image reference, e.g. registry.example.com/you/app:latest. |
$TARGET_DIGEST |
The digest being deployed, e.g. sha256:…. |
$TARGET_IMAGE |
The digest-pinned reference repo@sha256:…. Pull this to deploy the exact digest. |
$ENV_FILE |
Path to the service's .env, materialized for docker run --env-file "$ENV_FILE". |
Every variable from the service's .env document is also exported directly into the
script's shell. $ENV_FILE is written 0600, holds only the service env (not the
variables above), and is removed once the deploy finishes — so pass it to the container
with --env-file rather than forwarding each value with -e by hand. Because the docker
CLI reads --env-file locally, this works whether deploybot runs as a container or a
local binary; it does not write a file inside the deployed container.
Example deploy script snippet:
docker pull "$TARGET_IMAGE"
# ... backup steps ...
docker rm -f "$SERVICE" 2>/dev/null || true
docker run -d --name "$SERVICE" \
--label deploybot.service="$SERVICE" \
--env-file "$ENV_FILE" \
"$TARGET_IMAGE"| Policy | Behavior |
|---|---|
manual |
Deploy only via dashboard button |
immediate |
Auto-deploy when registry digest changes (polled every 60s) |
scheduled |
Deploy on cron schedule (e.g. 0 3 * * *) |
| Variable | Required | Description |
|---|---|---|
DEPLOYBOT_KEY |
yes | Base64-encoded 32-byte AES key for encrypting secret env vars |
DEPLOYBOT_SESSION_KEY |
yes | Base64-encoded 32+ byte key for signing session cookies |
DEPLOYBOT_ADMIN_HASH |
yes | Bcrypt hash of admin password (deploybot hash-password) |
DEPLOYBOT_DB |
no | SQLite path (default: deploybot.db) |
DEPLOYBOT_LISTEN |
no | Listen address (default: :8080) |
DEPLOYBOT_DOCKER_HOST |
no | Docker host override |
DEPLOYBOT_TERMINAL_DIR |
no | Initial terminal directory (default: current directory; Docker image: /data) |
DEPLOYBOT_POLL_INTERVAL |
no | Registry poll interval (default: 60s) |
When started by the launcher, DEPLOYBOT_KEY, DEPLOYBOT_SESSION_KEY, and
DEPLOYBOT_ADMIN_HASH are generated once and read from /config/deploybot.env.
The launcher also sets DEPLOYBOT_CONFIG_VOLUME, DEPLOYBOT_SELF_CONTAINER,
and DEPLOYBOT_SELF_IMAGE; do not set only some of these manually.
Launcher-managed installations automatically add a protected deploybot
service to the dashboard. Its policy defaults to manual, so a newly published
image appears as an update that you deploy while watching. The normal deploy
history is used: after the handoff starts, the row remains running until the
new instance starts and verifies its own digest.
Deploying this service deliberately interrupts the browser connection, including
any browser terminal session. Wait for deploybot to return at the same address,
then refresh its deployment history; that is when the handoff is resolved to
success or failed.
The deploybot-config volume is the source of truth for a launcher-managed
installation:
| File | Purpose | Editing guidance |
|---|---|---|
/config/run.json |
Image, container name, ports, volumes, labels, restart policy, and current/previous digests | Edit only to intentionally change the launcher-owned container configuration; then run deploybot up from a detached launcher to apply it. |
/config/deploybot.env |
Application configuration and generated secrets | Treat as a secret. Do not regenerate DEPLOYBOT_KEY after the first start, or encrypted service environments become unreadable. |
Do not replace the managed self-service script or its launcher identity variables. The launcher must remain outside deploybot's container so it can survive the container swap.
There is intentionally no health check or automatic rollback. If a self-update leaves deploybot unavailable, run a detached launcher manually with the saved config volume, for example:
docker run --rm \
-v /var/run/docker.sock:/var/run/docker.sock \
-v deploybot-config:/config \
"$DEPLOYBOT_IMAGE" rollbackStart with the approved self-update design before changing this feature. The implementation is intentionally split as follows:
internal/launcher: persistent config, first boot, Docker CLI swap, and rollback.internal/store: the managedis_selfservice and startup reconciliation.internal/executor: handoff-only success for the self-service; it must leave the deploymentrunning.cmd/deploybot/self.go: startup seeding and the final digest comparison.
The non-negotiable invariants are that the launcher config remains canonical,
DEPLOYBOT_KEY is never regenerated after bootstrap, and only the replacement
instance may resolve a successful self-deployment. Keep tests around these
boundaries when extending the feature.
This app mounts docker.sock, making it root-equivalent on the host. Requirements:
- Always use authentication (enabled by default)
- Put Cloudflare Access (or similar) in front as a second layer
- Never expose without TLS in production
- Treat browser terminal access as unrestricted administrator access to the host
Stamp images with a readable version for the dashboard:
- run: docker build -t registry.example.com/your-org/your-app:${VERSION} .
- run: docker tag ... :latestOr set the OCI label org.opencontainers.image.version.
deploybot # start server
deploybot hash-password PWD # generate bcrypt hash for DEPLOYBOT_ADMIN_HASH
deploybot seed-demo # insert a demo service row
deploybot up --image IMAGE # bootstrap/recreate from launcher config
deploybot update --target-digest sha256:... # swap to an image digest
deploybot rollback # swap to the previous recorded digestThe browser editor and terminal bundles are committed, so building the Go binary does
not require a JavaScript toolchain. If you change internal/web/editor.js or
internal/web/terminal.js, rebuild them with Bun:
make assets