Skip to content

tomusdrw/nori

Repository files navigation

Nori

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.

Requirements

  • Docker socket access (/var/run/docker.sock)
  • Go 1.25+ (for building from source)
  • Bash and tmux (for the browser terminal)

Quick start

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/deploybot

Open http://localhost:8080 and log in with your password.

Docker (recommended)

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.

Choose an application image

export DEPLOYBOT_IMAGE=registry.example.com/your-org/deploybot:latest
docker pull "$DEPLOYBOT_IMAGE"

docker compose

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"

docker run

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.

Private-image authentication

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.

Reverse proxy

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.com

Add --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.

Existing installations

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.

Browser terminal

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.

Per-service configuration

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.

Per-service contract

Each service requires two declarations in your deploy script:

  1. Watched image — configured in the UI (e.g. registry.example.com/you/app:latest). This image's digest drives "update available" and auto-deploy.
  2. Container label — add --label deploybot.service=$SERVICE to every docker 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"

Auto-deploy policies

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 * * *)

Environment variables

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.

Self-updates

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.

Launcher configuration

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" rollback

Maintainer notes

Start 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 managed is_self service and startup reconciliation.
  • internal/executor: handoff-only success for the self-service; it must leave the deployment running.
  • 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.

Security

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

CI recommendation

Stamp images with a readable version for the dashboard:

- run: docker build -t registry.example.com/your-org/your-app:${VERSION} .
- run: docker tag ... :latest

Or set the OCI label org.opencontainers.image.version.

Commands

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 digest

The 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

About

Self-hosted deployment control panel for Docker services on GHCR

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

No releases published

Packages

 
 
 

Contributors