An open API service indexing awesome lists of open source software.

https://github.com/tomusdrw/nori

Self-hosted deployment control panel for Docker services on GHCR
https://github.com/tomusdrw/nori

deployment docker self-hosted

Last synced: 3 days ago
JSON representation

Self-hosted deployment control panel for Docker services on GHCR

Awesome Lists containing this project

README

          

# 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:

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

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

### docker compose

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

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

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

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

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

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

```bash
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](docs/superpowers/specs/2026-07-15-deploybot-self-update-design.md)
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:

```yaml
- 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

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

```bash
make assets
```