{"id":51715454,"url":"https://github.com/tomusdrw/nori","last_synced_at":"2026-07-17T02:09:46.360Z","repository":{"id":371738879,"uuid":"1300899394","full_name":"tomusdrw/nori","owner":"tomusdrw","description":"Self-hosted deployment control panel for Docker services on GHCR","archived":false,"fork":false,"pushed_at":"2026-07-16T15:47:41.000Z","size":426,"stargazers_count":0,"open_issues_count":0,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-07-16T17:23:06.921Z","etag":null,"topics":["deployment","docker","self-hosted"],"latest_commit_sha":null,"homepage":null,"language":"Go","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":null,"status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/tomusdrw.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":null,"code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2026-07-14T20:00:10.000Z","updated_at":"2026-07-16T15:48:20.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/tomusdrw/nori","commit_stats":null,"previous_names":["tomusdrw/nori"],"tags_count":null,"template":false,"template_full_name":null,"purl":"pkg:github/tomusdrw/nori","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/tomusdrw%2Fnori","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/tomusdrw%2Fnori/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/tomusdrw%2Fnori/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/tomusdrw%2Fnori/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/tomusdrw","download_url":"https://codeload.github.com/tomusdrw/nori/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/tomusdrw%2Fnori/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":35563725,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-26T15:22:16.424Z","status":"online","status_checked_at":"2026-07-17T02:00:06.162Z","response_time":116,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["deployment","docker","self-hosted"],"created_at":"2026-07-17T02:09:45.912Z","updated_at":"2026-07-17T02:09:46.346Z","avatar_url":"https://github.com/tomusdrw.png","language":"Go","funding_links":[],"categories":[],"sub_categories":[],"readme":"# Nori\n\nSelf-hosted deployment control panel for Docker-based services. Watches container\nregistries for new images, runs your bash deploy scripts, and provides a dashboard to\nmanage services.\n\n## Requirements\n\n- Docker socket access (`/var/run/docker.sock`)\n- Go 1.25+ (for building from source)\n- Bash and tmux (for the browser terminal)\n\n## Quick start\n\nFor a local binary, set the application secrets yourself:\n\n```bash\n# Generate secrets\nexport DEPLOYBOT_KEY=$(head -c 32 /dev/urandom | base64)\nexport DEPLOYBOT_SESSION_KEY=$(head -c 32 /dev/urandom | base64)\nexport DEPLOYBOT_ADMIN_HASH=$(go run ./cmd/deploybot hash-password 'your-password')\n\nmake build\n./bin/deploybot\n```\n\nOpen http://localhost:8080 and log in with your password.\n\n## Docker (recommended)\n\nThe published image talks to the **host Docker daemon** via a mounted socket. This is\nintentional — deploybot orchestrates containers on the host by running your bash scripts\n(which call `docker`). The socket mount makes deploybot root-equivalent on that host, so\nkeep auth enabled and put Cloudflare Access (or similar) in front.\n\n### Choose an application image\n\n```bash\nexport DEPLOYBOT_IMAGE=registry.example.com/your-org/deploybot:latest\ndocker pull \"$DEPLOYBOT_IMAGE\"\n```\n\n### docker compose\n\n```bash\ndocker compose run --rm -it launcher up \\\n  --image \"$DEPLOYBOT_IMAGE\" \\\n  --port \"${DEPLOYBOT_PORT:-8080}:8080\"\n```\n\nThe first run asks for an admin password, generates the encryption and session\nkeys, writes them to the `deploybot-config` volume, and creates the long-running\n`deploybot` container. Later `up` invocations read that saved configuration and\nrecreate the container without prompting or changing any secrets.\n\nFor a scripted first boot, generate the admin password hash without a local Go\ninstall and pass it to `up`:\n\n```bash\nexport DEPLOYBOT_ADMIN_HASH=$(docker run --rm \"$DEPLOYBOT_IMAGE\" \\\n  hash-password 'your-password')\ndocker compose run --rm launcher up \\\n  --image \"$DEPLOYBOT_IMAGE\" \\\n  --port \"${DEPLOYBOT_PORT:-8080}:8080\" \\\n  --admin-password-hash \"$DEPLOYBOT_ADMIN_HASH\"\n```\n\n### docker run\n\n```bash\nexport IMAGE=registry.example.com/your-org/deploybot:latest\ndocker run --rm -it \\\n  -v /var/run/docker.sock:/var/run/docker.sock \\\n  -v deploybot-config:/config \\\n  \"$IMAGE\" \\\n  up --image \"$IMAGE\"\n```\n\nUse `--data-volume`, `--config-volume`, `--container-name`, repeat `--port`,\n`--no-port`, `--network`, or repeat `--env`/`--volume` at first boot to change\nthe defaults. The port, network, volume, and environment options can also\nintentionally update an existing launch configuration. The launcher stores a\nhuman-editable `run.json` and `deploybot.env` on the config volume;\n`deploybot.env` contains plaintext secrets, so it has the same sensitive trust\nboundary as `docker.sock`.\n\n### Private-image authentication\n\nWatching a **private** package needs registry credentials. Both the in-process\ndigest poll and your deploy script's `docker pull` run *inside* the deploybot\ncontainer, which does not see the host user's `docker login`. Give the container\naccess to those credentials by mounting your Docker config with `--volume`:\n\n```bash\ndocker run --rm -it \\\n  -v /var/run/docker.sock:/var/run/docker.sock \\\n  -v deploybot-config:/config \\\n  \"$IMAGE\" up \\\n  --image \"$IMAGE\" \\\n  --volume \"$HOME/.docker/config.json:/root/.docker/config.json:ro\"\n```\n\nThe mount is persisted in `run.json` and survives self-updates. This works when\n`~/.docker/config.json` holds an inline `auths` token (the default after a `docker login`\non a Linux host). It does **not** work if your host uses a\ncredential *helper* (`credsStore`/`credHelpers`, e.g. Docker Desktop's\n`desktop`), because the mounted file only references a helper binary that is\nabsent from the container — in that case generate a config with an inline token\nand mount that instead.\n\n### Reverse proxy\n\nIf a Docker-aware reverse proxy routes containers using `VIRTUAL_HOST` and\n`VIRTUAL_PORT`, let it own external port exposure. For an nginx-proxy\ncertificate companion, also set `LETSENCRYPT_HOST`. On first boot, omit the host\nport mapping and persist the proxy variables with the launcher:\n\n```bash\ndocker run --rm -it \\\n  -v /var/run/docker.sock:/var/run/docker.sock \\\n  -v deploybot-config:/config \\\n  \"$IMAGE\" up \\\n  --image \"$IMAGE\" \\\n  --no-port \\\n  --env VIRTUAL_HOST=deploybot.example.com \\\n  --env VIRTUAL_PORT=8080 \\\n  --env LETSENCRYPT_HOST=deploybot.example.com\n```\n\nAdd `--network your-proxy-network` when the proxy requires deploybot to join a\nspecific Docker network. The `--env`, `--port`/`--no-port`, `--network`, and\n`--volume` options also update an existing launcher configuration without\nregenerating its secrets, so the same command repairs a first boot that failed\nbecause port 8080 was already in use. These settings are retained for every\nself-update.\n\nThe image ships `bash`, `tmux`, and the `docker` CLI so your deploy scripts,\nlauncher, and browser terminal can call Docker against the host daemon through\nthe mounted socket.\n\n### Existing installations\n\nAn existing container started directly with `docker run` has no launcher config\nvolume and therefore cannot safely self-update yet. Re-bootstrap through `up`\nonce, passing the old `DEPLOYBOT_KEY`, `DEPLOYBOT_SESSION_KEY`, and\n`DEPLOYBOT_ADMIN_HASH` as `--key`, `--session-key`, and\n`--admin-password-hash` on that first run if you need to keep the existing\nencrypted service environment values. The migration is deliberate; the launcher\nnever guesses a container's run configuration.\n\n## Browser terminal\n\nThe **Terminal** link opens an interactive Bash shell in the app container. It is backed\nby one named tmux session, so closing the tab or losing the WebSocket connection only\ndetaches the browser: commands keep running and the next connection reattaches to the\nsame shell. Type `exit` when you intentionally want to end the session. Restarting or\nreplacing the container ends the shell process, while files written under `/data` remain\non the persistent volume.\n\nThe shell starts in `DEPLOYBOT_TERMINAL_DIR` (`.` by default and `/data` in the supplied\nDocker image). Reverse proxies must support WebSocket upgrades for `/terminal/ws`.\n\nThe terminal is protected by the same admin session as the rest of the app and rejects\ncross-origin WebSocket connections. It can run arbitrary commands with the app's\npermissions, including use of the mounted Docker socket, so treat terminal access as\nroot access to the Docker host.\n\n## Per-service configuration\n\nEach service is configured with a watched image, one complete `.env` document, and a\nBash deployment script. The form uses code editors with syntax highlighting, line\nnumbers, and live validation. Invalid dotenv or Bash syntax cannot be saved, and Bash\nis checked again immediately before every deployment.\n\nThe `.env` document is kept exactly as entered, including comments and blank lines. The\ncomplete document is encrypted at rest in SQLite because any value may be sensitive.\n\n## Per-service contract\n\nEach service requires two declarations in your deploy script:\n\n1. **Watched image** — configured in the UI (e.g. `registry.example.com/you/app:latest`). This image's\n   digest drives \"update available\" and auto-deploy.\n2. **Container label** — add `--label deploybot.service=$SERVICE` to every `docker run`.\n\nThe app injects these variables into your script's environment on every deploy:\n\n| Variable | Value |\n|----------|-------|\n| `$SERVICE` | The service name. |\n| `$IMAGE` | The watched image reference, e.g. `registry.example.com/you/app:latest`. |\n| `$TARGET_DIGEST` | The digest being deployed, e.g. `sha256:…`. |\n| `$TARGET_IMAGE` | The digest-pinned reference `repo@sha256:…`. Pull this to deploy the exact digest. |\n| `$ENV_FILE` | Path to the service's `.env`, materialized for `docker run --env-file \"$ENV_FILE\"`. |\n\nEvery variable from the service's `.env` document is also exported directly into the\nscript's shell. `$ENV_FILE` is written `0600`, holds only the service env (not the\nvariables above), and is removed once the deploy finishes — so pass it to the container\nwith `--env-file` rather than forwarding each value with `-e` by hand. Because the docker\nCLI reads `--env-file` locally, this works whether deploybot runs as a container or a\nlocal binary; it does **not** write a file inside the deployed container.\n\nExample deploy script snippet:\n\n```bash\ndocker pull \"$TARGET_IMAGE\"\n# ... backup steps ...\ndocker rm -f \"$SERVICE\" 2\u003e/dev/null || true\ndocker run -d --name \"$SERVICE\" \\\n  --label deploybot.service=\"$SERVICE\" \\\n  --env-file \"$ENV_FILE\" \\\n  \"$TARGET_IMAGE\"\n```\n\n## Auto-deploy policies\n\n| Policy | Behavior |\n|--------|----------|\n| `manual` | Deploy only via dashboard button |\n| `immediate` | Auto-deploy when registry digest changes (polled every 60s) |\n| `scheduled` | Deploy on cron schedule (e.g. `0 3 * * *`) |\n\n## Environment variables\n\n| Variable | Required | Description |\n|----------|----------|-------------|\n| `DEPLOYBOT_KEY` | yes | Base64-encoded 32-byte AES key for encrypting secret env vars |\n| `DEPLOYBOT_SESSION_KEY` | yes | Base64-encoded 32+ byte key for signing session cookies |\n| `DEPLOYBOT_ADMIN_HASH` | yes | Bcrypt hash of admin password (`deploybot hash-password`) |\n| `DEPLOYBOT_DB` | no | SQLite path (default: `deploybot.db`) |\n| `DEPLOYBOT_LISTEN` | no | Listen address (default: `:8080`) |\n| `DEPLOYBOT_DOCKER_HOST` | no | Docker host override |\n| `DEPLOYBOT_TERMINAL_DIR` | no | Initial terminal directory (default: current directory; Docker image: `/data`) |\n| `DEPLOYBOT_POLL_INTERVAL` | no | Registry poll interval (default: `60s`) |\n\nWhen started by the launcher, `DEPLOYBOT_KEY`, `DEPLOYBOT_SESSION_KEY`, and\n`DEPLOYBOT_ADMIN_HASH` are generated once and read from `/config/deploybot.env`.\nThe launcher also sets `DEPLOYBOT_CONFIG_VOLUME`, `DEPLOYBOT_SELF_CONTAINER`,\nand `DEPLOYBOT_SELF_IMAGE`; do not set only some of these manually.\n\n## Self-updates\n\nLauncher-managed installations automatically add a protected **deploybot**\nservice to the dashboard. Its policy defaults to manual, so a newly published\nimage appears as an update that you deploy while watching. The normal deploy\nhistory is used: after the handoff starts, the row remains `running` until the\nnew instance starts and verifies its own digest.\n\nDeploying this service deliberately interrupts the browser connection, including\nany browser terminal session. Wait for deploybot to return at the same address,\nthen refresh its deployment history; that is when the handoff is resolved to\n`success` or `failed`.\n\n### Launcher configuration\n\nThe `deploybot-config` volume is the source of truth for a launcher-managed\ninstallation:\n\n| File | Purpose | Editing guidance |\n|---|---|---|\n| `/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. |\n| `/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. |\n\nDo not replace the managed self-service script or its launcher identity\nvariables. The launcher must remain outside deploybot's container so it can\nsurvive the container swap.\n\nThere is intentionally no health check or automatic rollback. If a self-update\nleaves deploybot unavailable, run a detached launcher manually with the saved\nconfig volume, for example:\n\n```bash\ndocker run --rm \\\n  -v /var/run/docker.sock:/var/run/docker.sock \\\n  -v deploybot-config:/config \\\n  \"$DEPLOYBOT_IMAGE\" rollback\n```\n\n## Maintainer notes\n\nStart with the [approved self-update design](docs/superpowers/specs/2026-07-15-deploybot-self-update-design.md)\nbefore changing this feature. The implementation is intentionally split as follows:\n\n- `internal/launcher`: persistent config, first boot, Docker CLI swap, and rollback.\n- `internal/store`: the managed `is_self` service and startup reconciliation.\n- `internal/executor`: handoff-only success for the self-service; it must leave the deployment `running`.\n- `cmd/deploybot/self.go`: startup seeding and the final digest comparison.\n\nThe non-negotiable invariants are that the launcher config remains canonical,\n`DEPLOYBOT_KEY` is never regenerated after bootstrap, and only the replacement\ninstance may resolve a successful self-deployment. Keep tests around these\nboundaries when extending the feature.\n\n## Security\n\nThis app mounts `docker.sock`, making it **root-equivalent on the host**. Requirements:\n\n- Always use authentication (enabled by default)\n- Put **Cloudflare Access** (or similar) in front as a second layer\n- Never expose without TLS in production\n- Treat browser terminal access as unrestricted administrator access to the host\n\n## CI recommendation\n\nStamp images with a readable version for the dashboard:\n\n```yaml\n- run: docker build -t registry.example.com/your-org/your-app:${VERSION} .\n- run: docker tag ... :latest\n```\n\nOr set the OCI label `org.opencontainers.image.version`.\n\n## Commands\n\n```bash\ndeploybot                    # start server\ndeploybot hash-password PWD  # generate bcrypt hash for DEPLOYBOT_ADMIN_HASH\ndeploybot seed-demo          # insert a demo service row\ndeploybot up --image IMAGE   # bootstrap/recreate from launcher config\ndeploybot update --target-digest sha256:...  # swap to an image digest\ndeploybot rollback            # swap to the previous recorded digest\n```\n\nThe browser editor and terminal bundles are committed, so building the Go binary does\nnot require a JavaScript toolchain. If you change `internal/web/editor.js` or\n`internal/web/terminal.js`, rebuild them with Bun:\n\n```bash\nmake assets\n```\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Ftomusdrw%2Fnori","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Ftomusdrw%2Fnori","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Ftomusdrw%2Fnori/lists"}