mirror of
https://github.com/tiennm99/composes.git
synced 2026-10-16 20:14:06 +00:00
Compare commits
4
Commits
main
..
0ed09bc0f5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0ed09bc0f5 | ||
|
|
f9a663b998 | ||
|
|
e8ce5207e0 | ||
|
|
8241914411 |
No files matched your search
@@ -1,60 +0,0 @@
|
|||||||
# Environment and secrets
|
|
||||||
|
|
||||||
## Environment variable order
|
|
||||||
|
|
||||||
`environment:` entries are ordered by how badly the service needs them — not
|
|
||||||
alphabetically, and not by when they were added:
|
|
||||||
|
|
||||||
1. **Must have** — without it the service does not do its job, or is exposed.
|
|
||||||
Auth secrets, the uid/gid its files belong to, host allow-lists, proxy
|
|
||||||
trust, and the mod list that supplies the toolchain.
|
|
||||||
2. **Should have** — it starts without these, but behaves wrongly for this
|
|
||||||
setup: timezone, default workspace, shell, git identity, pinned tool
|
|
||||||
versions, extra packages.
|
|
||||||
3. **Optional** — cosmetics and conveniences; dropping one changes nothing
|
|
||||||
functional. Window titles, prompt labels.
|
|
||||||
|
|
||||||
Grouping wins over the tiers. Variables that belong together stay on adjacent
|
|
||||||
lines — `PUID`/`PGID`, `PASSWORD`/`SUDO_PASSWORD`, the four `GIT_*` entries,
|
|
||||||
the `PASEO_*` daemon settings, `DOCKER_MODS` with the `INSTALL_PACKAGES` and
|
|
||||||
`NODEJS_MOD_VERSION` that configure it — and the whole group sits at the tier
|
|
||||||
of its most important member, even when a member on its own would rank lower.
|
|
||||||
Within a group, the variable others configure comes first.
|
|
||||||
|
|
||||||
Optional variables are listed but commented out, in the form
|
|
||||||
`# - KEY=${KEY:-default}` (`# KEY: ${KEY:-default}` in a map-style
|
|
||||||
`environment:`), so the service runs without them and enabling one
|
|
||||||
means uncommenting its line. The matching `.env.example` entry is commented out
|
|
||||||
the same way (`# KEY=default`). Must-have and should-have variables stay active.
|
|
||||||
|
|
||||||
Do not write the tier into the file as a comment — the order is the
|
|
||||||
documentation. Where every variable is required, as in `alloy`, the tiers
|
|
||||||
collapse and the existing grouping stands.
|
|
||||||
|
|
||||||
`.env.example` follows its compose file's order. The names differ — one
|
|
||||||
`PASSWORD` can feed several container variables, and `SERVICE_HOSTNAME` feeds
|
|
||||||
`hostname:` and `HOST` — so each entry sits where the first compose entry that reads it sits.
|
|
||||||
Reordering a compose file means reordering the `.env.example` with it.
|
|
||||||
|
|
||||||
## Secrets
|
|
||||||
|
|
||||||
Every service reads secrets from a sibling `.env`. Never commit one — the root
|
|
||||||
`.gitignore` covers `.env`/`*.env` and re-includes `.env.example`. Keep
|
|
||||||
`.env.example` in sync whenever a compose file gains or drops a variable.
|
|
||||||
|
|
||||||
`.env.example` is a template for anyone, so every value in it stays generic —
|
|
||||||
the service's own name, a placeholder domain, or an empty string. Never a real
|
|
||||||
hostname, git identity, email, domain or account name. Personal values are set
|
|
||||||
per deployment, in the Coolify or Dokploy environment for that app, and live
|
|
||||||
only in the gitignored `.env`.
|
|
||||||
|
|
||||||
Compose interpolation reads the deploying shell's environment before the
|
|
||||||
`.env` file, so a variable must not share a name with anything the shell
|
|
||||||
already exports. `HOSTNAME` is the trap: it is set inside every container,
|
|
||||||
including the one Coolify itself runs in, and would silently win. Hence
|
|
||||||
`SERVICE_HOSTNAME` in `code-server` and `paseo`.
|
|
||||||
|
|
||||||
A value stored in the Coolify app's environment, including its preview copy,
|
|
||||||
beats the `${VAR:-default}` default in `compose.yml`. Changing a default in the
|
|
||||||
repo does nothing while that stored value exists; clear it in Coolify, then
|
|
||||||
redeploy.
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
# Images and comments
|
|
||||||
|
|
||||||
## Installing software in an image
|
|
||||||
|
|
||||||
Follow the upstream project's own documented install method, or the one the
|
|
||||||
community has settled on. Do not hand-roll a download, and do not take a stale
|
|
||||||
distro package just because `apt install` is shorter — check what version it
|
|
||||||
actually gives you first.
|
|
||||||
|
|
||||||
Where it gets installed depends on the kind of service. In a workspace service
|
|
||||||
(see `workspace-services.md`), install tools into a location that survives a
|
|
||||||
redeploy — the container user's home volume, such as `~/.local/bin` — not into
|
|
||||||
the image. The
|
|
||||||
exception is a system package that is more than a single binary — shared
|
|
||||||
libraries, a daemon, anything that hooks into `/etc` or the system paths. That
|
|
||||||
goes in the image, through the system package manager. Every other service
|
|
||||||
installs into the image.
|
|
||||||
|
|
||||||
## Comments in compose files, Dockerfiles and scripts
|
|
||||||
|
|
||||||
This holds for every file in a service directory, not just the compose file.
|
|
||||||
|
|
||||||
A comment says *what* a section installs, configures or does, in a line or
|
|
||||||
two. It does not explain *why*. Reasons — why not the distro package, why that
|
|
||||||
directory, why a version is pinned, why a step runs here and not there, what
|
|
||||||
would break if it were simplified — go in the service's `README.md`, where
|
|
||||||
they can be read in full and where someone deciding whether to change
|
|
||||||
something will actually look.
|
|
||||||
|
|
||||||
So: no rationale, no trade-offs, no cautionary notes in the file itself. When a
|
|
||||||
choice needs defending, write the defence in the README and let the header
|
|
||||||
comment point at it. Keep the README current whenever a file changes, otherwise
|
|
||||||
the reasoning is simply lost rather than relocated.
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
# Naming
|
|
||||||
|
|
||||||
## File naming
|
|
||||||
|
|
||||||
Every service uses `compose.yml` — the current Compose spec name, and the
|
|
||||||
shorter one. Not `docker-compose.yml`.
|
|
||||||
|
|
||||||
## Names
|
|
||||||
|
|
||||||
A service directory is named after the software it runs. When two services
|
|
||||||
package the same software, the one that is not upstream's own image takes a
|
|
||||||
suffix naming its source — `<software>-lsio` for a LinuxServer image —
|
|
||||||
so both can coexist. The suffix is a directory name only, forced by the
|
|
||||||
conflict; it is not a name to copy anywhere else.
|
|
||||||
|
|
||||||
Prefer each tool's official image, for the main service and for every
|
|
||||||
supporting container alike. Upstream's official compose file or Docker guide
|
|
||||||
is a starting point, not a spec: adapt it to the conventions in these rules
|
|
||||||
rather than copying its layout and names as they are.
|
|
||||||
|
|
||||||
Inside `compose.yml`, the main service is named after its image —
|
|
||||||
`code-server` for any code-server image, never a suffixed directory name. The
|
|
||||||
exception is an image whose name is not a good identifier on its own — too
|
|
||||||
generic, or longer than it needs to be. Use a short name that still says
|
|
||||||
which software it is: `owncloud` for `owncloud/server`, since `server` alone
|
|
||||||
could be anything; `traffmonetizer` for `traffmonetizer/cli_v2`; `hermes`
|
|
||||||
for `nousresearch/hermes-agent`. A supporting container is named for its
|
|
||||||
role, so the software behind it can be swapped without renaming: `db` for any
|
|
||||||
database, `cache` for Redis, Valkey or Memcached, and a short role name such as
|
|
||||||
`dockerproxy` for anything else (see the examples below). Swapping Redis for
|
|
||||||
Valkey, MySQL for MariaDB, or one SQL database for PostgreSQL then leaves every
|
|
||||||
name, hostname and volume as it is.
|
|
||||||
|
|
||||||
Role names in use or likely, as examples only — any short name that says what
|
|
||||||
the container does is fine, and this list does not limit the choice:
|
|
||||||
|
|
||||||
| Role name | Typical software |
|
|
||||||
| --- | --- |
|
|
||||||
| `db` | PostgreSQL, MySQL, MariaDB, MongoDB, pgvector |
|
|
||||||
| `cache` | Redis, Valkey, Memcached, KeyDB, Dragonfly |
|
|
||||||
| `queue` / `broker` | RabbitMQ, NATS, Kafka |
|
|
||||||
| `search` | Elasticsearch, OpenSearch, Meilisearch, Typesense |
|
|
||||||
| `vector` | Qdrant, Weaviate, Milvus, when separate from `db` |
|
|
||||||
| `storage` | MinIO, SeaweedFS, Garage — S3-compatible object storage |
|
|
||||||
| `worker` | The app's own image running background jobs |
|
|
||||||
| `scheduler` / `cron` | The app's own image running timed jobs |
|
|
||||||
| `migrate` / `init` | One-off setup jobs that run and exit |
|
|
||||||
| `dockerproxy` | `tecnativa/docker-socket-proxy` |
|
|
||||||
| `proxy` | A reverse proxy inside the app, such as nginx or Caddy |
|
|
||||||
| `mail` / `smtp` | Mailpit, a Postfix relay |
|
|
||||||
| `browser` | Headless Chrome (browserless), for agents |
|
|
||||||
| `tunnel` | cloudflared |
|
|
||||||
| `backup` | A database dump or volume backup running alongside |
|
|
||||||
|
|
||||||
A volume is named `<service>-<what it holds>`, after the service that mounts it
|
|
||||||
and its mount point or meaning — `code-server-home`, `code-server-workspace`,
|
|
||||||
`db-data`, `cache-data`.
|
|
||||||
|
|
||||||
Services declare no networks. Coolify creates one per app and attaches every
|
|
||||||
container to it, so there is nothing to add. Should a service ever need its own,
|
|
||||||
it is named after the main service: `code-server-network`.
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
# Workspace services
|
|
||||||
|
|
||||||
A service someone works *inside* — an editor, a coding agent, anything with a
|
|
||||||
shell — gets exactly two named volumes: one for the container user's home
|
|
||||||
directory, one mounted at `/workspace`. The home volume holds settings,
|
|
||||||
credentials and CLI logins; `/workspace` holds the code. Point whatever
|
|
||||||
variable selects the working directory at `/workspace`.
|
|
||||||
|
|
||||||
`code-server`, `paseo` and `webtop` all follow this. A service with no
|
|
||||||
human inside it does not — an agent such as `hermes` or `openclaw` keeps the
|
|
||||||
volume layout of its official Docker guide.
|
|
||||||
|
|
||||||
The split is so that wiping one does not take the other. Reinstalling an editor
|
|
||||||
should not cost you a repository, and deleting a repository should not cost you
|
|
||||||
your extensions and logins.
|
|
||||||
|
|
||||||
Check who owns `/workspace` on a fresh volume. Docker creates it `root:root`
|
|
||||||
unless the image ships the directory, and an image that drops to a non-root
|
|
||||||
user will not be able to write there. `code-server` and `webtop` need an
|
|
||||||
explicit `chown` for this reason; `paseo` does not.
|
|
||||||
@@ -1,110 +0,0 @@
|
|||||||
---
|
|
||||||
name: debug-service
|
|
||||||
description: Debug a service in this compose collection — read its compose definition, pull deploy status and logs from Coolify, check out the matching upstream source into sources/, and prove the root cause before changing the service directory. Use when a service here fails to deploy, crashes, or misbehaves. Not for bugs in the upstream project's own code or in repos outside this collection.
|
|
||||||
---
|
|
||||||
|
|
||||||
# Debug a service
|
|
||||||
|
|
||||||
Find why one service in this repository fails, with evidence, and fix it in
|
|
||||||
that service's directory. Prove the cause before editing anything; stop
|
|
||||||
investigating as soon as it is proven.
|
|
||||||
|
|
||||||
## Boundaries
|
|
||||||
|
|
||||||
- Read `<service>/.env.example`, never `<service>/.env`; it holds real secrets.
|
|
||||||
Do not copy tokens, passwords or keys from logs into reports.
|
|
||||||
- Read-only Coolify calls need no confirmation. `control` (start, stop,
|
|
||||||
restart) and `deploy` affect a live service: ask the user first.
|
|
||||||
- Never commit anything under `sources/`, and never fix a service by editing
|
|
||||||
upstream code there.
|
|
||||||
|
|
||||||
## 1. Frame the issue
|
|
||||||
|
|
||||||
Name the service directory (`<service>/`), the observed symptom and the
|
|
||||||
expected behaviour. If the user did not name the service, match the symptom
|
|
||||||
against the root `README.md` table.
|
|
||||||
|
|
||||||
## 2. Read the local definition
|
|
||||||
|
|
||||||
Read `<service>/compose.yml`, `<service>/README.md`, `<service>/.env.example`,
|
|
||||||
and `<service>/Dockerfile` with any files it copies. For each container note
|
|
||||||
the image and tag (or the Dockerfile's `FROM`), environment variable names,
|
|
||||||
volumes and command.
|
|
||||||
|
|
||||||
Run `git log --oneline -10 -- <service>/`; a recent change is the first suspect.
|
|
||||||
|
|
||||||
## 3. Collect runtime evidence
|
|
||||||
|
|
||||||
One Coolify MCP, `miti-coolify`, manages both servers, `miti-sg` and
|
|
||||||
`miti-jp`; the service may live on either.
|
|
||||||
|
|
||||||
1. `search_resources` with the service name.
|
|
||||||
2. For a failed deploy: `list_deployments`, then `get_deployment` with
|
|
||||||
`include_log_summary=true`.
|
|
||||||
3. `get_logs` only when the resource is running; otherwise follow the
|
|
||||||
returned reason and `next_tools` rather than retrying.
|
|
||||||
4. `list_env_keys` to confirm every variable in `.env.example` is set
|
|
||||||
(names only; values are never returned). When a changed
|
|
||||||
`${VAR:-default}` in `compose.yml` has no effect, check here first: a value
|
|
||||||
stored in Coolify, including its preview copy, overrides the default.
|
|
||||||
Confirm the live value from the container logs.
|
|
||||||
|
|
||||||
If neither server has it, the service is likely on Dokploy, which has no MCP
|
|
||||||
here: ask the user to paste the container logs and deploy output.
|
|
||||||
|
|
||||||
Keep the exact error lines; they are the search keys for step 5. If the logs
|
|
||||||
and compose definition already prove the cause (a missing variable, a wrong
|
|
||||||
path), skip to step 6.
|
|
||||||
|
|
||||||
## 4. Check out the upstream source
|
|
||||||
|
|
||||||
Pick the repositories that matter:
|
|
||||||
|
|
||||||
- `image:` services: the image's upstream repo, found from the registry page,
|
|
||||||
the image's `org.opencontainers.image.source` label, or the service README.
|
|
||||||
- `build: .` services: the Dockerfile in the service directory is local code;
|
|
||||||
also take the `FROM` image's repo, and for a wrapper image (linuxserver,
|
|
||||||
for example) the application repo it packages, if the error comes from it.
|
|
||||||
- Closed-source images have no repo. Say so and work from logs and vendor
|
|
||||||
docs only.
|
|
||||||
|
|
||||||
Resolve the version the container runs. Use an exact tag directly. For a
|
|
||||||
moving tag (`latest`, `:4`), take the version printed in the logs; failing
|
|
||||||
that, the latest release (`gh release view -R <owner>/<repo> --json tagName`),
|
|
||||||
and say it is inferred.
|
|
||||||
|
|
||||||
Clone into `sources/<owner>/<repo>`:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
git clone --depth 1 --branch <tag> https://github.com/<owner>/<repo> sources/<owner>/<repo>
|
|
||||||
```
|
|
||||||
|
|
||||||
If the checkout exists, switch it instead of cloning again:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
git -C sources/<owner>/<repo> fetch --depth 1 origin tag <tag>
|
|
||||||
git -C sources/<owner>/<repo> checkout <tag>
|
|
||||||
```
|
|
||||||
|
|
||||||
## 5. Trace the cause
|
|
||||||
|
|
||||||
- Search the checkout for the exact error text, the failing config key, or
|
|
||||||
the environment variable name.
|
|
||||||
- Read how the code consumes that variable, path or flag, and compare it with
|
|
||||||
what `compose.yml` and the Dockerfile provide.
|
|
||||||
- When it looks like a regression, check the upstream changelog and issues
|
|
||||||
for that version.
|
|
||||||
|
|
||||||
State the cause with evidence: the log line, the source file and line, and
|
|
||||||
the mismatch with the service definition. If evidence is inconclusive, report
|
|
||||||
the hypotheses and what would distinguish them instead of guessing a fix.
|
|
||||||
|
|
||||||
## 6. Fix and report
|
|
||||||
|
|
||||||
Change only `<service>/`, following this repository's `CLAUDE.md` and `.claude/rules/`: comments
|
|
||||||
say *what*, reasons go in the service `README.md`, `.env.example` stays in sync
|
|
||||||
and in compose order. Pin an exact version only when the newer release is
|
|
||||||
proven broken, and write what breaks in the README.
|
|
||||||
|
|
||||||
Report the cause, the evidence, the change, and how to verify after redeploy.
|
|
||||||
Ask before redeploying. Leave `sources/` checkouts in place for next time.
|
|
||||||
@@ -1,117 +0,0 @@
|
|||||||
---
|
|
||||||
name: migrate-service
|
|
||||||
description: Move a running Coolify deployment — typically a one-click Service template or a hand-made app — onto the app built from a service directory in this repo, keeping its data. Finds both resources through the Coolify MCP, copies named volumes with docker, and walks env, domain, watch path, deploy and verification. Use when the user wants to migrate, move or switch an old deployment to the composes-repo version. Not for first-time deploys with no data, nor for debugging a service (debug-service).
|
|
||||||
---
|
|
||||||
|
|
||||||
# Migrate a service onto the composes repo
|
|
||||||
|
|
||||||
Carry one service's data and settings from an existing Coolify resource (the
|
|
||||||
**old** one) to the Coolify app built from `<service>/` in this repo (the
|
|
||||||
**new** one), so the new app resumes exactly where the old one stopped. The
|
|
||||||
old resource stays intact until the user deletes it; it is the rollback.
|
|
||||||
|
|
||||||
## Boundaries
|
|
||||||
|
|
||||||
- Never read Coolify's stored env files or any `.env` holding real values, and
|
|
||||||
never print secrets. The Coolify MCP shows env key names only; values are
|
|
||||||
copied by the user in the Coolify UI.
|
|
||||||
- Ask before `control` stop/start and before `deploy`. Volume copies overwrite
|
|
||||||
the new resource's data: show the dry run and get confirmation before
|
|
||||||
`--apply`.
|
|
||||||
- Delete nothing of the old resource. Deleting it, its volumes, or the backup
|
|
||||||
volume happens only when the user asks, after verification.
|
|
||||||
|
|
||||||
## 1. Identify both resources
|
|
||||||
|
|
||||||
Read `<service>/compose.yml` and `<service>/README.md`. Then
|
|
||||||
`search_resources` with the service name through the Coolify MCP
|
|
||||||
(`miti-coolify`, which manages both servers `miti-sg` and `miti-jp`). The old one is usually a Service (`get_service`,
|
|
||||||
`list_service_applications`, `list_service_databases`); the new one is an
|
|
||||||
Application whose `git_repository` is this repo and `base_directory` is
|
|
||||||
`/<service>`. If the new app does not exist, the user creates it first —
|
|
||||||
it must have deployed once so its volumes exist.
|
|
||||||
|
|
||||||
For each, collect: uuid, server, status, domain (`fqdn` on the old service's
|
|
||||||
application, `docker_compose_domains` on the new app), `watch_paths`,
|
|
||||||
`list_storages`, `list_env_keys`.
|
|
||||||
|
|
||||||
## 2. Compare layouts before copying
|
|
||||||
|
|
||||||
Build a table: old volume and mount path → new volume and mount path. Volumes
|
|
||||||
are named `<uuid>_<compose-volume-name>`. Check, against the new compose file:
|
|
||||||
|
|
||||||
- Paths the old deployment used that the new layout moves (a config file or
|
|
||||||
skills dir that was outside a volume, or in a separate volume the new compose
|
|
||||||
folds into another). Inspect contents read-only to see where things really
|
|
||||||
live:
|
|
||||||
`docker run --rm -v <vol>:/v:ro alpine sh -c 'du -sh /v; ls -la /v'`.
|
|
||||||
- Database images: a raw copy of a data directory needs the same engine major
|
|
||||||
version (e.g. `pgvector:pg18` → `pg18`). On a mismatch, stop and dump/restore
|
|
||||||
instead.
|
|
||||||
- Env keys: list keys present on the old resource and missing or differing on
|
|
||||||
the new. Flag the keys that must carry the **same value**: encryption keys
|
|
||||||
(stored data becomes unreadable otherwise) and database user, password and
|
|
||||||
name (they live inside the copied data directory).
|
|
||||||
|
|
||||||
## 3. Stop both resources
|
|
||||||
|
|
||||||
Confirm with the user, then stop the old and the new resource (`control`
|
|
||||||
action `stop`, `confirm: true`). The copy refuses to run while any container
|
|
||||||
mounts a volume it touches.
|
|
||||||
|
|
||||||
## 4. Copy volumes
|
|
||||||
|
|
||||||
The volumes must be on the Docker host this workspace talks to; check with
|
|
||||||
`docker volume ls | grep -E '<old-uuid>|<new-uuid>'`. If they are not, the
|
|
||||||
service runs on another server: give the user the commands to run in that
|
|
||||||
server's Coolify terminal instead.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
scripts/migrate-volumes.sh --from <old-uuid> --to <new-uuid> [--map old=new ...]
|
|
||||||
scripts/migrate-volumes.sh --from <old-uuid> --to <new-uuid> [--map old=new ...] --apply
|
|
||||||
```
|
|
||||||
|
|
||||||
Dry run first; show its output. It pairs volumes by name, skips empty source
|
|
||||||
volumes, and lists unmatched ones — use `--map` when a name differs, or note
|
|
||||||
the volume as intentionally dropped. With `--apply`, each target volume is
|
|
||||||
archived into the volume `migrate-backup-<new-uuid>`, emptied, then filled
|
|
||||||
from the source; it prints `OK` when entry counts match, `DIFF` otherwise.
|
|
||||||
A file that must land in a subdirectory of another volume is a one-off
|
|
||||||
`docker run ... cp -a` after the script, shown to the user first.
|
|
||||||
|
|
||||||
Mount only Docker volumes or host paths in `docker run -v`. This workspace is
|
|
||||||
itself a container: a workspace path given to `-v` resolves on the host, not
|
|
||||||
here, so backups go into a volume.
|
|
||||||
|
|
||||||
## 5. Settings in the Coolify UI (user)
|
|
||||||
|
|
||||||
Give the user one checklist:
|
|
||||||
|
|
||||||
- Env values to copy from old to new, with the must-match keys from step 2
|
|
||||||
marked. Any optional variable the old one used that the new compose keeps
|
|
||||||
commented out needs uncommenting in `<service>/compose.yml`.
|
|
||||||
- Domain: remove it from the old resource, then set it on the new app's
|
|
||||||
container, keeping the port suffix (`https://host:PORT`).
|
|
||||||
- Watch path on the new app: `<service>/**`.
|
|
||||||
|
|
||||||
## 6. Deploy and verify
|
|
||||||
|
|
||||||
Once the user confirms the settings, `deploy` the new app (ask first). Then
|
|
||||||
check the deployment finished (`list_deployments`, `get_deployment` with
|
|
||||||
`include_log_summary`), the status is healthy, `get_logs` shows no auth,
|
|
||||||
decrypt or migration errors, and the domain answers. Ask the user to confirm
|
|
||||||
their data is there in the app itself.
|
|
||||||
|
|
||||||
## 7. Wrap up
|
|
||||||
|
|
||||||
Report what moved and where the backup volume is. Leave the old resource
|
|
||||||
stopped. When the user is satisfied, they may ask to delete the old resource
|
|
||||||
(in Coolify, with its volumes) and `migrate-backup-<new-uuid>`.
|
|
||||||
|
|
||||||
If the service's README describes setup in a way the migration proved wrong,
|
|
||||||
update it.
|
|
||||||
|
|
||||||
## Resources
|
|
||||||
|
|
||||||
- `scripts/migrate-volumes.sh` — pairs, backs up and copies named volumes
|
|
||||||
between two Coolify resources; dry run by default.
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# Copies the named volumes of an old Coolify resource into the matching volumes
|
|
||||||
# of the new one. Volumes match by the name after the "<uuid>_" prefix. Dry run
|
|
||||||
# by default.
|
|
||||||
#
|
|
||||||
# Usage: migrate-volumes.sh --from <old-uuid> --to <new-uuid> [--map old=new ...] [--apply]
|
|
||||||
# --map pairs volumes whose names differ, e.g. --map db-data=postgres-data.
|
|
||||||
# With --apply, each target volume is first archived into the volume
|
|
||||||
# migrate-backup-<new-uuid>, then emptied and filled with the source contents.
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
FROM=""
|
|
||||||
TO=""
|
|
||||||
APPLY=0
|
|
||||||
declare -A MAP=()
|
|
||||||
while [ $# -gt 0 ]; do
|
|
||||||
case "$1" in
|
|
||||||
--from) FROM="$2"; shift 2 ;;
|
|
||||||
--to) TO="$2"; shift 2 ;;
|
|
||||||
--map) MAP["${2%%=*}"]="${2#*=}"; shift 2 ;;
|
|
||||||
--apply) APPLY=1; shift ;;
|
|
||||||
*) echo "unknown argument: $1" >&2; exit 2 ;;
|
|
||||||
esac
|
|
||||||
done
|
|
||||||
[ -n "$FROM" ] && [ -n "$TO" ] || { echo "--from and --to are required" >&2; exit 2; }
|
|
||||||
[[ "$FROM$TO" =~ ^[a-z0-9]+$ ]] || { echo "uuids must be lowercase alphanumeric" >&2; exit 2; }
|
|
||||||
|
|
||||||
IMG=alpine
|
|
||||||
vols() { docker volume ls --format '{{.Name}}' | sed -n "s/^$1_//p" | sort; }
|
|
||||||
stats() { docker run --rm -v "$1":/v:ro "$IMG" sh -c 'echo "$(du -sk /v | cut -f1)K $(find /v | wc -l) entries"'; }
|
|
||||||
in_use() { docker ps --filter "volume=$1" --format '{{.Names}}'; }
|
|
||||||
|
|
||||||
OLD=$(vols "$FROM")
|
|
||||||
NEW=$(vols "$TO")
|
|
||||||
[ -n "$OLD" ] || { echo "no volumes named ${FROM}_* on this Docker host" >&2; exit 1; }
|
|
||||||
[ -n "$NEW" ] || { echo "no volumes named ${TO}_* on this Docker host; deploy the new resource once first" >&2; exit 1; }
|
|
||||||
|
|
||||||
# Source suffix -> target suffix.
|
|
||||||
declare -A PAIRS=()
|
|
||||||
echo "=== VOLUME PAIRS ==="
|
|
||||||
for o in $OLD; do
|
|
||||||
n="${MAP[$o]:-$o}"
|
|
||||||
if [ -z "$(docker run --rm -v "${FROM}_$o":/v:ro "$IMG" find /v -mindepth 1 -maxdepth 1)" ]; then
|
|
||||||
echo "EMPTY ${FROM}_$o (nothing to copy)"
|
|
||||||
elif ! grep -qx "$n" <<<"$NEW"; then
|
|
||||||
echo "UNMATCHED ${FROM}_$o ($(stats "${FROM}_$o")); pass --map $o=<target> if its data is needed"
|
|
||||||
else
|
|
||||||
PAIRS[$o]="$n"
|
|
||||||
echo "COPY ${FROM}_$o ($(stats "${FROM}_$o")) -> ${TO}_$n ($(stats "${TO}_$n"))"
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
for n in $NEW; do
|
|
||||||
matched=0
|
|
||||||
for o in "${!PAIRS[@]}"; do [ "${PAIRS[$o]}" = "$n" ] && matched=1; done
|
|
||||||
[ $matched = 1 ] || echo "UNTOUCHED ${TO}_$n"
|
|
||||||
done
|
|
||||||
|
|
||||||
busy=""
|
|
||||||
for o in "${!PAIRS[@]}"; do
|
|
||||||
for v in "${FROM}_$o" "${TO}_${PAIRS[$o]}"; do
|
|
||||||
c=$(in_use "$v"); [ -z "$c" ] || busy+=" $v used by: $c"$'\n'
|
|
||||||
done
|
|
||||||
done
|
|
||||||
if [ -n "$busy" ]; then
|
|
||||||
printf 'Containers still mount these volumes; stop both resources first:\n%s' "$busy" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ $APPLY = 0 ]; then
|
|
||||||
echo "${#PAIRS[@]} volume(s) would be overwritten. Re-run with --apply to execute."
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
BACKUP="migrate-backup-$TO"
|
|
||||||
STAMP=$(date +%Y%m%d-%H%M%S)
|
|
||||||
docker volume create "$BACKUP" >/dev/null
|
|
||||||
echo "=== APPLYING (backups in volume $BACKUP) ==="
|
|
||||||
for o in "${!PAIRS[@]}"; do
|
|
||||||
src="${FROM}_$o"; dst="${TO}_${PAIRS[$o]}"
|
|
||||||
docker run --rm -v "$dst":/v:ro -v "$BACKUP":/b "$IMG" tar czf "/b/$STAMP-${PAIRS[$o]}.tgz" -C /v .
|
|
||||||
docker run --rm -v "$src":/from:ro -v "$dst":/to "$IMG" \
|
|
||||||
sh -c 'find /to -mindepth 1 -delete && cp -a /from/. /to/'
|
|
||||||
s=$(stats "$src"); d=$(stats "$dst")
|
|
||||||
if [ "${s#* }" = "${d#* }" ]; then echo " OK $src -> $dst ($d)"
|
|
||||||
else echo " DIFF $src ($s) -> $dst ($d)"; fi
|
|
||||||
done
|
|
||||||
@@ -21,10 +21,3 @@ desktop.ini
|
|||||||
*.swp
|
*.swp
|
||||||
.idea/
|
.idea/
|
||||||
.vscode/
|
.vscode/
|
||||||
|
|
||||||
# Upstream source checkouts for debugging; only the directory itself is kept
|
|
||||||
sources/*
|
|
||||||
!sources/.gitkeep
|
|
||||||
|
|
||||||
# Local agent plans and reports
|
|
||||||
/plans/
|
|
||||||
@@ -4,83 +4,16 @@ Personal docker compose collection. One directory per service, each holding
|
|||||||
`compose.yml`, its own `README.md`, a committed `.env.example`, and a
|
`compose.yml`, its own `README.md`, a committed `.env.example`, and a
|
||||||
gitignored `.env`.
|
gitignored `.env`.
|
||||||
|
|
||||||
Only common, well-known third-party services belong here. Our own tools
|
|
||||||
ship their compose file in their own repo and deploy from there.
|
|
||||||
|
|
||||||
Topic rules live in `.claude/rules/` and load with this file:
|
|
||||||
|
|
||||||
- `naming.md` — file, directory, service, volume and network names, with
|
|
||||||
example role names for supporting containers.
|
|
||||||
- `images-and-comments.md` — where and how software is installed, and what
|
|
||||||
comments in compose files, Dockerfiles and scripts may say.
|
|
||||||
- `workspace-services.md` — the home and `/workspace` volume split for services
|
|
||||||
someone works inside.
|
|
||||||
- `environment-and-secrets.md` — `environment:` order, `.env.example`, and
|
|
||||||
secrets.
|
|
||||||
|
|
||||||
## Deployment target
|
|
||||||
|
|
||||||
Services are deployed through Coolify, not plain `docker compose` on a host.
|
|
||||||
The platform owns the parts a standalone compose file would declare itself.
|
|
||||||
|
|
||||||
Coolify is the primary target: design, test and debug against it first.
|
|
||||||
Dokploy is optional — keep a service working there when it costs nothing
|
|
||||||
(the `restart:` policy below), but never trade Coolify behaviour for Dokploy
|
|
||||||
compatibility, and do not block on Dokploy-only issues.
|
|
||||||
|
|
||||||
## Service READMEs stay inside their directory
|
|
||||||
|
|
||||||
This rule has high priority: editing one service must never redeploy another.
|
|
||||||
A service's `README.md` describes that service and nothing else. It does not
|
|
||||||
name, link to, or compare itself with another service, and it does not link up
|
|
||||||
to the root README, CLAUDE.md or `.claude/rules/`. Shared conventions — the workspace volume
|
|
||||||
split, no published ports, the restart policy, secrets, variable order — are
|
|
||||||
written once at the root and are not restated or "see the root for why"-linked
|
|
||||||
from a service. A service README says what the service is, its variables,
|
|
||||||
storage and wiring, and the reasons behind choices specific to that service.
|
|
||||||
Keep it concise and minimal.
|
|
||||||
|
|
||||||
The root `README.md` is an index only — it covers the shared conventions and
|
The root `README.md` is an index only — it covers the shared conventions and
|
||||||
links out to each service. Per-service detail (variables, ports, storage)
|
links out to each service. Per-service detail (variables, ports, storage)
|
||||||
belongs in that service's README, not the root one. Adding a service means
|
belongs in that service's README, not the root one. Adding a service means
|
||||||
adding its README and a row to the root table.
|
adding its README and a row to the root table.
|
||||||
|
|
||||||
This is a deployment rule, not a style preference. Each service is a separate
|
## Deployment target
|
||||||
Coolify app whose webhook watch path is `<service>/**`. A cross-link means
|
|
||||||
renaming or editing one service touches another's directory and redeploys it.
|
|
||||||
Cross-cutting changes to every compose file (a new restart policy, say) are the
|
|
||||||
one legitimate case where a push redeploys several services.
|
|
||||||
|
|
||||||
Every Coolify app created from this repo sets its watch path to `<service>/**`.
|
Services are deployed through Coolify and Dokploy, not plain `docker compose`
|
||||||
After creating one, check `watch_paths` with the Coolify MCP `get_application`;
|
on a host. The platform owns the parts a standalone compose file would declare
|
||||||
`null` means the app deploys on every push to the repository. Only these are
|
itself.
|
||||||
`null` and expected — do not flag or "fix" them:
|
|
||||||
|
|
||||||
- `traffmonetizer`, on both `miti-sg` and `miti-jp`, leaves it unset on purpose
|
|
||||||
to be restarted on every push.
|
|
||||||
|
|
||||||
## Service directories hold deploy files only
|
|
||||||
|
|
||||||
Because of that watch path, every file in a service directory redeploys the
|
|
||||||
service when it changes. A service directory holds only what the deploy reads
|
|
||||||
or what describes it: `compose.yml`, `README.md`, `.env.example`,
|
|
||||||
`.gitignore`, and any `Dockerfile`, entrypoint or config file the build or
|
|
||||||
containers use.
|
|
||||||
|
|
||||||
- **Agent skills** for a service — maintenance scripts, runbooks — go in the
|
|
||||||
root `.claude/skills/<name>/`, never in `<service>/.claude/`. A skill there
|
|
||||||
also only loads once a session touches that directory.
|
|
||||||
- **Docs** beyond the README go in the root `docs/<service>/`, named exactly
|
|
||||||
like the service directory; renaming a service renames its docs directory in
|
|
||||||
the same commit. The service `README.md` covers only what the service is and
|
|
||||||
how to deploy it — variables, storage, wiring, and the reasons behind its
|
|
||||||
configuration. Anything issue-related — known problems, log noise,
|
|
||||||
troubleshooting, investigations, upstream research and audits — goes in
|
|
||||||
`docs/<service>/`. The README does not link there; docs may link to the
|
|
||||||
service.
|
|
||||||
- **Anything else** that is not deploy input — CI workflows, test fixtures —
|
|
||||||
has no default home. Ask the user where it goes before adding it to a
|
|
||||||
service directory.
|
|
||||||
|
|
||||||
## Intentional omissions — do not "fix" these
|
## Intentional omissions — do not "fix" these
|
||||||
|
|
||||||
@@ -89,27 +22,16 @@ unprompted:
|
|||||||
|
|
||||||
- **No `ports:`.** Coolify and Dokploy attach the container to their proxy
|
- **No `ports:`.** Coolify and Dokploy attach the container to their proxy
|
||||||
network and map a domain to the internal port. Publishing a port is redundant
|
network and map a domain to the internal port. Publishing a port is redundant
|
||||||
and would additionally expose it on the host. Coolify's `ports_exposes`
|
and would additionally expose it on the host.
|
||||||
field (often a prefilled `3000`) is never read for compose apps; the proxy
|
- **No `restart:` policy.** The platform manages the container lifecycle.
|
||||||
port comes from the compose `expose:` entry or the domain's port. Leave it.
|
|
||||||
- **No `container_name:`.** Let Compose derive it from the directory.
|
- **No `container_name:`.** Let Compose derive it from the directory.
|
||||||
|
|
||||||
More generally: these files are tuned to one person's setup and are not meant
|
More generally: these files are tuned to one person's setup and are not meant
|
||||||
to be portable, standard, or turnkey. Prefer leaving a service minimal over
|
to be portable, standard, or turnkey. Prefer leaving a service minimal over
|
||||||
adding hardening or convention that the platform already provides.
|
adding hardening or convention that the platform already provides.
|
||||||
|
|
||||||
`restart:` is the exception that is *not* omitted. Every service sets
|
## Secrets
|
||||||
`restart: unless-stopped`, on every container. Coolify injects that exact value
|
|
||||||
when a service does not declare one, and keeps the declared value when it does;
|
|
||||||
Dokploy does not inject anything, so in its default compose mode an omitted
|
|
||||||
policy leaves the container down after a crash or a host reboot. Setting it is
|
|
||||||
correct on both. `traffmonetizer` sets `restart: always` on purpose, to be
|
|
||||||
restarted as often as possible.
|
|
||||||
|
|
||||||
## Upstream sources
|
Every service reads secrets from a sibling `.env`. Never commit one — the root
|
||||||
|
`.gitignore` covers `.env`/`*.env` and re-includes `.env.example`. Keep
|
||||||
`sources/` is for upstream source checkouts used while debugging, cloned as
|
`.env.example` in sync whenever a compose file gains or drops a variable.
|
||||||
`sources/<owner>/<repo>` at the version the service runs. Its contents are
|
|
||||||
gitignored; only `sources/.gitkeep` is tracked. Never commit a checkout or fix
|
|
||||||
a service by editing code there. Use the `debug-service` skill
|
|
||||||
(`.claude/skills/debug-service/SKILL.md`) for service issues.
|
|
||||||
@@ -1,23 +1,19 @@
|
|||||||
# composes
|
# composes
|
||||||
|
|
||||||
My docker compose collection — one directory per service, each self-contained.
|
My docker compose collection — one directory per service, each self-contained.
|
||||||
Tuned to my own setup rather than written as general-purpose templates.
|
|
||||||
|
|
||||||
Services are deployed through [Coolify](https://coolify.io), with
|
These are tuned to my own setup, not written as general-purpose templates. They
|
||||||
[Dokploy](https://dokploy.com) supported as an optional extra. The platform
|
are deployed through [Coolify](https://coolify.io) and
|
||||||
owns what a standalone compose file would otherwise declare:
|
[Dokploy](https://dokploy.com), so they lean on the platform for things a
|
||||||
|
standalone compose file would normally declare:
|
||||||
|
|
||||||
- **No published ports.** The platform attaches the container to its proxy
|
- **No published ports.** Both platforms attach the container to their proxy
|
||||||
network and maps a domain to the internal port. Publishing one would also
|
network and map a domain directly to the internal port, so `ports:` is
|
||||||
expose it on the host.
|
unnecessary — and adding it would expose the host port as well.
|
||||||
- **No `container_name:`.** Compose derives it from the directory.
|
- **No `restart:` policy.** The platform manages the container lifecycle.
|
||||||
|
|
||||||
Every container does set `restart: unless-stopped`. Coolify would inject the
|
Treat them as working examples rather than drop-in configs. Running one with
|
||||||
same value on its own, but Dokploy leaves an omitted policy alone, which in its
|
plain `docker compose` means adding whatever your setup needs.
|
||||||
default compose mode means the container stays down after a reboot.
|
|
||||||
|
|
||||||
Services that publish ports or set `container_name:` say so in their own
|
|
||||||
README.
|
|
||||||
|
|
||||||
## Layout
|
## Layout
|
||||||
|
|
||||||
@@ -29,34 +25,15 @@ README.
|
|||||||
.env # real values, gitignored
|
.env # real values, gitignored
|
||||||
```
|
```
|
||||||
|
|
||||||
Compose names the project after its directory, so `gitea/` comes up as
|
Compose names the project after its directory, so `code-server/` comes up as
|
||||||
the `gitea` project with its own network and volumes.
|
the `code-server` project with its own network and volumes.
|
||||||
|
|
||||||
Each service README covers only its own service. Shared conventions live here
|
|
||||||
and are not repeated or linked from a service, so editing one service never
|
|
||||||
touches another's directory — each is a separate Coolify app deploying on a
|
|
||||||
`<service>/**` watch path.
|
|
||||||
|
|
||||||
A service directory holds only what its deploy reads, so changing anything else
|
|
||||||
never redeploys it. Known issues, troubleshooting and research for a service
|
|
||||||
live in `docs/<service>/`, named after its directory; agent skills live in
|
|
||||||
`.claude/skills/`.
|
|
||||||
|
|
||||||
## Upstream sources
|
|
||||||
|
|
||||||
`sources/` holds checkouts of the upstream repositories behind these images,
|
|
||||||
cloned as `sources/<owner>/<repo>` when a service needs debugging against its
|
|
||||||
real code. Only the empty directory is tracked; its contents are gitignored.
|
|
||||||
The `debug-service` skill in `.claude/skills/` walks through the process.
|
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
In Coolify or Dokploy, point a Docker Compose resource at the service directory
|
In Coolify or Dokploy, point a Docker Compose resource at the service directory
|
||||||
and set the environment variables from its `.env.example`. To move an existing
|
and set the environment variables from its `.env.example`.
|
||||||
deployment and its data onto that resource, use the `migrate-service` skill in
|
|
||||||
`.claude/skills/`.
|
|
||||||
|
|
||||||
Locally:
|
Locally, for a quick check:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
cd <service>
|
cd <service>
|
||||||
@@ -66,8 +43,8 @@ docker compose logs -f
|
|||||||
docker compose down
|
docker compose down
|
||||||
```
|
```
|
||||||
|
|
||||||
`.env` is picked up automatically because it sits next to `compose.yml`. Never
|
`.env` is picked up automatically because it sits next to `compose.yml`.
|
||||||
commit it — the root `.gitignore` covers `.env`/`*.env` and re-includes
|
Never commit it — the root `.gitignore` covers `.env`/`*.env` and re-includes
|
||||||
`.env.example`.
|
`.env.example`.
|
||||||
|
|
||||||
## Services
|
## Services
|
||||||
@@ -76,22 +53,4 @@ Each links to its own README for variables, ports, and storage.
|
|||||||
|
|
||||||
| Service | What it is |
|
| Service | What it is |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| [alloy](alloy/README.md) | Grafana Alloy shipping host and Docker telemetry to Grafana Cloud |
|
| [code-server](code-server/README.md) | VS Code in the browser, as a remote dev box |
|
||||||
| [code-server](code-server/README.md) | VS Code in the browser from the official image |
|
|
||||||
| [couchbase](couchbase/README.md) | Couchbase Server |
|
|
||||||
| [diun](diun/README.md) | Image-update notifier, reading the Docker API through a read-only proxy |
|
|
||||||
| [gitea](gitea/README.md) | Gitea backed by PostgreSQL |
|
|
||||||
| [gitea-mirror](gitea-mirror/README.md) | gitea-mirror, mirroring GitHub repos into Gitea |
|
|
||||||
| [goclaw](goclaw/README.md) | Multi-tenant AI agent gateway, with pgvector PostgreSQL |
|
|
||||||
| [hermes](hermes/README.md) | Hermes Agent with its built-in web dashboard |
|
|
||||||
| [litellm](litellm/README.md) | LiteLLM proxy in front of many LLM providers, with PostgreSQL and Redis |
|
|
||||||
| [nextcloud](nextcloud/README.md) | Nextcloud file sync and share, with PostgreSQL, Redis and a cron container |
|
|
||||||
| [open-webui](open-webui/README.md) | Open WebUI chat interface for OpenAI-compatible and Ollama providers |
|
|
||||||
| [openclaw](openclaw/README.md) | OpenClaw AI agent gateway, with built-in browser automation |
|
|
||||||
| [opencloud](opencloud/README.md) | OpenCloud file sync and share, single container with built-in identity provider |
|
|
||||||
| [owncloud](owncloud/README.md) | ownCloud file sync and share, with MariaDB and Redis |
|
|
||||||
| [paseo](paseo/README.md) | Paseo coding-agent daemon and web UI |
|
|
||||||
| [traffmonetizer](traffmonetizer/README.md) | TraffMonetizer bandwidth-sharing client |
|
|
||||||
| [webtop](webtop/README.md) | Ubuntu XFCE desktop in the browser |
|
|
||||||
|
|
||||||
Licensed under Apache 2.0 — see [LICENSE](LICENSE).
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
# Copy to .env and fill in. Never commit .env.
|
|
||||||
#
|
|
||||||
# cp .env.example .env
|
|
||||||
|
|
||||||
# Container hostname, and the Loki/Prometheus `instance` label. Set per host.
|
|
||||||
ALLOY_HOSTNAME=alloy
|
|
||||||
|
|
||||||
# Grafana Fleet Management endpoint, agent id (set per host) and user id.
|
|
||||||
REMOTECFG_URL=
|
|
||||||
REMOTECFG_ID=alloy
|
|
||||||
REMOTECFG_USER=
|
|
||||||
|
|
||||||
# Prometheus remote-write endpoint and user id.
|
|
||||||
PROM_URL=
|
|
||||||
PROM_USER=
|
|
||||||
|
|
||||||
# Loki push endpoint and user id.
|
|
||||||
LOKI_URL=
|
|
||||||
LOKI_USER=
|
|
||||||
|
|
||||||
# Cloud Access Policy token with metrics:write, logs:write and
|
|
||||||
# fleet-management:read. Serves remotecfg, Prometheus and Loki.
|
|
||||||
GRAFANA_TOKEN=
|
|
||||||
-153
@@ -1,153 +0,0 @@
|
|||||||
# alloy
|
|
||||||
|
|
||||||
[Grafana Alloy](https://grafana.com/docs/alloy/latest/) shipping host and
|
|
||||||
container telemetry to Grafana Cloud, with remote config from Grafana Fleet
|
|
||||||
Management.
|
|
||||||
|
|
||||||
One container runs both the `node_exporter` (host) and `cadvisor` (container)
|
|
||||||
collectors. A second, tiny container proxies a read-only slice of the Docker
|
|
||||||
API to it. The Alloy config is embedded inline via Compose `configs:`, so
|
|
||||||
there is no `config.alloy` on disk.
|
|
||||||
|
|
||||||
`dockerproxy` publishes
|
|
||||||
`127.0.0.1:2375` — Alloy runs with host networking, so it has no compose
|
|
||||||
network to reach the proxy over.
|
|
||||||
|
|
||||||
## What it collects
|
|
||||||
|
|
||||||
| Source | Component | Notes |
|
|
||||||
|---|---|---|
|
|
||||||
| Host metrics | `prometheus.exporter.unix` | CPU, memory, load, disk I/O, filesystem, network, uname, boot time, systemd, vmstat, sockstat — the default collector set minus `ipvs/btrfs/infiniband/xfs/zfs` |
|
|
||||||
| Container metrics | `prometheus.exporter.cadvisor` | CPU, memory, fs usage/limit, network, `last_seen`. Docker labels are not copied onto the series (`store_container_labels = false`): Coolify gives its containers 50–80 labels, which pushes series past Grafana Cloud's 60-label limit and gets them rejected |
|
|
||||||
| Container logs | `loki.source.docker` | All running containers, labeled `container`, `stream`, `instance` |
|
|
||||||
| Journal logs | `loki.source.journal` | systemd journal, labeled `unit`, `boot_id`, `transport`, `level` |
|
|
||||||
| File logs | `loki.source.file` | `/var/log/syslog`, `/var/log/messages`, `/var/log/*.log` |
|
|
||||||
| Remote config | `remotecfg` | Polls Grafana Fleet Management every 60s |
|
|
||||||
|
|
||||||
Metric filtering copies the `keep`-lists from the upstream Grafana Cloud
|
|
||||||
integrations verbatim ([Linux Node](https://grafana.com/docs/grafana-cloud/monitor-infrastructure/integrations/integration-reference/integration-linux-node/#metrics),
|
|
||||||
[Docker](https://grafana.com/docs/grafana-cloud/monitor-infrastructure/integrations/integration-reference/integration-docker/#metrics)).
|
|
||||||
Logs are unfiltered, except that container log lines older than 168h are
|
|
||||||
dropped before they are sent. Grafana Cloud Loki rejects anything older than
|
|
||||||
7 days, and `loki.source.docker` re-reads a container's last second of logs
|
|
||||||
on every restart (its saved position has one-second precision), so an idle
|
|
||||||
container's last lines would otherwise come back as a `400 timestamp too old`
|
|
||||||
error on every redeploy. Nothing Loki would have accepted is dropped.
|
|
||||||
|
|
||||||
## Environment
|
|
||||||
|
|
||||||
All nine are required; `docker compose up` fails fast if any is unset.
|
|
||||||
|
|
||||||
| Variable | Purpose |
|
|
||||||
| --- | --- |
|
|
||||||
| `ALLOY_HOSTNAME` | Container hostname, and the Loki/Prometheus `instance` label |
|
|
||||||
| `REMOTECFG_URL` | Fleet Management endpoint |
|
|
||||||
| `REMOTECFG_ID` | Fleet Management agent id |
|
|
||||||
| `REMOTECFG_USER` | Fleet Management user id |
|
|
||||||
| `PROM_URL` / `PROM_USER` | Prometheus remote-write endpoint and user id |
|
|
||||||
| `LOKI_URL` / `LOKI_USER` | Loki push endpoint and user id |
|
|
||||||
| `GRAFANA_TOKEN` | One Cloud Access Policy token, scopes `metrics:write` + `logs:write` + `fleet-management:read` |
|
|
||||||
|
|
||||||
`GRAFANA_TOKEN` is also passed into the container as `GCLOUD_RW_API_KEY`.
|
|
||||||
Fleet Management's auto-generated `self_monitoring_*` pipelines read the token
|
|
||||||
from that fixed name; without it their remote-write gets an empty password and
|
|
||||||
Grafana Cloud answers `401 invalid token`, so the collector shows no health
|
|
||||||
data in Fleet Management.
|
|
||||||
|
|
||||||
Find the values under Grafana Cloud → your stack → **Details** on each data
|
|
||||||
source, and under Fleet Management. The same token serves `remotecfg`,
|
|
||||||
Prometheus and Loki basic-auth.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cp .env.example .env # then fill in the values
|
|
||||||
docker compose up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
Run the same file on every host, changing `ALLOY_HOSTNAME` and `REMOTECFG_ID`
|
|
||||||
per host. Filter in Grafana with `instance=~"..."`.
|
|
||||||
|
|
||||||
## Privileges
|
|
||||||
|
|
||||||
The upstream Grafana Cloud docker integration runs `privileged: true` with the
|
|
||||||
raw Docker socket mounted read-write. Nothing in this config needs that, and
|
|
||||||
the combination is host-root-equivalent: `privileged` grants every capability
|
|
||||||
and unmasks `/proc` and `/sys`, and a container that can talk to the Docker
|
|
||||||
socket can start another container that mounts `/` writable. The socket's `:ro`
|
|
||||||
flag does not help — it stops the socket *file* being replaced, not the API
|
|
||||||
being used.
|
|
||||||
|
|
||||||
What it runs instead:
|
|
||||||
|
|
||||||
| Setting | Reason |
|
|
||||||
| --- | --- |
|
|
||||||
| `cap_drop: [ALL]` + `cap_add: [DAC_OVERRIDE]` | The image's entrypoint runs as uid 0 and reads host files owned by other users — the journal, paths under `/rootfs`, `/var/log`. Dropping every capability leaves it unable to open them, and unable to create its own storage directory. `DAC_OVERRIDE` restores exactly that and nothing else; `SYS_ADMIN`, `NET_ADMIN`, `SYS_PTRACE`, `MKNOD` and the rest stay dropped. |
|
|
||||||
| `no-new-privileges:true` | No setuid binary in the image can regain what was dropped. |
|
|
||||||
| `mem_limit: 2g`, `pids_limit: 512` | Steady state is around 900 MB; the limit stops a leak taking the host down with it. |
|
|
||||||
| `dockerproxy` instead of `/var/run/docker.sock` | See below. |
|
|
||||||
|
|
||||||
`read_only: true` is deliberately absent. Compose materialises an inline
|
|
||||||
`configs:` entry by writing it into the container, and refuses to do that on a
|
|
||||||
read-only service — `cannot create config ... : \`file\` is the sole supported
|
|
||||||
option`. Keeping the config inline is worth more than the read-only rootfs
|
|
||||||
here; adding it back means moving the config to a `config.alloy` file on disk
|
|
||||||
and switching the `configs:` entry to `file:`.
|
|
||||||
|
|
||||||
`network_mode: host` stays. `/proc/net` is a symlink to `/proc/self/net` and
|
|
||||||
resolves against the reading process's network namespace, so bind-mounting the
|
|
||||||
host's `/proc` to `/rootproc` is not enough — without host networking the
|
|
||||||
netdev, netstat, sockstat and conntrack families would describe the container's
|
|
||||||
veth pair instead of `eth0`. That is roughly 45 of the 157 kept metrics.
|
|
||||||
|
|
||||||
`/:/rootfs:ro` also stays, and is the widest remaining exposure: the container
|
|
||||||
can read every file on the host. The filesystem collector needs to `statfs()`
|
|
||||||
each mount point, and a bind mount cannot grant that without granting reads.
|
|
||||||
Dropping it would cost disk-space monitoring. Treat the container as
|
|
||||||
secret-bearing — it holds `GRAFANA_TOKEN` regardless.
|
|
||||||
|
|
||||||
## Docker API access
|
|
||||||
|
|
||||||
`prometheus.exporter.cadvisor` and `loki.source.docker` both need the Docker
|
|
||||||
API, so it cannot simply be removed. `dockerproxy` runs
|
|
||||||
[tecnativa/docker-socket-proxy](https://github.com/Tecnativa/docker-socket-proxy)
|
|
||||||
with the socket mounted read-only and publishes it on `127.0.0.1:2375`, which
|
|
||||||
Alloy reaches over host networking.
|
|
||||||
|
|
||||||
`POST` is revoked by default in that image, which is the point: container
|
|
||||||
create, `exec`, start and kill are all refused with 403, so a compromise of
|
|
||||||
Alloy can no longer become root on the host. The granted sections are the ones
|
|
||||||
the two components actually call:
|
|
||||||
|
|
||||||
| Variable | Called by |
|
|
||||||
| --- | --- |
|
|
||||||
| `CONTAINERS` | `discovery.docker` listing, `loki.source.docker` inspect and log read, cadvisor metadata |
|
|
||||||
| `NETWORKS` | `discovery.docker` — Prometheus' Docker SD resolves network names per container, and returns **zero targets** without it |
|
|
||||||
| `IMAGES`, `INFO`, `VERSION` | cadvisor |
|
|
||||||
| `EVENTS` | cadvisor container watch (granted by default) |
|
|
||||||
|
|
||||||
What this does not do: `GET /containers/{id}/json` still returns every
|
|
||||||
container's environment variables. The proxy closes the escalation path, not
|
|
||||||
the disclosure one.
|
|
||||||
|
|
||||||
## Mounts
|
|
||||||
|
|
||||||
| Mount | Why |
|
|
||||||
|---|---|
|
|
||||||
| `/proc:/rootproc:ro` | node-exporter cpu/mem/load, via `procfs_path` |
|
|
||||||
| `/sys:/sys:ro` | node-exporter and cadvisor cgroups |
|
|
||||||
| `/:/rootfs:ro` | filesystem collector, via `rootfs_path` |
|
|
||||||
| `/dev/disk/:/dev/disk:ro` | node-exporter diskstats device labels |
|
|
||||||
| `/run/udev:/run/udev:ro` | node-exporter diskstats udev device properties (`/run/udev/data`) |
|
|
||||||
| `/var/lib/docker:ro` | cadvisor container metadata |
|
|
||||||
| `/var/log:/var/log:ro` | `loki.source.journal` and `loki.source.file` |
|
|
||||||
| `/etc/machine-id:ro` | Stable host id for the journal reader |
|
|
||||||
| `alloy-data` | WAL and remotecfg cache — the only writable path |
|
|
||||||
|
|
||||||
`dockerproxy` mounts `/var/run/docker.sock:ro` and nothing else.
|
|
||||||
|
|
||||||
|
|
||||||
## Version pinning
|
|
||||||
|
|
||||||
Both images use `latest`. Neither project publishes a moving major tag:
|
|
||||||
`grafana/alloy` ships only exact `v1.x.y` tags, and `tecnativa/docker-socket-proxy`
|
|
||||||
only exact `v0.x.y` tags (its bare `0` tag is a stale leftover). `latest` is the
|
|
||||||
closest equivalent, so new releases arrive on the next redeploy.
|
|
||||||
@@ -1,292 +0,0 @@
|
|||||||
services:
|
|
||||||
alloy:
|
|
||||||
image: grafana/alloy:latest
|
|
||||||
restart: unless-stopped
|
|
||||||
hostname: ${ALLOY_HOSTNAME:?required}
|
|
||||||
network_mode: host
|
|
||||||
cap_drop: [ALL]
|
|
||||||
cap_add: [DAC_OVERRIDE]
|
|
||||||
security_opt:
|
|
||||||
- no-new-privileges:true
|
|
||||||
mem_limit: 2g
|
|
||||||
pids_limit: 512
|
|
||||||
depends_on:
|
|
||||||
- dockerproxy
|
|
||||||
environment:
|
|
||||||
ALLOY_DEPLOY_MODE: docker
|
|
||||||
REMOTECFG_URL: ${REMOTECFG_URL:?required}
|
|
||||||
REMOTECFG_ID: ${REMOTECFG_ID:?required}
|
|
||||||
REMOTECFG_USER: ${REMOTECFG_USER:?required}
|
|
||||||
PROM_URL: ${PROM_URL:?required}
|
|
||||||
PROM_USER: ${PROM_USER:?required}
|
|
||||||
LOKI_URL: ${LOKI_URL:?required}
|
|
||||||
LOKI_USER: ${LOKI_USER:?required}
|
|
||||||
GRAFANA_TOKEN: ${GRAFANA_TOKEN:?required}
|
|
||||||
GCLOUD_RW_API_KEY: ${GRAFANA_TOKEN:?required}
|
|
||||||
volumes:
|
|
||||||
- alloy-data:/var/lib/alloy/data
|
|
||||||
- /proc:/rootproc:ro
|
|
||||||
- /sys:/sys:ro
|
|
||||||
- /:/rootfs:ro
|
|
||||||
- /dev/disk/:/dev/disk:ro
|
|
||||||
- /run/udev:/run/udev:ro
|
|
||||||
- /var/lib/docker/:/var/lib/docker:ro
|
|
||||||
- /var/log:/var/log:ro
|
|
||||||
- /etc/machine-id:/etc/machine-id:ro
|
|
||||||
configs:
|
|
||||||
- { source: alloy_config, target: /etc/alloy/config.alloy }
|
|
||||||
command: 'run --storage.path=/var/lib/alloy/data /etc/alloy/config.alloy'
|
|
||||||
|
|
||||||
dockerproxy:
|
|
||||||
image: tecnativa/docker-socket-proxy:latest
|
|
||||||
restart: unless-stopped
|
|
||||||
security_opt:
|
|
||||||
- no-new-privileges:true
|
|
||||||
mem_limit: 64m
|
|
||||||
pids_limit: 64
|
|
||||||
environment:
|
|
||||||
CONTAINERS: 1
|
|
||||||
NETWORKS: 1
|
|
||||||
IMAGES: 1
|
|
||||||
INFO: 1
|
|
||||||
VERSION: 1
|
|
||||||
EVENTS: 1
|
|
||||||
ports:
|
|
||||||
- 127.0.0.1:2375:2375
|
|
||||||
volumes:
|
|
||||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
alloy-data:
|
|
||||||
|
|
||||||
configs:
|
|
||||||
alloy_config:
|
|
||||||
content: |
|
|
||||||
remotecfg {
|
|
||||||
url = sys.env("REMOTECFG_URL")
|
|
||||||
id = sys.env("REMOTECFG_ID")
|
|
||||||
poll_frequency = "60s"
|
|
||||||
|
|
||||||
basic_auth {
|
|
||||||
username = sys.env("REMOTECFG_USER")
|
|
||||||
password = sys.env("GRAFANA_TOKEN")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
prometheus.remote_write "metrics_service" {
|
|
||||||
endpoint {
|
|
||||||
url = sys.env("PROM_URL")
|
|
||||||
|
|
||||||
basic_auth {
|
|
||||||
username = sys.env("PROM_USER")
|
|
||||||
password = sys.env("GRAFANA_TOKEN")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
loki.write "grafana_cloud_loki" {
|
|
||||||
endpoint {
|
|
||||||
url = sys.env("LOKI_URL")
|
|
||||||
|
|
||||||
basic_auth {
|
|
||||||
username = sys.env("LOKI_USER")
|
|
||||||
password = sys.env("GRAFANA_TOKEN")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
discovery.relabel "integrations_node_exporter" {
|
|
||||||
targets = prometheus.exporter.unix.integrations_node_exporter.targets
|
|
||||||
|
|
||||||
rule {
|
|
||||||
target_label = "instance"
|
|
||||||
replacement = constants.hostname
|
|
||||||
}
|
|
||||||
|
|
||||||
rule {
|
|
||||||
target_label = "job"
|
|
||||||
replacement = "integrations/node_exporter"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
prometheus.exporter.unix "integrations_node_exporter" {
|
|
||||||
disable_collectors = ["ipvs", "btrfs", "infiniband", "xfs", "zfs"]
|
|
||||||
|
|
||||||
// Read host filesystems through the /rootfs and /rootproc bind mounts.
|
|
||||||
rootfs_path = "/rootfs"
|
|
||||||
procfs_path = "/rootproc"
|
|
||||||
|
|
||||||
filesystem {
|
|
||||||
fs_types_exclude = "^(autofs|binfmt_misc|bpf|cgroup2?|configfs|debugfs|devpts|devtmpfs|tmpfs|fusectl|hugetlbfs|iso9660|mqueue|nsfs|overlay|proc|procfs|pstore|rpc_pipefs|securityfs|selinuxfs|squashfs|sysfs|tracefs)$"
|
|
||||||
mount_points_exclude = "^/(dev|proc|run/credentials/.+|sys|var/lib/docker/.+)($|/)"
|
|
||||||
mount_timeout = "5s"
|
|
||||||
}
|
|
||||||
|
|
||||||
netclass {
|
|
||||||
ignored_devices = "^(veth.*|cali.*|[a-f0-9]{15})$"
|
|
||||||
}
|
|
||||||
|
|
||||||
netdev {
|
|
||||||
device_exclude = "^(veth.*|cali.*|[a-f0-9]{15})$"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
prometheus.scrape "integrations_node_exporter" {
|
|
||||||
targets = discovery.relabel.integrations_node_exporter.output
|
|
||||||
forward_to = [prometheus.relabel.integrations_node_exporter.receiver]
|
|
||||||
}
|
|
||||||
|
|
||||||
// Keep only the Grafana Cloud Linux Node integration's metric list.
|
|
||||||
prometheus.relabel "integrations_node_exporter" {
|
|
||||||
forward_to = [prometheus.remote_write.metrics_service.receiver]
|
|
||||||
|
|
||||||
rule {
|
|
||||||
source_labels = ["__name__"]
|
|
||||||
regex = "node_arp_entries|node_boot_time_seconds|node_context_switches_total|node_cpu_seconds_total|node_disk_io_time_seconds_total|node_disk_io_time_weighted_seconds_total|node_disk_read_bytes_total|node_disk_read_time_seconds_total|node_disk_reads_completed_total|node_disk_write_time_seconds_total|node_disk_writes_completed_total|node_disk_written_bytes_total|node_filefd_allocated|node_filefd_maximum|node_filesystem_avail_bytes|node_filesystem_device_error|node_filesystem_files|node_filesystem_files_free|node_filesystem_readonly|node_filesystem_size_bytes|node_intr_total|node_load1|node_load15|node_load5|node_md_disks|node_md_disks_required|node_memory_Active_anon_bytes|node_memory_Active_bytes|node_memory_Active_file_bytes|node_memory_AnonHugePages_bytes|node_memory_AnonPages_bytes|node_memory_Bounce_bytes|node_memory_Buffers_bytes|node_memory_Cached_bytes|node_memory_CommitLimit_bytes|node_memory_Committed_AS_bytes|node_memory_DirectMap1G_bytes|node_memory_DirectMap2M_bytes|node_memory_DirectMap4k_bytes|node_memory_Dirty_bytes|node_memory_HugePages_Free|node_memory_HugePages_Rsvd|node_memory_HugePages_Surp|node_memory_HugePages_Total|node_memory_Hugepagesize_bytes|node_memory_Inactive_anon_bytes|node_memory_Inactive_bytes|node_memory_Inactive_file_bytes|node_memory_Mapped_bytes|node_memory_MemAvailable_bytes|node_memory_MemFree_bytes|node_memory_MemTotal_bytes|node_memory_SReclaimable_bytes|node_memory_SUnreclaim_bytes|node_memory_ShmemHugePages_bytes|node_memory_ShmemPmdMapped_bytes|node_memory_Shmem_bytes|node_memory_Slab_bytes|node_memory_SwapTotal_bytes|node_memory_VmallocChunk_bytes|node_memory_VmallocTotal_bytes|node_memory_VmallocUsed_bytes|node_memory_WritebackTmp_bytes|node_memory_Writeback_bytes|node_netstat_Icmp6_InErrors|node_netstat_Icmp6_InMsgs|node_netstat_Icmp6_OutMsgs|node_netstat_Icmp_InErrors|node_netstat_Icmp_InMsgs|node_netstat_Icmp_OutMsgs|node_netstat_IpExt_InOctets|node_netstat_IpExt_OutOctets|node_netstat_TcpExt_ListenDrops|node_netstat_TcpExt_ListenOverflows|node_netstat_TcpExt_TCPSynRetrans|node_netstat_Tcp_InErrs|node_netstat_Tcp_InSegs|node_netstat_Tcp_OutRsts|node_netstat_Tcp_OutSegs|node_netstat_Tcp_RetransSegs|node_netstat_Udp6_InDatagrams|node_netstat_Udp6_InErrors|node_netstat_Udp6_NoPorts|node_netstat_Udp6_OutDatagrams|node_netstat_Udp6_RcvbufErrors|node_netstat_Udp6_SndbufErrors|node_netstat_UdpLite_InErrors|node_netstat_Udp_InDatagrams|node_netstat_Udp_InErrors|node_netstat_Udp_NoPorts|node_netstat_Udp_OutDatagrams|node_netstat_Udp_RcvbufErrors|node_netstat_Udp_SndbufErrors|node_network_carrier|node_network_info|node_network_mtu_bytes|node_network_receive_bytes_total|node_network_receive_compressed_total|node_network_receive_drop_total|node_network_receive_errs_total|node_network_receive_fifo_total|node_network_receive_multicast_total|node_network_receive_packets_total|node_network_speed_bytes|node_network_transmit_bytes_total|node_network_transmit_compressed_total|node_network_transmit_drop_total|node_network_transmit_errs_total|node_network_transmit_fifo_total|node_network_transmit_multicast_total|node_network_transmit_packets_total|node_network_transmit_queue_length|node_network_up|node_nf_conntrack_entries|node_nf_conntrack_entries_limit|node_os_info|node_procs_running|node_sockstat_FRAG6_inuse|node_sockstat_FRAG_inuse|node_sockstat_RAW6_inuse|node_sockstat_RAW_inuse|node_sockstat_TCP6_inuse|node_sockstat_TCP_alloc|node_sockstat_TCP_inuse|node_sockstat_TCP_mem|node_sockstat_TCP_mem_bytes|node_sockstat_TCP_orphan|node_sockstat_TCP_tw|node_sockstat_UDP6_inuse|node_sockstat_UDPLITE6_inuse|node_sockstat_UDPLITE_inuse|node_sockstat_UDP_inuse|node_sockstat_UDP_mem|node_sockstat_UDP_mem_bytes|node_sockstat_sockets_used|node_softnet_dropped_total|node_softnet_processed_total|node_softnet_times_squeezed_total|node_systemd_service_restart_total|node_systemd_unit_state|node_textfile_scrape_error|node_time_zone_offset_seconds|node_timex_estimated_error_seconds|node_timex_maxerror_seconds|node_timex_offset_seconds|node_timex_sync_status|node_uname_info|node_vmstat_oom_kill|node_vmstat_pgfault|node_vmstat_pgmajfault|node_vmstat_pgpgin|node_vmstat_pgpgout|node_vmstat_pswpin|node_vmstat_pswpout|process_max_fds|process_open_fds|up"
|
|
||||||
action = "keep"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
loki.relabel "integrations_node_exporter" {
|
|
||||||
forward_to = [loki.write.grafana_cloud_loki.receiver]
|
|
||||||
rule {
|
|
||||||
target_label = "job"
|
|
||||||
replacement = "integrations/node_exporter"
|
|
||||||
}
|
|
||||||
rule {
|
|
||||||
target_label = "instance"
|
|
||||||
replacement = constants.hostname
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
journal_module "integrations_node_exporter" {
|
|
||||||
forward_to = [loki.relabel.integrations_node_exporter.receiver]
|
|
||||||
}
|
|
||||||
|
|
||||||
// File logs from the Linux Node integration: syslog, messages, *.log.
|
|
||||||
local.file_match "integrations_node_exporter_files" {
|
|
||||||
path_targets = [{
|
|
||||||
__address__ = "localhost",
|
|
||||||
__path__ = "/var/log/{syslog,messages,*.log}",
|
|
||||||
}]
|
|
||||||
}
|
|
||||||
loki.source.file "integrations_node_exporter_files" {
|
|
||||||
targets = local.file_match.integrations_node_exporter_files.targets
|
|
||||||
forward_to = [loki.relabel.integrations_node_exporter.receiver]
|
|
||||||
}
|
|
||||||
|
|
||||||
// Journal logs.
|
|
||||||
declare "journal_module" {
|
|
||||||
argument "forward_to" {
|
|
||||||
optional = false
|
|
||||||
}
|
|
||||||
|
|
||||||
loki.source.journal "default" {
|
|
||||||
max_age = "12h0m0s"
|
|
||||||
forward_to = [loki.process.default.receiver]
|
|
||||||
relabel_rules = loki.relabel.default.rules
|
|
||||||
}
|
|
||||||
|
|
||||||
loki.relabel "default" {
|
|
||||||
rule {
|
|
||||||
source_labels = ["__journal__systemd_unit"]
|
|
||||||
target_label = "unit"
|
|
||||||
}
|
|
||||||
rule {
|
|
||||||
source_labels = ["__journal__boot_id"]
|
|
||||||
target_label = "boot_id"
|
|
||||||
}
|
|
||||||
rule {
|
|
||||||
source_labels = ["__journal__transport"]
|
|
||||||
target_label = "transport"
|
|
||||||
}
|
|
||||||
rule {
|
|
||||||
source_labels = ["__journal_priority_keyword"]
|
|
||||||
target_label = "level"
|
|
||||||
}
|
|
||||||
forward_to = []
|
|
||||||
}
|
|
||||||
loki.process "default" {
|
|
||||||
forward_to = argument.forward_to.value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
prometheus.exporter.cadvisor "integrations_cadvisor" {
|
|
||||||
docker_only = true
|
|
||||||
store_container_labels = false
|
|
||||||
docker_host = "tcp://127.0.0.1:2375"
|
|
||||||
}
|
|
||||||
discovery.relabel "integrations_cadvisor" {
|
|
||||||
targets = prometheus.exporter.cadvisor.integrations_cadvisor.targets
|
|
||||||
|
|
||||||
rule {
|
|
||||||
target_label = "job"
|
|
||||||
replacement = "integrations/docker"
|
|
||||||
}
|
|
||||||
|
|
||||||
rule {
|
|
||||||
target_label = "instance"
|
|
||||||
replacement = constants.hostname
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
prometheus.relabel "integrations_cadvisor" {
|
|
||||||
forward_to = [prometheus.remote_write.metrics_service.receiver]
|
|
||||||
|
|
||||||
rule {
|
|
||||||
source_labels = ["__name__"]
|
|
||||||
regex = "container_cpu_usage_seconds_total|container_fs_reads_total|container_fs_usage_bytes|container_fs_writes_total|container_last_seen|container_memory_cache|container_memory_rss|container_memory_usage_bytes|container_memory_working_set_bytes|container_network_receive_bytes_total|container_network_receive_errors_total|container_network_receive_packets_dropped_total|container_network_transmit_bytes_total|container_network_transmit_errors_total|container_network_transmit_packets_dropped_total|container_spec_memory_limit_bytes|container_spec_memory_reservation_limit_bytes|machine_memory_bytes|machine_scrape_error|up"
|
|
||||||
action = "keep"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
prometheus.scrape "integrations_cadvisor" {
|
|
||||||
targets = discovery.relabel.integrations_cadvisor.output
|
|
||||||
forward_to = [prometheus.relabel.integrations_cadvisor.receiver]
|
|
||||||
}
|
|
||||||
|
|
||||||
discovery.docker "logs_integrations_docker" {
|
|
||||||
host = "tcp://127.0.0.1:2375"
|
|
||||||
refresh_interval = "5s"
|
|
||||||
}
|
|
||||||
discovery.relabel "logs_integrations_docker" {
|
|
||||||
targets = []
|
|
||||||
|
|
||||||
rule {
|
|
||||||
target_label = "job"
|
|
||||||
replacement = "integrations/docker"
|
|
||||||
}
|
|
||||||
|
|
||||||
rule {
|
|
||||||
target_label = "instance"
|
|
||||||
replacement = constants.hostname
|
|
||||||
}
|
|
||||||
|
|
||||||
rule {
|
|
||||||
source_labels = ["__meta_docker_container_name"]
|
|
||||||
regex = "/(.*)"
|
|
||||||
target_label = "container"
|
|
||||||
}
|
|
||||||
|
|
||||||
rule {
|
|
||||||
source_labels = ["__meta_docker_container_log_stream"]
|
|
||||||
target_label = "stream"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
loki.source.docker "logs_integrations_docker" {
|
|
||||||
host = "tcp://127.0.0.1:2375"
|
|
||||||
targets = discovery.docker.logs_integrations_docker.targets
|
|
||||||
forward_to = [loki.process.logs_integrations_docker.receiver]
|
|
||||||
relabel_rules = discovery.relabel.logs_integrations_docker.rules
|
|
||||||
refresh_interval = "5s"
|
|
||||||
}
|
|
||||||
|
|
||||||
// Drop container log lines older than Grafana Cloud Loki accepts.
|
|
||||||
loki.process "logs_integrations_docker" {
|
|
||||||
forward_to = [loki.write.grafana_cloud_loki.receiver]
|
|
||||||
|
|
||||||
stage.drop {
|
|
||||||
older_than = "168h"
|
|
||||||
drop_counter_reason = "too_old"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,16 +2,12 @@
|
|||||||
#
|
#
|
||||||
# cp .env.example .env
|
# cp .env.example .env
|
||||||
|
|
||||||
# Container hostname, also passed in as HOST -- the name zsh's prompt shows.
|
# Web UI login password. MUST NOT be blank -- the LinuxServer image serves
|
||||||
SERVICE_HOSTNAME=code-server
|
# code-server without authentication when PASSWORD is empty.
|
||||||
|
# Also used for SUDO_PASSWORD inside the container.
|
||||||
# Web UI login password. Required.
|
|
||||||
# Generate one with: openssl rand -base64 24
|
# Generate one with: openssl rand -base64 24
|
||||||
PASSWORD=
|
PASSWORD=
|
||||||
|
|
||||||
# Git identity baked into the container (author + committer).
|
# Git identity baked into the container (author + committer).
|
||||||
GIT_NAME=
|
GIT_NAME=
|
||||||
GIT_EMAIL=
|
GIT_EMAIL=
|
||||||
|
|
||||||
# Name shown in the title bar and welcome page.
|
|
||||||
# CODE_SERVER_APP_NAME=code-server
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
FROM codercom/code-server:latest
|
|
||||||
|
|
||||||
USER root
|
|
||||||
|
|
||||||
# Extra system packages.
|
|
||||||
RUN apt-get update \
|
|
||||||
&& apt-get install -y --no-install-recommends \
|
|
||||||
build-essential bubblewrap unzip zip \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
# Command-line utilities: network and DNS, search and review, lint,
|
|
||||||
# database clients, and backup and maintenance.
|
|
||||||
RUN apt-get update \
|
|
||||||
&& apt-get install -y --no-install-recommends \
|
|
||||||
iproute2 lsof bind9-dnsutils netcat-openbsd socat mtr-tiny whois \
|
|
||||||
tree fd-find fzf git-delta bat \
|
|
||||||
shellcheck shfmt yamllint \
|
|
||||||
postgresql-client sqlite3 redis-tools \
|
|
||||||
ncdu tmux restic rclone age hyperfine entr 7zip \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
# Chromium and fonts for headless browser automation.
|
|
||||||
RUN apt-get update \
|
|
||||||
&& apt-get install -y --no-install-recommends \
|
|
||||||
chromium fonts-liberation fonts-noto-color-emoji fonts-noto-cjk \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
# Workspace directory, owned by the container user.
|
|
||||||
RUN mkdir -p /workspace && chown 1000:1000 /workspace
|
|
||||||
|
|
||||||
# Startup wrapper: enters DEFAULT_WORKSPACE, joins the Docker socket's group,
|
|
||||||
# then runs the image's entrypoint.
|
|
||||||
COPY entrypoint.sh /usr/local/bin/entrypoint.sh
|
|
||||||
USER 1000
|
|
||||||
|
|
||||||
ENTRYPOINT ["/usr/local/bin/entrypoint.sh", "--bind-addr", "0.0.0.0:8080", "."]
|
|
||||||
+11
-274
@@ -1,291 +1,28 @@
|
|||||||
# code-server
|
# code-server
|
||||||
|
|
||||||
[VS Code in the browser](https://github.com/coder/code-server), from Coder's
|
[VS Code in the browser](https://github.com/linuxserver/docker-code-server),
|
||||||
official `codercom/code-server` image.
|
from the LinuxServer image, set up as a full remote dev box.
|
||||||
|
|
||||||
The image ships code-server on Debian with `git`, `zsh`, `curl`, `sudo` and a
|
Comes with Go, Node.js 24, Python 3, pnpm, and zsh via LinuxServer mods, plus
|
||||||
few editors. The `Dockerfile` adds `build-essential`, `bubblewrap`, `zip`,
|
`gh`, `git`, `ffmpeg`, `imagemagick`, and other CLI tools through
|
||||||
`unzip`, a set of command-line utilities and Chromium, and wraps the entrypoint so it starts in `/workspace` and `coder` can
|
`INSTALL_PACKAGES`. Git author/committer identity is injected from `.env`.
|
||||||
use the Docker socket. Language toolchains and CLIs are installed into home,
|
|
||||||
below.
|
|
||||||
|
|
||||||
## Toolchains
|
|
||||||
|
|
||||||
Only `/home/coder` and `/workspace` survive a redeploy, so install toolchains
|
|
||||||
and CLIs into home from the editor's terminal. Each command below was tested in
|
|
||||||
the image, in this order. Open a new terminal afterwards so the `PATH` changes
|
|
||||||
apply.
|
|
||||||
|
|
||||||
The terminal is zsh, and the image ships no `~/.zshrc`. Create it first, with
|
|
||||||
`~/.local/bin` on `PATH` for the binaries below:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
mkdir -p ~/.local/bin
|
|
||||||
echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.zshrc
|
|
||||||
```
|
|
||||||
|
|
||||||
### Official install methods
|
|
||||||
|
|
||||||
These follow the project's own documented install, which already targets home.
|
|
||||||
|
|
||||||
Node.js, with nvm, the method marked recommended for Linux on the
|
|
||||||
[Node.js download page](https://nodejs.org/en/download). nvm, Node and global
|
|
||||||
npm packages all live in `~/.nvm`:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.8/install.sh | bash
|
|
||||||
. "$HOME/.nvm/nvm.sh"
|
|
||||||
nvm install 24
|
|
||||||
```
|
|
||||||
|
|
||||||
Python, with uv, from the
|
|
||||||
[uv installation guide](https://docs.astral.sh/uv/getting-started/installation/)
|
|
||||||
and [Python install guide](https://docs.astral.sh/uv/guides/install-python/).
|
|
||||||
uv puts itself in `~/.local/bin` and prebuilt Pythons in
|
|
||||||
`~/.local/share/uv/python`. python.org itself documents only distro packages
|
|
||||||
and source builds, both of which land outside home:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
curl -LsSf https://astral.sh/uv/install.sh | sh
|
|
||||||
. "$HOME/.local/bin/env"
|
|
||||||
uv python install 3.13
|
|
||||||
```
|
|
||||||
|
|
||||||
Rust, with rustup, from the [install page](https://rustup.rs/). The toolchain
|
|
||||||
lives in `~/.rustup`, and `cargo` with the tools it installs in `~/.cargo/bin`;
|
|
||||||
rustup adds itself to `~/.zshenv`. `-y` accepts the default install:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
|
|
||||||
. "$HOME/.cargo/env"
|
|
||||||
```
|
|
||||||
|
|
||||||
Docker Compose and Buildx, as CLI plugins in `~/.docker/cli-plugins`, the
|
|
||||||
manual install from the
|
|
||||||
[Compose docs](https://docs.docker.com/compose/install/linux/#install-the-plugin-manually)
|
|
||||||
and the [Buildx README](https://github.com/docker/buildx#manual-download). The
|
|
||||||
Docker client itself is below:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
DOCKER_CONFIG=${DOCKER_CONFIG:-$HOME/.docker}
|
|
||||||
mkdir -p "$DOCKER_CONFIG/cli-plugins"
|
|
||||||
ARCH=$(dpkg --print-architecture)
|
|
||||||
|
|
||||||
V=$(curl -fsSLI -o /dev/null -w '%{url_effective}' https://github.com/docker/compose/releases/latest | sed 's|.*/||')
|
|
||||||
curl -fsSL "https://github.com/docker/compose/releases/download/$V/docker-compose-linux-$(uname -m)" -o "$DOCKER_CONFIG/cli-plugins/docker-compose"
|
|
||||||
|
|
||||||
V=$(curl -fsSLI -o /dev/null -w '%{url_effective}' https://github.com/docker/buildx/releases/latest | sed 's|.*/||')
|
|
||||||
curl -fsSL "https://github.com/docker/buildx/releases/download/$V/buildx-$V.linux-$ARCH" -o "$DOCKER_CONFIG/cli-plugins/docker-buildx"
|
|
||||||
|
|
||||||
chmod +x "$DOCKER_CONFIG/cli-plugins/docker-compose" "$DOCKER_CONFIG/cli-plugins/docker-buildx"
|
|
||||||
```
|
|
||||||
|
|
||||||
To upgrade them, run the same commands again.
|
|
||||||
|
|
||||||
### Suggested by AI, may not be the optimal way
|
|
||||||
|
|
||||||
These use each project's official download, but no official doc covers
|
|
||||||
installing it into home: the location and the latest-version lookup are
|
|
||||||
improvised. Debian's own packages are no alternative, being several releases
|
|
||||||
behind.
|
|
||||||
|
|
||||||
Go, from the official tarball on [go.dev/dl](https://go.dev/dl/).
|
|
||||||
[go.dev/doc/install](https://go.dev/doc/install) extracts it to `/usr/local`;
|
|
||||||
the tarball runs from any directory, so it goes in `~/.local/go`. `go install`
|
|
||||||
puts tools in `~/go/bin`:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
V=$(curl -fsSL 'https://go.dev/VERSION?m=text' | head -1)
|
|
||||||
curl -fsSL "https://go.dev/dl/$V.linux-$(dpkg --print-architecture).tar.gz" | tar -C ~/.local -xzf -
|
|
||||||
echo 'export PATH="$HOME/.local/go/bin:$HOME/go/bin:$PATH"' >> ~/.zshrc
|
|
||||||
```
|
|
||||||
|
|
||||||
To upgrade Go, `rm -rf ~/.local/go` and run the same commands again, without
|
|
||||||
the `echo` line.
|
|
||||||
|
|
||||||
Docker CLI and GitHub CLI, as the release binaries each project publishes,
|
|
||||||
into `~/.local/bin`:
|
|
||||||
|
|
||||||
- Docker: [static binaries](https://docs.docker.com/engine/install/binaries/),
|
|
||||||
documented for `/usr/bin`. Only the client is taken; the daemon is the
|
|
||||||
host's, through the socket.
|
|
||||||
- GitHub CLI: the `.tar.gz` on [cli.github.com](https://cli.github.com/), with
|
|
||||||
no documented location.
|
|
||||||
|
|
||||||
```sh
|
|
||||||
ARCH=$(dpkg --print-architecture)
|
|
||||||
|
|
||||||
V=$(curl -fsSL https://download.docker.com/linux/static/stable/$(uname -m)/ | grep -o 'docker-[0-9.]*\.tgz' | sort -V | tail -1)
|
|
||||||
curl -fsSL "https://download.docker.com/linux/static/stable/$(uname -m)/$V" | tar -C ~/.local/bin -xzf - --strip-components=1 docker/docker
|
|
||||||
|
|
||||||
V=$(curl -fsSLI -o /dev/null -w '%{url_effective}' https://github.com/cli/cli/releases/latest | sed 's|.*/v||')
|
|
||||||
curl -fsSL "https://github.com/cli/cli/releases/download/v$V/gh_${V}_linux_$ARCH.tar.gz" | tar -C ~/.local/bin -xzf - --strip-components=2 "gh_${V}_linux_$ARCH/bin/gh"
|
|
||||||
```
|
|
||||||
|
|
||||||
To upgrade one, run the `ARCH=` line and that tool's two lines again; the
|
|
||||||
new binary overwrites the old one.
|
|
||||||
|
|
||||||
### Java, last
|
|
||||||
|
|
||||||
Java, with SDKMAN, from its [install guide](https://sdkman.io/install/).
|
|
||||||
SDKMAN and every JDK it installs live in `~/.sdkman`; it needs the `zip` and
|
|
||||||
`unzip` the `Dockerfile` installs. Its installer appends to `~/.zshrc` and
|
|
||||||
requires its lines to stay at the end of the file, so install it after
|
|
||||||
everything above. `sdk install java` with no version takes SDKMAN's default,
|
|
||||||
the current Temurin LTS:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
curl -s "https://get.sdkman.io" | bash
|
|
||||||
. "$HOME/.sdkman/bin/sdkman-init.sh"
|
|
||||||
sdk install java
|
|
||||||
```
|
|
||||||
|
|
||||||
`sdk list java` shows other vendors and versions, and `sdk install gradle` or
|
|
||||||
`sdk install maven` adds a build tool the same way.
|
|
||||||
|
|
||||||
## Environment
|
## Environment
|
||||||
|
|
||||||
| Variable | Purpose |
|
| Variable | Purpose |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `PASSWORD` | Web UI login. Required. |
|
| `PASSWORD` | Web UI login, also the in-container sudo password. **A blank value disables authentication entirely.** |
|
||||||
| `GIT_NAME` / `GIT_EMAIL` | Git author and committer identity |
|
| `GIT_NAME` / `GIT_EMAIL` | Git author and committer identity |
|
||||||
| `SERVICE_HOSTNAME` | Container hostname, and the name the shell prompt shows (also passed as `HOST`) |
|
|
||||||
| `CODE_SERVER_APP_NAME` | Optional. Name in the title bar and welcome page; defaults to `code-server`. |
|
|
||||||
|
|
||||||
Generate a password with `openssl rand -base64 24`.
|
Generate a password with `openssl rand -base64 24`.
|
||||||
|
|
||||||
The compose file refuses to start without `PASSWORD`. If it is unset, code-server
|
|
||||||
generates a random password into `~/.config/code-server/config.yaml`, which you
|
|
||||||
can only read from inside the container.
|
|
||||||
|
|
||||||
The `coder` user has passwordless `sudo`, as the image sets it up. Anyone who
|
|
||||||
can log in to the editor is root in the container.
|
|
||||||
|
|
||||||
`SERVICE_HOSTNAME` is used twice: as the container's `hostname:` and as the
|
|
||||||
`HOST` variable inside it. Coolify injects `HOST=0.0.0.0` into every compose
|
|
||||||
app, and zsh seeds `$HOST` and the `%m`/`%M` prompt escapes from that variable
|
|
||||||
rather than calling `gethostname()`, so a zsh prompt reads `0`. code-server
|
|
||||||
itself never reads `HOST`; it binds through `--bind-addr`. bash is unaffected;
|
|
||||||
its `\h` uses the real hostname.
|
|
||||||
|
|
||||||
## Docker access
|
|
||||||
|
|
||||||
The host's Docker socket is bind-mounted at `/var/run/docker.sock`. The image
|
|
||||||
ships no Docker CLI; install the client into home as above. Containers
|
|
||||||
started through it are siblings on the host, not children, so bind mounts in
|
|
||||||
them resolve against host paths.
|
|
||||||
|
|
||||||
The socket belongs to the host's `docker` group, whose GID differs from host
|
|
||||||
to host and is not exported anywhere a compose file could read it, so a fixed
|
|
||||||
`group_add:` would break on another host. Instead `entrypoint.sh` runs first
|
|
||||||
at startup: it reads the GID off the socket, adds `coder` to a group with that
|
|
||||||
GID (creating `docker-host` if none exists) and restarts the image's own
|
|
||||||
entrypoint under the new group. `docker` then works for `coder` without sudo,
|
|
||||||
in the editor's terminal and in `docker exec` shells alike. Access to the
|
|
||||||
socket is root on the host, which is accepted here because this is a
|
|
||||||
single-user dev box.
|
|
||||||
|
|
||||||
The wrapper keeps the image's `USER 1000`, so `docker exec` still opens a
|
|
||||||
shell as `coder`, and gets root through the image's passwordless sudo. A
|
|
||||||
running process cannot join a group, so it re-execs through `sudo -E setpriv
|
|
||||||
--init-groups`, which loads the new group; `PATH` and `USER` are passed
|
|
||||||
explicitly because sudo resets both. sudo stays as PID 1 and forwards stop
|
|
||||||
signals to code-server. On a container restart `coder` is already in the
|
|
||||||
group, and the wrapper hands straight to the image's entrypoint. Without a
|
|
||||||
socket mounted it does nothing.
|
|
||||||
|
|
||||||
The `:ro` flag is not a security boundary. It marks the socket file read-only,
|
|
||||||
but clients reach the Docker API by connecting to the socket, which a
|
|
||||||
read-only mount does not stop.
|
|
||||||
|
|
||||||
## Networking
|
## Networking
|
||||||
|
|
||||||
Listens on `8080`; point the domain at it.
|
Listens on `8443`. No ports are published — point the domain at that port in
|
||||||
|
Coolify or Dokploy. See the [root README](../README.md) for why.
|
||||||
|
|
||||||
## Storage
|
## Storage
|
||||||
|
|
||||||
| Volume | Mount | Holds |
|
Everything lives in the `code-server-config` named volume mounted at `/config`;
|
||||||
| --- | --- | --- |
|
the default workspace is `/config/workspace`. Removing the volume wipes your
|
||||||
| `code-server-home` | `/home/coder` | Home directory: settings, extensions, shell history, CLI logins |
|
files, settings, and extensions.
|
||||||
| `code-server-workspace` | `/workspace` | Code you work on |
|
|
||||||
|
|
||||||
The image's entrypoint opens `.`, its working directory. `entrypoint.sh`
|
|
||||||
changes into `DEFAULT_WORKSPACE`, set to `/workspace` in `compose.yml`, before
|
|
||||||
starting it, so that is the folder code-server opens and where new terminals
|
|
||||||
start. Changing the variable moves both without editing the compose file's
|
|
||||||
structure. `docker exec` shells are not affected and start in the image's
|
|
||||||
`/home/coder`.
|
|
||||||
|
|
||||||
The `Dockerfile` also makes `/workspace` writable. The image does not
|
|
||||||
ship that directory, so a named volume mounted there comes up `root:root`,
|
|
||||||
and `coder` (uid `1000`) cannot write to it. Creating the directory in the
|
|
||||||
image, owned by `1000:1000`, fixes that without a runtime step, because Docker
|
|
||||||
seeds an empty named volume from the image's directory, ownership included.
|
|
||||||
The home volume needs no such step, because the image already ships
|
|
||||||
`/home/coder` owned by `coder`.
|
|
||||||
|
|
||||||
`build-essential`, `bubblewrap`, `zip` and `unzip` are in the `Dockerfile`
|
|
||||||
rather than home because their projects publish no standalone binaries;
|
|
||||||
Debian's packages are the install method, and they land outside home. The base
|
|
||||||
image ships none of them. `build-essential` supplies the `gcc` and `make` that
|
|
||||||
native builds expect: the linker `cargo` calls, node-gyp addons and Python
|
|
||||||
sdists.
|
|
||||||
|
|
||||||
The command-line utilities are Debian packages too, kept in the image as a
|
|
||||||
baseline every fresh deploy has without a setup step:
|
|
||||||
|
|
||||||
| Group | Packages |
|
|
||||||
| --- | --- |
|
|
||||||
| Network and DNS | `iproute2` (`ss`, `ip`), `lsof`, `bind9-dnsutils` (`dig`), `netcat-openbsd` (`nc`), `socat`, `mtr-tiny`, `whois` |
|
|
||||||
| Search and review | `tree`, `fd-find`, `fzf`, `git-delta`, `bat` |
|
|
||||||
| Lint | `shellcheck`, `shfmt`, `yamllint` |
|
|
||||||
| Database clients | `postgresql-client` (`psql`), `sqlite3`, `redis-tools` (`redis-cli`) |
|
|
||||||
| Maintenance | `ncdu`, `tmux`, `restic`, `rclone`, `age`, `hyperfine`, `entr`, `7zip` (`7z`) |
|
|
||||||
|
|
||||||
Debian installs `fd` as `fdfind` and `bat` as `batcat`. They track Debian 13's
|
|
||||||
versions, which lag upstream: `rclone` is 1.60, and `postgresql-client` is 17,
|
|
||||||
whose `pg_dump` refuses to dump a newer server. Install a newer copy into home
|
|
||||||
when one of these falls short.
|
|
||||||
|
|
||||||
Everything else lives in home rather than the image, so the image build stays
|
|
||||||
small and a redeploy does not reinstall or upgrade tools behind your back;
|
|
||||||
upgrading is the same commands run again.
|
|
||||||
|
|
||||||
## Browser
|
|
||||||
|
|
||||||
Debian's `chromium` is in the image so that browser-automation CLIs, such as
|
|
||||||
agent-browser, can launch a headless browser inside the container, where they
|
|
||||||
reach dev servers on `localhost`. Chrome for Testing, the browser those tools
|
|
||||||
download by default, publishes no Linux ARM64 build, so the distro package is
|
|
||||||
the install method on an ARM host. agent-browser finds `/usr/bin/chromium` on
|
|
||||||
its own; no path variable is needed. Debian 13 ships Chromium through its
|
|
||||||
security repository, so it stays within a release of upstream stable.
|
|
||||||
|
|
||||||
`fonts-liberation`, `fonts-noto-color-emoji` and `fonts-noto-cjk` cover
|
|
||||||
Latin, Vietnamese, emoji and CJK text in pages and screenshots; the base image
|
|
||||||
ships almost no fonts. Together with Chromium they add about 850 MB to the
|
|
||||||
image.
|
|
||||||
|
|
||||||
There is no GPU. Headless Chromium renders in software, and WebGL runs on the
|
|
||||||
SwiftShader backend that the package bundles, which works but is slow.
|
|
||||||
|
|
||||||
`shm_size: 1gb` raises `/dev/shm`, the in-memory filesystem Chromium uses to
|
|
||||||
pass rendered frames and data between its processes. Docker's default is
|
|
||||||
64 MB, which makes tabs crash on heavy pages. The size is a ceiling, not a
|
|
||||||
reservation: memory is used only while something is stored there.
|
|
||||||
|
|
||||||
## Shell
|
|
||||||
|
|
||||||
`SHELL=/bin/zsh` in `compose.yml` picks the shell. The editor's terminal takes
|
|
||||||
its default from `$SHELL` first and only falls back to the login shell in
|
|
||||||
`/etc/passwd`, so the variable is enough, and switching to another shell the
|
|
||||||
image ships means changing one line rather than rebuilding. `sudo -E` in
|
|
||||||
`entrypoint.sh` keeps the variable; without it sudo would replace it with
|
|
||||||
root's `/bin/bash`. The value is written literally, not read from `.env`,
|
|
||||||
because every deploying shell exports its own `SHELL`, which interpolation
|
|
||||||
would pick up first.
|
|
||||||
|
|
||||||
## Image
|
|
||||||
|
|
||||||
`codercom/code-server:latest` is the Debian 13 build and moves with every
|
|
||||||
release. Upstream publishes no major tag.
|
|
||||||
+13
-14
@@ -1,24 +1,23 @@
|
|||||||
services:
|
services:
|
||||||
code-server:
|
code-server:
|
||||||
build: .
|
image: 'lscr.io/linuxserver/code-server:latest'
|
||||||
restart: unless-stopped
|
hostname: miti99-cs
|
||||||
hostname: ${SERVICE_HOSTNAME}
|
|
||||||
shm_size: 1gb
|
|
||||||
environment:
|
environment:
|
||||||
- PASSWORD=${PASSWORD:?required}
|
- PUID=1000
|
||||||
|
- PGID=1000
|
||||||
- TZ=Asia/Ho_Chi_Minh
|
- TZ=Asia/Ho_Chi_Minh
|
||||||
- DEFAULT_WORKSPACE=/workspace
|
- DEFAULT_WORKSPACE=/config/workspace
|
||||||
- SHELL=/bin/zsh
|
- PWA_APPNAME=code-server
|
||||||
|
- 'DOCKER_MODS=linuxserver/mods:universal-package-install|linuxserver/mods:universal-docker|linuxserver/mods:code-server-golang|linuxserver/mods:code-server-nodejs|linuxserver/mods:code-server-npmglobal|linuxserver/mods:code-server-pnpm|linuxserver/mods:code-server-python3|linuxserver/mods:code-server-zsh'
|
||||||
|
- INSTALL_PACKAGES=apache2-utils|bfs|ffmpeg|gh|git|imagemagick|librsvg2-bin|lsof|psmisc|ugrep|unzip|zip
|
||||||
|
- NODEJS_MOD_VERSION=24
|
||||||
|
- PASSWORD=${PASSWORD}
|
||||||
|
- SUDO_PASSWORD=${PASSWORD}
|
||||||
- GIT_AUTHOR_NAME=${GIT_NAME}
|
- GIT_AUTHOR_NAME=${GIT_NAME}
|
||||||
- GIT_AUTHOR_EMAIL=${GIT_EMAIL}
|
- GIT_AUTHOR_EMAIL=${GIT_EMAIL}
|
||||||
- GIT_COMMITTER_NAME=${GIT_NAME}
|
- GIT_COMMITTER_NAME=${GIT_NAME}
|
||||||
- GIT_COMMITTER_EMAIL=${GIT_EMAIL}
|
- GIT_COMMITTER_EMAIL=${GIT_EMAIL}
|
||||||
- HOST=${SERVICE_HOSTNAME}
|
|
||||||
# - CODE_SERVER_APP_NAME=${CODE_SERVER_APP_NAME:-code-server}
|
|
||||||
volumes:
|
volumes:
|
||||||
- 'code-server-home:/home/coder'
|
- 'code-server-config:/config'
|
||||||
- 'code-server-workspace:/workspace'
|
|
||||||
- '/var/run/docker.sock:/var/run/docker.sock:ro'
|
|
||||||
volumes:
|
volumes:
|
||||||
code-server-home:
|
code-server-config:
|
||||||
code-server-workspace:
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
set -eu
|
|
||||||
|
|
||||||
# Start in DEFAULT_WORKSPACE, the folder code-server opens.
|
|
||||||
if [ -n "${DEFAULT_WORKSPACE:-}" ]; then
|
|
||||||
cd "$DEFAULT_WORKSPACE"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Add coder to the group that owns the mounted Docker socket, then restart
|
|
||||||
# under that group.
|
|
||||||
if [ -S /var/run/docker.sock ]; then
|
|
||||||
gid=$(stat -c %g /var/run/docker.sock)
|
|
||||||
if ! id -G | tr ' ' '\n' | grep -qx "$gid"; then
|
|
||||||
group=$(getent group "$gid" | cut -d: -f1)
|
|
||||||
if [ -z "$group" ]; then
|
|
||||||
group=docker-host
|
|
||||||
sudo groupadd -g "$gid" "$group"
|
|
||||||
fi
|
|
||||||
sudo usermod -aG "$group" coder
|
|
||||||
exec sudo -E setpriv --reuid=coder --regid=coder --init-groups \
|
|
||||||
env PATH="$PATH" USER=coder /usr/bin/entrypoint.sh "$@"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Start the image's own entrypoint.
|
|
||||||
exec /usr/bin/entrypoint.sh "$@"
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
# Copy to .env and fill in. Never commit .env.
|
|
||||||
#
|
|
||||||
# cp .env.example .env
|
|
||||||
|
|
||||||
# No variables: the cluster is configured in the admin console on port 8091.
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
# couchbase
|
|
||||||
|
|
||||||
[Couchbase Server](https://www.couchbase.com), single node, defined in
|
|
||||||
`compose.yml`.
|
|
||||||
|
|
||||||
## Images
|
|
||||||
|
|
||||||
`couchbase:community` runs Community Edition, free in production for clusters
|
|
||||||
of up to five nodes, without XDCR. The untagged image is Enterprise Edition,
|
|
||||||
free only for development and testing; production needs a paid subscription.
|
|
||||||
Upstream publishes no moving major tag for Community, so `community` tracks
|
|
||||||
the newest Community release (8.0.x at the time of writing) and moves to the
|
|
||||||
next major when one ships.
|
|
||||||
|
|
||||||
## Networking
|
|
||||||
|
|
||||||
Clients connect to Couchbase directly on its ports, not through a domain on the
|
|
||||||
proxy, so it publishes them on the host:
|
|
||||||
|
|
||||||
| Ports | Purpose |
|
|
||||||
| --- | --- |
|
|
||||||
| `8091-8097` | Cluster manager, views, query, search, analytics, eventing |
|
|
||||||
| `18091-18097` | The same, over TLS |
|
|
||||||
| `11207`, `11210` | Data service (TLS, plain) |
|
|
||||||
| `11280`, `9123` | Internal services |
|
|
||||||
|
|
||||||
The admin console is on `8091`. Complete the first-run setup there.
|
|
||||||
|
|
||||||
## Storage
|
|
||||||
|
|
||||||
`couchbase-data` at `/opt/couchbase/var` — data, indexes, config, logs.
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
services:
|
|
||||||
couchbase:
|
|
||||||
image: couchbase:community
|
|
||||||
ports:
|
|
||||||
- "8091-8097:8091-8097"
|
|
||||||
- "9123:9123"
|
|
||||||
- "11207:11207"
|
|
||||||
- "11210:11210"
|
|
||||||
- "11280:11280"
|
|
||||||
- "18091-18097:18091-18097"
|
|
||||||
volumes:
|
|
||||||
- couchbase-data:/opt/couchbase/var
|
|
||||||
restart: unless-stopped
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
couchbase-data:
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
DIUN_PROVIDERS_DOCKER_WATCHBYDEFAULT=true
|
|
||||||
DIUN_NOTIF_TELEGRAM_TOKEN=
|
|
||||||
DIUN_NOTIF_TELEGRAM_CHATIDS=
|
|
||||||
DIUN_WATCH_SCHEDULE=0 0 * * *
|
|
||||||
# DIUN_WATCH_WORKERS=10
|
|
||||||
# DIUN_WATCH_JITTER=30s
|
|
||||||
TZ=UTC
|
|
||||||
# LOG_LEVEL=info
|
|
||||||
# LOG_JSON=false
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
# diun
|
|
||||||
|
|
||||||
[Diun](https://crazymax.dev/diun/) watches the images of every running
|
|
||||||
container and sends a Telegram message when one has an update. It only
|
|
||||||
notifies — it never pulls or restarts anything.
|
|
||||||
|
|
||||||
Two containers: `diun` itself, and `dockerproxy`, which hands it a read-only
|
|
||||||
slice of the Docker API.
|
|
||||||
|
|
||||||
## Docker API access
|
|
||||||
|
|
||||||
Diun needs the Docker API to enumerate containers and read the image reference
|
|
||||||
each one runs. Mounting `/var/run/docker.sock` into it directly would be
|
|
||||||
host-root-equivalent — the API has no read/write split, so anything that can
|
|
||||||
talk to the socket can create a privileged container that mounts `/`. Adding
|
|
||||||
`:ro` to the mount does not help: it stops the socket *file* being replaced, not
|
|
||||||
the API being used.
|
|
||||||
|
|
||||||
So the socket is mounted into
|
|
||||||
[tecnativa/docker-socket-proxy](https://github.com/Tecnativa/docker-socket-proxy)
|
|
||||||
instead, and Diun reaches it over the compose network at
|
|
||||||
`tcp://dockerproxy:2375`. `POST` is revoked by default in that image, so
|
|
||||||
container create, `exec`, start and kill return 403. A compromised Diun image
|
|
||||||
can no longer become root on the host — which matters because Diun's whole job
|
|
||||||
is to talk to the daemon.
|
|
||||||
|
|
||||||
Exactly two API sections are granted, both verified against a live watch cycle:
|
|
||||||
|
|
||||||
| Variable | Why |
|
|
||||||
| --- | --- |
|
|
||||||
| `CONTAINERS` | enumerate running containers |
|
|
||||||
| `IMAGES` | `ImageInspect` on each container's image — without it every image logs `403 Forbidden` and nothing is analysed |
|
|
||||||
|
|
||||||
`INFO`, `NETWORKS`, `VOLUMES` and the rest stay revoked; a watch cycle runs
|
|
||||||
clean without them.
|
|
||||||
|
|
||||||
## Environment
|
|
||||||
|
|
||||||
`DIUN_NOTIF_TELEGRAM_TOKEN` and `DIUN_NOTIF_TELEGRAM_CHATIDS` are required and
|
|
||||||
fail fast if unset. Everything else has a working default.
|
|
||||||
|
|
||||||
| Variable | Default | Purpose |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| `DIUN_NOTIF_TELEGRAM_TOKEN` | — | Bot token from @BotFather |
|
|
||||||
| `DIUN_NOTIF_TELEGRAM_CHATIDS` | — | Comma-separated chat ids to notify |
|
|
||||||
| `DIUN_PROVIDERS_DOCKER_WATCHBYDEFAULT` | `true` | Watch every container without per-container labels |
|
|
||||||
| `DIUN_WATCH_SCHEDULE` | `0 0 * * *` | Cron for the watch cycle — once a day at midnight `TZ` |
|
|
||||||
| `DIUN_WATCH_WORKERS` | `10` | Parallel registry lookups |
|
|
||||||
| `DIUN_WATCH_JITTER` | `30s` | Random delay before each job |
|
|
||||||
| `TZ` | `UTC` | Timezone for the schedule and log timestamps |
|
|
||||||
| `LOG_LEVEL` / `LOG_JSON` | `info` / `false` | Logging |
|
|
||||||
|
|
||||||
`DIUN_WATCH_WORKERS`, `DIUN_WATCH_JITTER`, `LOG_LEVEL` and `LOG_JSON` are
|
|
||||||
optional: their defaults are Diun’s own, so they are commented out.
|
|
||||||
|
|
||||||
State lives in the `diun-data` volume (`DIUN_DB_PATH=/data/diun.db`). Diun
|
|
||||||
notifies on first sight of an image, so a fresh volume produces one round of
|
|
||||||
notifications for everything currently running.
|
|
||||||
|
|
||||||
## Version pinning
|
|
||||||
|
|
||||||
`crazymax/diun:4` — the moving major tag, so patch and minor releases arrive on
|
|
||||||
the next pull without an edit here, while a breaking `5.x` never does. The
|
|
||||||
upstream project publishes `4` alongside every `4.x.y`, so the tag always
|
|
||||||
resolves to the newest release of that line.
|
|
||||||
|
|
||||||
`tecnativa/docker-socket-proxy:latest`. That project only publishes exact tags
|
|
||||||
(`v0.5.0`, `v0.4.2`, …) for its current scheme — the bare `0` and `0.3` tags
|
|
||||||
are stale leftovers from an older one — so there is no moving major tag to
|
|
||||||
follow and `latest` is the closest equivalent.
|
|
||||||
|
|
||||||
Moving tags mean an image can change under a redeploy without this file
|
|
||||||
changing. That is the accepted trade-off: the proxy is what bounds the damage a
|
|
||||||
bad image could do — `POST` is revoked, so no image pulled through either tag
|
|
||||||
can create a privileged container.
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
services:
|
|
||||||
diun:
|
|
||||||
image: crazymax/diun:4
|
|
||||||
restart: unless-stopped
|
|
||||||
command: serve
|
|
||||||
environment:
|
|
||||||
DIUN_PROVIDERS_DOCKER: "true"
|
|
||||||
DIUN_PROVIDERS_DOCKER_ENDPOINT: tcp://dockerproxy:2375
|
|
||||||
DIUN_PROVIDERS_DOCKER_WATCHBYDEFAULT: "${DIUN_PROVIDERS_DOCKER_WATCHBYDEFAULT:-true}"
|
|
||||||
DIUN_NOTIF_TELEGRAM_TOKEN: ${DIUN_NOTIF_TELEGRAM_TOKEN:?required}
|
|
||||||
DIUN_NOTIF_TELEGRAM_CHATIDS: ${DIUN_NOTIF_TELEGRAM_CHATIDS:?required}
|
|
||||||
DIUN_DB_PATH: /data/diun.db
|
|
||||||
DIUN_WATCH_SCHEDULE: "${DIUN_WATCH_SCHEDULE:-0 0 * * *}"
|
|
||||||
# DIUN_WATCH_WORKERS: "${DIUN_WATCH_WORKERS:-10}"
|
|
||||||
# DIUN_WATCH_JITTER: "${DIUN_WATCH_JITTER:-30s}"
|
|
||||||
TZ: "${TZ:-UTC}"
|
|
||||||
# LOG_LEVEL: "${LOG_LEVEL:-info}"
|
|
||||||
# LOG_JSON: "${LOG_JSON:-false}"
|
|
||||||
volumes:
|
|
||||||
- diun-data:/data
|
|
||||||
depends_on:
|
|
||||||
- dockerproxy
|
|
||||||
|
|
||||||
# Read-only slice of the Docker API.
|
|
||||||
dockerproxy:
|
|
||||||
image: tecnativa/docker-socket-proxy:latest
|
|
||||||
restart: unless-stopped
|
|
||||||
environment:
|
|
||||||
CONTAINERS: 1
|
|
||||||
IMAGES: 1
|
|
||||||
security_opt:
|
|
||||||
- no-new-privileges:true
|
|
||||||
volumes:
|
|
||||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
diun-data:
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
# Docker logs re-sent on restart
|
|
||||||
|
|
||||||
Applies to `alloy/compose.yml`. Checked against Alloy v1.20.1.
|
|
||||||
|
|
||||||
## Symptom
|
|
||||||
|
|
||||||
Every time Alloy restarts, `loki.write` logs one rejected batch:
|
|
||||||
|
|
||||||
```
|
|
||||||
final error sending batch, no retries left, dropping data ... status=400
|
|
||||||
13 errors like: entry for stream '{container="coolify-realtime", ...}'
|
|
||||||
has timestamp too old: 2026-09-18T17:00:51Z, oldest acceptable timestamp is: ...
|
|
||||||
```
|
|
||||||
|
|
||||||
The same containers, the same timestamps and the same counts show up on every
|
|
||||||
restart.
|
|
||||||
|
|
||||||
## Cause
|
|
||||||
|
|
||||||
`loki.source.docker` saves each container's read position as a Unix timestamp
|
|
||||||
in **seconds** (`internal/component/loki/source/docker/tailer.go`,
|
|
||||||
`t.positions.Put(..., ts.Unix())`). On start it asks the Docker API for logs
|
|
||||||
`since` that second, and Docker includes the second itself. So every restart
|
|
||||||
re-reads every line written in the container's last logged second.
|
|
||||||
|
|
||||||
For a busy container those lines are recent, and Loki silently discards them
|
|
||||||
as exact duplicates. For an idle container whose last line is more than 7 days
|
|
||||||
old, Grafana Cloud Loki rejects them with `timestamp too old` instead.
|
|
||||||
Positions are kept in the `alloy-data` volume, so they are not being lost.
|
|
||||||
Re-reading that last second is simply how the component works.
|
|
||||||
|
|
||||||
## Fix
|
|
||||||
|
|
||||||
`loki.process "logs_integrations_docker"` sits between `loki.source.docker` and
|
|
||||||
`loki.write` and drops lines older than `168h` with `stage.drop`. Those lines
|
|
||||||
would be rejected by Loki anyway, so nothing that would have been stored is
|
|
||||||
lost. The drops are counted under `loki_process_dropped_lines_total{reason="too_old"}`.
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
# Duplicate journal and syslog lines
|
|
||||||
|
|
||||||
Applies to `alloy/compose.yml`.
|
|
||||||
|
|
||||||
## Symptom
|
|
||||||
|
|
||||||
The same log line arrives in Loki twice: once from `loki.source.journal`, once
|
|
||||||
from `loki.source.file`.
|
|
||||||
|
|
||||||
## Cause
|
|
||||||
|
|
||||||
Where rsyslog mirrors journald into `/var/log/syslog` — the Debian and Ubuntu
|
|
||||||
default — the journal and file pipelines both ship it.
|
|
||||||
|
|
||||||
## Fix
|
|
||||||
|
|
||||||
Drop one source on those hosts. On systemd-only stacks the file-based one is
|
|
||||||
the redundant one.
|
|
||||||
@@ -1,103 +0,0 @@
|
|||||||
# Known Noise: Coolify SSH Session Spam
|
|
||||||
|
|
||||||
> **Skip this doc** if you don't use [Coolify](https://coolify.io/) to manage the host. This is a special case for our setup, not a general issue.
|
|
||||||
|
|
||||||
## Symptom
|
|
||||||
|
|
||||||
Grafana Loki shows a constant stream of `info`-level lines on the **Linux Node** dashboard:
|
|
||||||
|
|
||||||
```
|
|
||||||
Session closed
|
|
||||||
New session NNNNN of user root.
|
|
||||||
```
|
|
||||||
|
|
||||||
Labels: `instance=<host>`, `job=integrations/node_exporter`, `level=info`, `unit=systemd-logind.service` (or `session-NNN.scope`).
|
|
||||||
|
|
||||||
Rate observed on a Coolify-managed host: **~300 sessions/hour ≈ 5/min**, in bursts of 2–3 within the same second.
|
|
||||||
|
|
||||||
## Cause
|
|
||||||
|
|
||||||
The Coolify host SSHes into each managed server every minute to check reachability and collect Docker container state. Each SSH connect → PAM `session opened` + `session closed` → systemd-logind writes it to the journal → `loki.source.journal` ships it to Grafana Cloud.
|
|
||||||
|
|
||||||
The **misleading part**: the label `job=integrations/node_exporter` makes it look like node_exporter emitted the log. It didn't. node_exporter only produces metrics — the journal-log pipeline reuses that label so the logs land on the same dashboard.
|
|
||||||
|
|
||||||
## Exact Coolify call flow (verified against source)
|
|
||||||
|
|
||||||
`coollabsio/coolify` v4.x — `app/Console/Kernel.php` and `app/Jobs/ServerManagerJob.php`:
|
|
||||||
|
|
||||||
1. **`ServerManagerJob` runs every minute** (self-hosted) or every 5 minutes (Cloud).
|
|
||||||
2. Per managed server, per cycle, it dispatches:
|
|
||||||
|
|
||||||
| Job | What it does over SSH | Skip condition |
|
|
||||||
|---|---|---|
|
|
||||||
| `ServerConnectionCheckJob` | Opens SSH, runs reachability probe | `isSentinelEnabled() && isSentinelLive()` |
|
|
||||||
| `ServerCheckJob` | Opens SSH, runs `docker container ls --format json` + proxy/log-drain checks | Sentinel "in sync" — last push within `sentinel_push_interval_seconds × 3` (min 120s) |
|
|
||||||
| `ServerStorageCheckJob` | Opens SSH, reads filesystem usage | Daily cron only (`0 23 * * *`), and only when Sentinel out of sync |
|
|
||||||
| `ServerPatchCheckJob` | Opens SSH, checks patch info | Weekly only (`0 0 * * 0`) |
|
|
||||||
| `CheckAndStartSentinelJob` | Opens SSH to (re)start the Sentinel container | Daily only |
|
|
||||||
|
|
||||||
= **roughly 2–3 SSH connects per minute per server** when Sentinel is OFF (matches the 5/min × bursts pattern in our journal).
|
|
||||||
|
|
||||||
3. The only built-in throttle is `shouldSkipDueToBackoff` — it backs off to every 3 / 6 / 12 minutes only **after the server has been marked unreachable** several times. There is **no UI setting to slow checks on a healthy server**.
|
|
||||||
|
|
||||||
## How to confirm on your host
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Top sources of SSH sessions in the last hour
|
|
||||||
sudo journalctl _COMM=sshd --since "1 hour ago" \
|
|
||||||
| grep "Accepted" | grep -oE "from [0-9.]+" | sort | uniq -c | sort -rn | head
|
|
||||||
|
|
||||||
# Match the SSH key fingerprint to its owner — replace fingerprint
|
|
||||||
while read -r line; do
|
|
||||||
fp=$(echo "$line" | ssh-keygen -lf - 2>/dev/null | awk '{print $2}')
|
|
||||||
[[ "$fp" == "SHA256:<paste-fingerprint-here>" ]] && echo "MATCH: $line"
|
|
||||||
done < /root/.ssh/authorized_keys
|
|
||||||
```
|
|
||||||
|
|
||||||
If the matching key's comment is `coolify` → this doc applies.
|
|
||||||
|
|
||||||
## Fixes (pick one)
|
|
||||||
|
|
||||||
### Option A — drop the noise at Alloy (host stays as-is)
|
|
||||||
|
|
||||||
Edit the `loki.process "default"` block inside `journal_module` in `alloy/compose.yml`:
|
|
||||||
|
|
||||||
```alloy
|
|
||||||
loki.process "default" {
|
|
||||||
forward_to = argument.forward_to.value
|
|
||||||
|
|
||||||
stage.drop {
|
|
||||||
expression = "(session opened|session closed|New session|Removed session) .*"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Restart: `docker compose up -d --force-recreate alloy`.
|
|
||||||
|
|
||||||
Trade-off: also loses visibility of legitimate human SSH logins. Narrow to `unit="systemd-logind.service"` or to a specific source IP if you want to keep auditing real users.
|
|
||||||
|
|
||||||
### Option B — eliminate the SSH polling at Coolify (recommended root-cause fix)
|
|
||||||
|
|
||||||
Enable **Sentinel** on the managed server. Sentinel is a small `coolify-sentinel` container that *pushes* metrics to the Coolify API. When it's healthy, `ServerManagerJob` **skips both `ServerConnectionCheckJob` and `ServerCheckJob`** for that server — SSH polling stops.
|
|
||||||
|
|
||||||
**Steps in the Coolify UI:**
|
|
||||||
|
|
||||||
1. Go to **Servers → `<your server>` → Configurations → General**.
|
|
||||||
2. Toggle **Sentinel** on. Optionally toggle **Metrics** in the same section if you want CPU/mem/disk pushed too.
|
|
||||||
3. Save. Coolify will deploy the `coolify-sentinel` container on the target server.
|
|
||||||
4. Wait ~2 minutes. Verify on the server: `docker ps | grep coolify-sentinel`.
|
|
||||||
5. Re-check the journal — SSH session rate should drop to occasional (daily Sentinel restart, weekly patch check, on-demand deploys), not ~5/min.
|
|
||||||
|
|
||||||
**Tunable:** `sentinel_push_interval_seconds` in the server's settings controls push cadence and the SSH-skip window (skip if last push within `× 3`, min 120s). Lower = fresher metrics, slightly more API traffic from Sentinel.
|
|
||||||
|
|
||||||
**Caveats:** Sentinel is flagged "experimental" in the Coolify docs. Metrics collection is **not** available for Docker Compose / Service-Template-based deployments — but Sentinel itself (connectivity + container status) still works.
|
|
||||||
|
|
||||||
## Why we don't ship a filter by default
|
|
||||||
|
|
||||||
The base setup is meant to be a generic Grafana Cloud Linux/Docker integration. Coolify-specific filtering belongs in a host-specific overlay, not in the shared compose file.
|
|
||||||
|
|
||||||
## References
|
|
||||||
|
|
||||||
- Coolify Sentinel docs: <https://coolify.io/docs/knowledge-base/server/sentinel>
|
|
||||||
- DeepWiki — Server Monitoring: <https://deepwiki.com/coollabsio/coolify/3.5-server-monitoring-(sentinel-and-metrics)>
|
|
||||||
- Source: [`app/Console/Kernel.php`](https://github.com/coollabsio/coolify/blob/v4.x/app/Console/Kernel.php), [`app/Jobs/ServerManagerJob.php`](https://github.com/coollabsio/coolify/blob/v4.x/app/Jobs/ServerManagerJob.php), [`app/Jobs/ServerCheckJob.php`](https://github.com/coollabsio/coolify/blob/v4.x/app/Jobs/ServerCheckJob.php)
|
|
||||||
@@ -1,103 +0,0 @@
|
|||||||
# Upstream Sources of Truth
|
|
||||||
|
|
||||||
## Hard rule: official sources only
|
|
||||||
|
|
||||||
> **Only use official sources** when deciding what metrics, labels, log pipelines, dashboards, or rules this repo ships. **Never** consult or copy from third-party repos, blog posts, community Gists, or personal exports — even if the names match.
|
|
||||||
>
|
|
||||||
> If an answer can't be found in an official source, document the gap rather than fill it with unofficial data. **Non-negotiable.**
|
|
||||||
|
|
||||||
### What counts as an official source
|
|
||||||
|
|
||||||
| Tier | Source | Use for |
|
|
||||||
|---|---|---|
|
|
||||||
| 1 | `https://grafana.com/docs/grafana-cloud/...` (Grafana Cloud integration reference pages) | Alloy snippet, scrape config, label conventions, drop/keep rules |
|
|
||||||
| 2 | `github.com/grafana/...` (Grafana Labs org repos) | Mixin libsonnet/jsonnet, `dashboards_out/*.json`, alerting rules |
|
|
||||||
| 3 | `github.com/prometheus/...` (Prometheus org repos) | Upstream node_exporter mixin, prom-mixin libraries |
|
|
||||||
| 4 | The Grafana Cloud stack's own running dashboards (via authenticated API) | Authoritative answer for "what does my live integration use" |
|
|
||||||
|
|
||||||
### What doesn't count (do not use)
|
|
||||||
|
|
||||||
- Personal forks, Terraform exports, ops-team copies of integration dashboards.
|
|
||||||
- `grafana.com/grafana/dashboards/<id>` community submissions, unless explicitly published by the `grafana` user.
|
|
||||||
- AI summaries of dashboards.
|
|
||||||
- Stale snapshots in unrelated repos that happen to contain `job=integrations/...` strings.
|
|
||||||
|
|
||||||
## Principle
|
|
||||||
|
|
||||||
Alloy here ships **only what the integration dashboards need** — not the full firehose of every metric/log line node_exporter and cadvisor can produce. The goal is dashboards that work out-of-the-box on Grafana Cloud, without paying for unused active series.
|
|
||||||
|
|
||||||
When the upstream integration changes (adds a metric, drops a panel, renames a label), this repo follows. Local additions outside that scope go into a separate, named, documented overlay.
|
|
||||||
|
|
||||||
## Upstream references
|
|
||||||
|
|
||||||
The two reference pages `alloy/compose.yml` mirrors:
|
|
||||||
|
|
||||||
- **Linux Node integration** — <https://grafana.com/docs/grafana-cloud/monitor-infrastructure/integrations/integration-reference/integration-linux-node/>
|
|
||||||
- **Docker integration** — <https://grafana.com/docs/grafana-cloud/monitor-infrastructure/integrations/integration-reference/integration-docker/>
|
|
||||||
|
|
||||||
Where official mixin dashboards exist they're tier-2 corroboration:
|
|
||||||
|
|
||||||
- Docker mixin (Grafana Labs): <https://github.com/grafana/jsonnet-libs/blob/master/docker-mixin/dashboards_out/docker.json>
|
|
||||||
- Node-exporter mixin (Prometheus project): <https://github.com/prometheus/node_exporter/tree/master/docs/node-mixin>
|
|
||||||
|
|
||||||
## Mapping our config to upstream
|
|
||||||
|
|
||||||
| Block in `alloy/compose.yml` | Upstream source |
|
|
||||||
|---|---|
|
|
||||||
| `prometheus.exporter.unix` (collectors, mounts, fs/net excludes) | Linux Node integration page → "Configure Alloy" |
|
|
||||||
| `prometheus.relabel "integrations_node_exporter"` (`keep` allowlist of 157 metrics) | Linux Node integration page → [Metrics](https://grafana.com/docs/grafana-cloud/monitor-infrastructure/integrations/integration-reference/integration-linux-node/#metrics) section, verbatim |
|
|
||||||
| `loki.source.journal "default"` + relabel rules (`unit`, `boot_id`, `transport`, `level`) | Linux Node integration page → log scraping |
|
|
||||||
| `loki.source.file` for `/var/log/{syslog,messages,*.log}` | Linux Node integration page → file log scraping |
|
|
||||||
| `prometheus.exporter.cadvisor` (`docker_only = true`) | Docker integration page |
|
|
||||||
| `prometheus.relabel "integrations_cadvisor"` (`keep` allowlist of 16 metrics) | Docker integration page → [Metrics](https://grafana.com/docs/grafana-cloud/monitor-infrastructure/integrations/integration-reference/integration-docker/#metrics) section, verbatim |
|
|
||||||
| `discovery.docker` + `loki.source.docker` (job/instance/container/stream) | Docker integration page → log scraping |
|
|
||||||
| `loki.process "logs_integrations_docker"` (drop lines older than 168h) | Local addition, not upstream. Drops only what Loki rejects anyway; see [docker-logs-resent-on-restart.md](docker-logs-resent-on-restart.md) |
|
|
||||||
|
|
||||||
## What "follow upstream" means in practice
|
|
||||||
|
|
||||||
1. **Don't expand the metric set unilaterally.** If a panel in a Grafana Cloud dashboard requires a metric we don't ship, we'd add it — but the trigger is "the integration dashboard needs it, verified from a tier 1–4 source", not "node_exporter exposes it".
|
|
||||||
2. **Don't filter further than upstream does.** We ship at least what the upstream config does. Tightening (e.g. for cost) goes in a clearly-named overlay or a downstream env-specific config.
|
|
||||||
3. **Re-check on Alloy/integration major versions.** When bumping `grafana/alloy` image or when the Grafana Cloud integration revs, diff the upstream Alloy snippet against `alloy/compose.yml` and update.
|
|
||||||
|
|
||||||
## Audit (2026-04-26)
|
|
||||||
|
|
||||||
Both keep-lists in `alloy/compose.yml` are copied verbatim from the **Metrics** section of each integration page (tier 1):
|
|
||||||
|
|
||||||
- **Linux-Node** — 157 raw metrics (`node_*`, `process_max_fds`, `process_open_fds`, `up`). The list also contains `instance:node_num_cpu:sum`, which is a recording-rule output computed server-side by Grafana Cloud's ruler — it's intentionally **not** in the keep-list because the agent doesn't produce it.
|
|
||||||
- **Docker** — 16 metrics (`container_*`, `machine_memory_bytes`, `machine_scrape_error`, `up`).
|
|
||||||
|
|
||||||
Re-verification on 2026-04-26 also confirmed:
|
|
||||||
|
|
||||||
- **Alloy image** bumped `v1.10.0` → `v1.16.1` (latest stable; v1.16.0 released 2026-04-23, patched to v1.16.1 on 2026-05-05).
|
|
||||||
- **`loki.source.journal` `path`** was previously pinned to `/var/log/journal`; upstream omits the field, letting Alloy default to **both** `/var/log/journal` (persistent) and `/run/log/journal` (volatile). Local now matches — `path` removed.
|
|
||||||
|
|
||||||
The Grafana Cloud integration's full dashboard set (the 7 Linux-Node + 2 Docker dashboards) is not publicly hosted. Tier-4 verification (against the live stack via authenticated API) was **not** performed and is the only known gap.
|
|
||||||
|
|
||||||
### Re-running the audit
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Tier 2 — Grafana Labs docker-mixin
|
|
||||||
curl -sL https://raw.githubusercontent.com/grafana/jsonnet-libs/master/docker-mixin/dashboards_out/docker.json \
|
|
||||||
| jq -r '.. | .expr? // empty' \
|
|
||||||
| grep -oE 'container_[a-zA-Z0-9_]+|machine_[a-zA-Z0-9_]+' | sort -u
|
|
||||||
|
|
||||||
# Tier 3 — Prometheus node-exporter mixin (libsonnet, grep for metric names)
|
|
||||||
curl -sL https://raw.githubusercontent.com/prometheus/node_exporter/master/docs/node-mixin/lib/prom-mixin.libsonnet \
|
|
||||||
| grep -oE 'node_[a-zA-Z0-9_]+|process_[a-zA-Z0-9_]+' | sort -u
|
|
||||||
|
|
||||||
# Tier 4 — your own Grafana Cloud stack
|
|
||||||
GRAFANA_TOKEN=... # service-account token, dashboards:read
|
|
||||||
STACK=<your-stack>.grafana.net
|
|
||||||
curl -s -H "Authorization: Bearer $GRAFANA_TOKEN" \
|
|
||||||
"https://$STACK/api/search?query=Linux+Node&type=dash-db" \
|
|
||||||
| jq -r '.[].uid' | while read uid; do
|
|
||||||
curl -s -H "Authorization: Bearer $GRAFANA_TOKEN" \
|
|
||||||
"https://$STACK/api/dashboards/uid/$uid" > "$uid.json"
|
|
||||||
done
|
|
||||||
# then jq + grep as above
|
|
||||||
```
|
|
||||||
|
|
||||||
## Out of scope
|
|
||||||
|
|
||||||
- Custom dashboards or metrics for app-level monitoring (deploy a separate Alloy config for that).
|
|
||||||
- Host-specific filtering (e.g. dropping noisy log lines from a particular daemon — see [known-noise-coolify-ssh-sessions.md](known-noise-coolify-ssh-sessions.md) for an example).
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
# gitea troubleshooting
|
|
||||||
|
|
||||||
Applies to `gitea/compose.yml`.
|
|
||||||
|
|
||||||
## Gitea cannot connect after changing `POSTGRES_PASSWORD`
|
|
||||||
|
|
||||||
Postgres sets the password only when it first initialises `db-data`. Changing
|
|
||||||
`POSTGRES_PASSWORD` later breaks Gitea's connection until the role is altered
|
|
||||||
to match:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
docker compose exec db psql -U gitea -c "ALTER USER gitea PASSWORD '<new>';"
|
|
||||||
```
|
|
||||||
|
|
||||||
## A redeploy kills clones in progress
|
|
||||||
|
|
||||||
Gitea restarts and the clone dies with it. Avoid pushing to `gitea/`
|
|
||||||
while a large first mirror runs.
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
# openclaw doctor findings
|
|
||||||
|
|
||||||
State of `openclaw doctor` on the miti-sg deployment after the migration from
|
|
||||||
the old Temp service, as of release 2026.9.8.
|
|
||||||
|
|
||||||
## Live-config workaround to undo
|
|
||||||
|
|
||||||
On ARM64, 2026.9.8 looks for the image's Playwright Chromium only under
|
|
||||||
`chrome-linux64/` and `chrome-linux/`, but the ARM64 build lives in
|
|
||||||
`chrome-linux-arm64/`. Upstream `main` already searches that directory
|
|
||||||
(`extensions/browser/src/browser/chrome.executables.ts`). Until a release
|
|
||||||
carries it, the live config points at the binary directly:
|
|
||||||
|
|
||||||
```
|
|
||||||
browser.executablePath = /home/node/.cache/ms-playwright/chromium-1243/chrome-linux-arm64/chrome
|
|
||||||
```
|
|
||||||
|
|
||||||
The path names the Playwright build (`chromium-1243`), so it breaks when an
|
|
||||||
image update ships a newer Chromium, and doctor will report the browser as
|
|
||||||
missing again. Once a release auto-detects the ARM64 build, remove the
|
|
||||||
override:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
node openclaw.mjs config unset browser.executablePath
|
|
||||||
```
|
|
||||||
|
|
||||||
The same session also set `browser.headless=true` and
|
|
||||||
`browser.noSandbox=true`. Chromium aborts inside this container with its
|
|
||||||
sandbox enabled, and the container has no display. These two stay.
|
|
||||||
|
|
||||||
## Notes that remain, by design
|
|
||||||
|
|
||||||
| Note | Why it stays |
|
|
||||||
| --- | --- |
|
|
||||||
| Session SQLite: 31 deferred historical transcripts | Daily 03:00 cron runs from May–June 2026 (one prompt, one reply each) plus one probe, whose header ID differs from the file name. Upstream: no action needed when expected conversations are present; do not edit receipts to silence it. |
|
|
||||||
| Gateway: service management skipped | Docker supervises the process, not OpenClaw. |
|
|
||||||
| Host desktop: disabled | Optional desktop lab, not used. |
|
|
||||||
| Security: bound to `lan` | Required in a container behind Coolify's proxy; auth is the gateway token. Fires for any non-loopback bind. |
|
|
||||||
| GitHub projects | Optional; set `gateway.controlUi.github.token` only if private-repo search is wanted. |
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
# paseo troubleshooting
|
|
||||||
|
|
||||||
Applies to `paseo/compose.yml`.
|
|
||||||
|
|
||||||
## The pairing screen stays on `localhost:6767`
|
|
||||||
|
|
||||||
After entering the address with its port, the UI may still show
|
|
||||||
`localhost:6767`: the old entry is cached in `localStorage`. Clear the site
|
|
||||||
data for the domain and enter it again.
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
BETTER_AUTH_SECRET=
|
|
||||||
ENCRYPTION_SECRET=
|
|
||||||
GITEA_MIRROR_URL=https://gitea-mirror.example.com
|
|
||||||
HTTP_IDLE_TIMEOUT=3600
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
# gitea-mirror
|
|
||||||
|
|
||||||
[gitea-mirror](https://github.com/RayLabsHQ/gitea-mirror) mirroring GitHub
|
|
||||||
repositories into a Gitea instance.
|
|
||||||
|
|
||||||
## Services
|
|
||||||
|
|
||||||
| Service | Image | Internal port | Domain |
|
|
||||||
| --- | --- | --- | --- |
|
|
||||||
| `gitea-mirror` | `ghcr.io/raylabshq/gitea-mirror:latest` | 4321 | `GITEA_MIRROR_URL` |
|
|
||||||
|
|
||||||
In Coolify, give `gitea-mirror` a domain on port 4321 matching
|
|
||||||
`GITEA_MIRROR_URL`. The image ships its own health check; the compose file
|
|
||||||
keeps its command and only slows how often it runs.
|
|
||||||
|
|
||||||
## Variables
|
|
||||||
|
|
||||||
| Variable | Feeds | Notes |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| `BETTER_AUTH_SECRET` | gitea-mirror | Signs sessions and encrypts its login keys. Generate with `openssl rand -base64 32`. |
|
|
||||||
| `ENCRYPTION_SECRET` | gitea-mirror | Encrypts the stored GitHub and Gitea tokens. Generate with `openssl rand -base64 48`. |
|
|
||||||
| `GITEA_MIRROR_URL` | `BETTER_AUTH_URL`, `PUBLIC_BETTER_AUTH_URL`, `BETTER_AUTH_TRUSTED_ORIGINS` | Public URL of the mirror UI, no trailing slash. |
|
|
||||||
| `HTTP_IDLE_TIMEOUT` | `BUN_CONFIG_HTTP_IDLE_TIMEOUT` | Seconds an outgoing HTTP request may sit idle before Bun drops it. Set it to at least the target Gitea's clone timeout. Defaults to `3600`; at most `14340`. |
|
|
||||||
|
|
||||||
Behind a reverse proxy, gitea-mirror rejects sign-in with "invalid origin"
|
|
||||||
unless all three Better Auth variables hold the external URL, so one variable
|
|
||||||
feeds them all.
|
|
||||||
|
|
||||||
Both secrets are set explicitly rather than left to the image, which would
|
|
||||||
otherwise generate its own into `gitea-mirror-data`. Data encrypted under one
|
|
||||||
secret is unreadable under another, so moving the data to a new deployment
|
|
||||||
means carrying the secrets with it. Never change either on an existing
|
|
||||||
install.
|
|
||||||
|
|
||||||
## Choices
|
|
||||||
|
|
||||||
- **Waits as long as Gitea clones.** Gitea's migrate API sends nothing until
|
|
||||||
the clone ends, and Bun's `fetch` drops a connection idle for 5 minutes.
|
|
||||||
gitea-mirror then marks the repository failed while Gitea keeps cloning; on
|
|
||||||
retry it finds the half-made repository and marks it mirrored. If that
|
|
||||||
clone later fails, Gitea keeps an empty repository with no mirror record,
|
|
||||||
which never syncs. `HTTP_IDLE_TIMEOUT` is set at least as long as Gitea's
|
|
||||||
clone timeout so the request outlives the clone. Bun caps it at 239
|
|
||||||
minutes, so a larger value stops helping there.
|
|
||||||
- **Health check every 5 minutes.** Each call to `/api/health` also asks
|
|
||||||
GitHub's API, without a token, for the latest gitea-mirror release, and a
|
|
||||||
failed answer is not cached. At the image's 30-second interval that is 120
|
|
||||||
calls an hour against GitHub's limit of 60 unauthenticated calls per IP, so
|
|
||||||
the check fails and logs a 403 every time. Five minutes makes it 12. During
|
|
||||||
the first 30 seconds it still checks every 5 seconds, so a deploy turns
|
|
||||||
healthy as quickly as before. Mirroring itself uses the GitHub token and is
|
|
||||||
unaffected either way.
|
|
||||||
- **`gitea-mirror:latest`** with `pull_policy: always`: upstream publishes no
|
|
||||||
major tag, so every redeploy takes the newest release.
|
|
||||||
|
|
||||||
## Usage
|
|
||||||
|
|
||||||
Sign up at `GITEA_MIRROR_URL`, then in its settings enter the Gitea instance's
|
|
||||||
public URL and an access token, plus the GitHub token to mirror from. These
|
|
||||||
are stored in `gitea-mirror-data`, not in the environment.
|
|
||||||
|
|
||||||
## Storage
|
|
||||||
|
|
||||||
| Volume | Holds |
|
|
||||||
| --- | --- |
|
|
||||||
| `gitea-mirror-data` | Mirror job database, settings and generated secrets |
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
services:
|
|
||||||
gitea-mirror:
|
|
||||||
image: ghcr.io/raylabshq/gitea-mirror:latest
|
|
||||||
restart: unless-stopped
|
|
||||||
pull_policy: always
|
|
||||||
environment:
|
|
||||||
BETTER_AUTH_SECRET: ${BETTER_AUTH_SECRET:?required}
|
|
||||||
ENCRYPTION_SECRET: ${ENCRYPTION_SECRET:?required}
|
|
||||||
BETTER_AUTH_URL: ${GITEA_MIRROR_URL:?required}
|
|
||||||
PUBLIC_BETTER_AUTH_URL: ${GITEA_MIRROR_URL:?required}
|
|
||||||
BETTER_AUTH_TRUSTED_ORIGINS: ${GITEA_MIRROR_URL:?required}
|
|
||||||
BUN_CONFIG_HTTP_IDLE_TIMEOUT: ${HTTP_IDLE_TIMEOUT:-3600}
|
|
||||||
volumes:
|
|
||||||
- gitea-mirror-data:/app/data
|
|
||||||
healthcheck:
|
|
||||||
interval: 5m
|
|
||||||
start_period: 30s
|
|
||||||
start_interval: 5s
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
gitea-mirror-data:
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
POSTGRES_PASSWORD=gitea
|
|
||||||
GITEA_ROOT_URL=https://gitea.example.com/
|
|
||||||
GITEA_CLONE_TIMEOUT=3600
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
# gitea
|
|
||||||
|
|
||||||
Self-hosted [Gitea](https://about.gitea.com/) backed by PostgreSQL.
|
|
||||||
|
|
||||||
## Services
|
|
||||||
|
|
||||||
| Service | Image | Internal port | Domain |
|
|
||||||
| --- | --- | --- | --- |
|
|
||||||
| `db` | `postgres:18-alpine` | 5432 | none |
|
|
||||||
| `gitea` | `gitea/gitea:28` | 3000 | `GITEA_ROOT_URL` |
|
|
||||||
|
|
||||||
In Coolify, give `gitea` a domain on port 3000 matching `GITEA_ROOT_URL`.
|
|
||||||
|
|
||||||
`gitea` waits for `db` to pass its health check, and checks `/api/healthz`
|
|
||||||
itself.
|
|
||||||
|
|
||||||
## Variables
|
|
||||||
|
|
||||||
| Variable | Feeds | Notes |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| `POSTGRES_PASSWORD` | `db`, `gitea` | Defaults to `gitea`. |
|
|
||||||
| `GITEA_ROOT_URL` | Gitea `server.ROOT_URL` | Public URL, with trailing slash. Gitea builds clone URLs and redirects from it. |
|
|
||||||
| `GITEA_CLONE_TIMEOUT` | Gitea `git.timeout` `MIGRATE` and `MIRROR` | Seconds a mirror's first clone or a later fetch may run. Defaults to `3600`. |
|
|
||||||
|
|
||||||
## Choices
|
|
||||||
|
|
||||||
- **HTTPS only.** The proxy routes HTTP, not SSH, so Gitea's SSH server is
|
|
||||||
disabled and the UI offers HTTPS clone URLs only.
|
|
||||||
- **Registration disabled.** The instance is public, and open sign-up drew
|
|
||||||
spam accounts. Admins create users from the site administration page.
|
|
||||||
- **One-hour clone timeout.** Gitea's defaults (600 s to migrate, 300 s to
|
|
||||||
fetch) cut off multi-gigabyte repositories mid-clone, leaving empty mirrors
|
|
||||||
that still hold gigabytes of unreachable packfiles. A client that calls the
|
|
||||||
migrate API gets no response until the clone ends, so it must wait at least
|
|
||||||
this long.
|
|
||||||
- **`gitea/gitea:28`.** Gitea publishes major tags; the major pin takes
|
|
||||||
updates without a surprise major upgrade.
|
|
||||||
- **`postgres:18-alpine`**, the newest Postgres major; Gitea needs 12 or
|
|
||||||
later. The major stays pinned because a new major cannot read an existing
|
|
||||||
data directory without a dump and restore. From 18 the image keeps its data
|
|
||||||
in `/var/lib/postgresql/18/docker`, so `db-data` mounts at
|
|
||||||
`/var/lib/postgresql`, which lets a later major sit beside it on the same
|
|
||||||
volume.
|
|
||||||
|
|
||||||
## Usage
|
|
||||||
|
|
||||||
Complete Gitea's first-run setup at `GITEA_ROOT_URL`, creating the admin
|
|
||||||
account there since sign-up is closed, then create an access
|
|
||||||
token for any tool that mirrors into it.
|
|
||||||
|
|
||||||
## Storage
|
|
||||||
|
|
||||||
| Volume | Holds |
|
|
||||||
| --- | --- |
|
|
||||||
| `db-data` | PostgreSQL data, under `18/docker` |
|
|
||||||
| `gitea-data` | Repositories, Gitea config and state |
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
services:
|
|
||||||
db:
|
|
||||||
image: postgres:18-alpine
|
|
||||||
restart: unless-stopped
|
|
||||||
environment:
|
|
||||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-gitea}
|
|
||||||
POSTGRES_USER: gitea
|
|
||||||
POSTGRES_DB: gitea
|
|
||||||
volumes:
|
|
||||||
- db-data:/var/lib/postgresql
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD-SHELL", "pg_isready -U gitea -d gitea"]
|
|
||||||
interval: 5s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 10
|
|
||||||
|
|
||||||
gitea:
|
|
||||||
image: gitea/gitea:28
|
|
||||||
restart: unless-stopped
|
|
||||||
depends_on:
|
|
||||||
db:
|
|
||||||
condition: service_healthy
|
|
||||||
environment:
|
|
||||||
GITEA__database__PASSWD: ${POSTGRES_PASSWORD:-gitea}
|
|
||||||
GITEA__database__DB_TYPE: postgres
|
|
||||||
GITEA__database__HOST: db:5432
|
|
||||||
GITEA__database__NAME: gitea
|
|
||||||
GITEA__database__USER: gitea
|
|
||||||
GITEA__server__ROOT_URL: ${GITEA_ROOT_URL:?required}
|
|
||||||
GITEA__server__DISABLE_SSH: "true"
|
|
||||||
GITEA__service__DISABLE_REGISTRATION: "true"
|
|
||||||
GITEA__git_0X2E_timeout__MIGRATE: ${GITEA_CLONE_TIMEOUT:-3600}
|
|
||||||
GITEA__git_0X2E_timeout__MIRROR: ${GITEA_CLONE_TIMEOUT:-3600}
|
|
||||||
volumes:
|
|
||||||
- gitea-data:/data
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD", "curl", "-fsS", "http://localhost:3000/api/healthz"]
|
|
||||||
interval: 30s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 5
|
|
||||||
start_period: 30s
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
db-data:
|
|
||||||
gitea-data:
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
# Copy to .env and fill in. Never commit .env.
|
|
||||||
#
|
|
||||||
# cp .env.example .env
|
|
||||||
|
|
||||||
# Bearer token for the gateway API and dashboard. Required.
|
|
||||||
# Generate one with: openssl rand -hex 16
|
|
||||||
GOCLAW_GATEWAY_TOKEN=
|
|
||||||
|
|
||||||
# AES-256-GCM key encrypting the LLM provider keys stored in PostgreSQL.
|
|
||||||
# Generate one with: openssl rand -hex 32
|
|
||||||
GOCLAW_ENCRYPTION_KEY=
|
|
||||||
|
|
||||||
# PostgreSQL credentials. Used by the database and by the DSN GoClaw connects
|
|
||||||
# with, so both sides move together.
|
|
||||||
POSTGRES_USER=goclaw
|
|
||||||
POSTGRES_PASSWORD=goclaw
|
|
||||||
POSTGRES_DB=goclaw
|
|
||||||
|
|
||||||
# Apply schema migrations and data hooks on startup.
|
|
||||||
GOCLAW_AUTO_UPGRADE=true
|
|
||||||
|
|
||||||
# MiniMax keys, for the MiniMax LLM provider and text-to-speech. Uncomment
|
|
||||||
# here and in compose.yml to use MiniMax.
|
|
||||||
# GOCLAW_MINIMAX_API_KEY=
|
|
||||||
# GOCLAW_TTS_MINIMAX_API_KEY=
|
|
||||||
# GOCLAW_TTS_MINIMAX_GROUP_ID=
|
|
||||||
|
|
||||||
# Set to 1 to log full LLM request and response bodies.
|
|
||||||
# GOCLAW_TRACE_VERBOSE=0
|
|
||||||
@@ -1,121 +0,0 @@
|
|||||||
# goclaw
|
|
||||||
|
|
||||||
[GoClaw](https://docs.goclaw.sh) is a multi-tenant AI agent gateway in Go: LLM
|
|
||||||
providers, messaging channels (Telegram, Discord, Slack, Zalo, Feishu,
|
|
||||||
WhatsApp), agent teams, and a knowledge vault, behind one binary with a
|
|
||||||
built-in web dashboard.
|
|
||||||
|
|
||||||
Two containers: `goclaw`, and `db` — pgvector, which holds tenants,
|
|
||||||
sessions, encrypted provider keys and the semantic memory.
|
|
||||||
|
|
||||||
## Setup
|
|
||||||
|
|
||||||
1. Generate the two secrets:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
openssl rand -hex 16 # GOCLAW_GATEWAY_TOKEN
|
|
||||||
openssl rand -hex 32 # GOCLAW_ENCRYPTION_KEY
|
|
||||||
```
|
|
||||||
|
|
||||||
2. Map the domain to port `18790` and deploy. Migrations run on start.
|
|
||||||
3. Open the domain and use the setup wizard to add an LLM provider key.
|
|
||||||
|
|
||||||
Health check: `GET /health`, which the `goclaw` container also polls as its
|
|
||||||
compose healthcheck so Coolify can report it healthy. The first check waits 60
|
|
||||||
seconds, to leave time for migrations on start.
|
|
||||||
|
|
||||||
## Authentication
|
|
||||||
|
|
||||||
`GOCLAW_GATEWAY_TOKEN` is the bearer token for the API and the dashboard, and
|
|
||||||
the only thing between the domain and a gateway whose agents run tools. It
|
|
||||||
fails fast if unset rather than defaulting to blank: upstream's own compose
|
|
||||||
allows an empty token for loopback-only development, which behind a public
|
|
||||||
domain would publish the gateway.
|
|
||||||
|
|
||||||
`GOCLAW_ENCRYPTION_KEY` encrypts the provider API keys at rest in PostgreSQL
|
|
||||||
(AES-256-GCM). It is required for the same reason, and it must not change once
|
|
||||||
keys are stored — every one of them becomes unreadable.
|
|
||||||
|
|
||||||
Both are the two values upstream's `prepare-env.sh` generates; the script
|
|
||||||
itself is not used here.
|
|
||||||
|
|
||||||
## Environment
|
|
||||||
|
|
||||||
| Variable | Default | Purpose |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| `GOCLAW_GATEWAY_TOKEN` | — | Bearer token for the API and dashboard |
|
|
||||||
| `GOCLAW_ENCRYPTION_KEY` | — | AES-256-GCM key for stored provider keys |
|
|
||||||
| `POSTGRES_USER` / `POSTGRES_PASSWORD` / `POSTGRES_DB` | `goclaw` | Database credentials, read by both containers |
|
|
||||||
| `GOCLAW_AUTO_UPGRADE` | `true` | Apply schema migrations and data hooks on startup |
|
|
||||||
| `GOCLAW_MINIMAX_API_KEY` | optional | MiniMax LLM provider key |
|
|
||||||
| `GOCLAW_TTS_MINIMAX_API_KEY` / `GOCLAW_TTS_MINIMAX_GROUP_ID` | optional | MiniMax text-to-speech credentials |
|
|
||||||
| `GOCLAW_TRACE_VERBOSE` | optional | Set to `1` to log full LLM request and response bodies |
|
|
||||||
|
|
||||||
`GOCLAW_AUTO_UPGRADE` defaults to `true`, the value upstream recommends for
|
|
||||||
Docker. The image entrypoint already runs `goclaw upgrade` before `serve`, but
|
|
||||||
it only logs a warning when that fails. With the variable set, `serve` applies
|
|
||||||
any pending migrations itself rather than stopping on a schema check.
|
|
||||||
|
|
||||||
The MiniMax variables are the one case where a provider key comes from the
|
|
||||||
environment rather than the dashboard: GoClaw reads them directly for the
|
|
||||||
MiniMax provider and its text-to-speech.
|
|
||||||
|
|
||||||
`GOCLAW_HOST`, `GOCLAW_PORT`, `GOCLAW_CONFIG` and `GOCLAW_SKILLS_DIR` are set
|
|
||||||
in `compose.yml` rather than here — they are properties of this layout, not of
|
|
||||||
whoever deploys it. The two paths override image defaults that point outside
|
|
||||||
any volume (`/app/config.json`, `/app/skills`), which would drop the
|
|
||||||
configuration and every installed skill on redeploy.
|
|
||||||
|
|
||||||
Other provider API keys are not variables: they are entered in the dashboard
|
|
||||||
and stored encrypted in PostgreSQL.
|
|
||||||
|
|
||||||
## Storage
|
|
||||||
|
|
||||||
| Volume | Mount | Holds |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| `goclaw-data` | `/app/data` | `config.json`, skills, Claude CLI credentials, runtime packages |
|
|
||||||
| `goclaw-workspace` | `/app/workspace` | Files the agents work on |
|
|
||||||
| `db-data` | `/var/lib/postgresql` | The database |
|
|
||||||
|
|
||||||
Upstream's PostgreSQL overlay adds a third `goclaw-skills` volume at
|
|
||||||
`/app/skills`; it is not here, because `GOCLAW_SKILLS_DIR` points at
|
|
||||||
`/app/data/skills` and the volume would mount empty over a path nothing reads.
|
|
||||||
Skills persist in `goclaw-data` instead.
|
|
||||||
|
|
||||||
`db-data` mounts `/var/lib/postgresql`, not `/var/lib/postgresql/data`.
|
|
||||||
PostgreSQL 18 images moved `PGDATA` down a level into a version directory, so
|
|
||||||
the parent is now the mount point.
|
|
||||||
|
|
||||||
The image's own user owns `/app` — `goclaw`, uid 1000, created with `/app` as
|
|
||||||
its home. `/app/.claude` is a symlink into `/app/data`, which is how CLI
|
|
||||||
credentials survive a recreate.
|
|
||||||
|
|
||||||
## Networking
|
|
||||||
|
|
||||||
Listens on `18790`.
|
|
||||||
|
|
||||||
`extra_hosts` maps `host.docker.internal` to the host gateway, so an agent can
|
|
||||||
reach a service running on the host itself.
|
|
||||||
|
|
||||||
## Image
|
|
||||||
|
|
||||||
`ghcr.io/nextlevelbuilder/goclaw:full`, the variant with every runtime and
|
|
||||||
skill dependency pre-installed — Python, Node, pandoc, poppler, the GitHub CLI.
|
|
||||||
The plain tag ships Python only and installs the rest on demand into
|
|
||||||
`/app/data/.runtime`, which means the first use of a skill waits on a package
|
|
||||||
install and a wiped data volume repeats it. Paying for the size once is the
|
|
||||||
better trade here.
|
|
||||||
|
|
||||||
The tag is moving, tracking the newest full build. The variant tags are only
|
|
||||||
published as `full` and `<major>.<minor>-full` — there is no `<major>-full` —
|
|
||||||
so `full` is the moving tag to follow.
|
|
||||||
|
|
||||||
## Hardening
|
|
||||||
|
|
||||||
`cap_drop: ALL` with `SETUID`/`SETGID`/`CHOWN` added back, `no-new-privileges`,
|
|
||||||
a `noexec` tmpfs on `/tmp`, and memory, CPU and pid limits — all as upstream
|
|
||||||
ships them. The three capabilities are what the entrypoint needs to install
|
|
||||||
persisted packages as root and then drop to the `goclaw` user via `su-exec`.
|
|
||||||
|
|
||||||
This is a service whose whole purpose is running model-chosen tools, so the
|
|
||||||
limits are worth keeping.
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
services:
|
|
||||||
goclaw:
|
|
||||||
image: ghcr.io/nextlevelbuilder/goclaw:full
|
|
||||||
restart: unless-stopped
|
|
||||||
environment:
|
|
||||||
- GOCLAW_GATEWAY_TOKEN=${GOCLAW_GATEWAY_TOKEN:?required}
|
|
||||||
- GOCLAW_ENCRYPTION_KEY=${GOCLAW_ENCRYPTION_KEY:?required}
|
|
||||||
- GOCLAW_POSTGRES_DSN=postgres://${POSTGRES_USER:-goclaw}:${POSTGRES_PASSWORD:-goclaw}@db:5432/${POSTGRES_DB:-goclaw}?sslmode=disable
|
|
||||||
- GOCLAW_HOST=0.0.0.0
|
|
||||||
- GOCLAW_PORT=18790
|
|
||||||
- GOCLAW_CONFIG=/app/data/config.json
|
|
||||||
- GOCLAW_SKILLS_DIR=/app/data/skills
|
|
||||||
- GOCLAW_AUTO_UPGRADE=${GOCLAW_AUTO_UPGRADE:-true}
|
|
||||||
# - GOCLAW_MINIMAX_API_KEY=${GOCLAW_MINIMAX_API_KEY:-}
|
|
||||||
# - GOCLAW_TTS_MINIMAX_API_KEY=${GOCLAW_TTS_MINIMAX_API_KEY:-}
|
|
||||||
# - GOCLAW_TTS_MINIMAX_GROUP_ID=${GOCLAW_TTS_MINIMAX_GROUP_ID:-}
|
|
||||||
# - GOCLAW_TRACE_VERBOSE=${GOCLAW_TRACE_VERBOSE:-0}
|
|
||||||
volumes:
|
|
||||||
- goclaw-data:/app/data
|
|
||||||
- goclaw-workspace:/app/workspace
|
|
||||||
depends_on:
|
|
||||||
db:
|
|
||||||
condition: service_healthy
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD-SHELL", "wget -qO- http://localhost:18790/health || exit 1"]
|
|
||||||
interval: 30s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 3
|
|
||||||
start_period: 60s
|
|
||||||
extra_hosts:
|
|
||||||
- host.docker.internal:host-gateway
|
|
||||||
init: true
|
|
||||||
security_opt:
|
|
||||||
- no-new-privileges:true
|
|
||||||
cap_drop:
|
|
||||||
- ALL
|
|
||||||
cap_add:
|
|
||||||
- SETUID
|
|
||||||
- SETGID
|
|
||||||
- CHOWN
|
|
||||||
tmpfs:
|
|
||||||
- /tmp:rw,noexec,nosuid,size=256m
|
|
||||||
deploy:
|
|
||||||
resources:
|
|
||||||
limits:
|
|
||||||
memory: 1G
|
|
||||||
cpus: '2.0'
|
|
||||||
pids: 200
|
|
||||||
|
|
||||||
# PostgreSQL 18 with pgvector, for agent memory and the knowledge vault.
|
|
||||||
db:
|
|
||||||
image: pgvector/pgvector:pg18
|
|
||||||
restart: unless-stopped
|
|
||||||
environment:
|
|
||||||
POSTGRES_USER: ${POSTGRES_USER:-goclaw}
|
|
||||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-goclaw}
|
|
||||||
POSTGRES_DB: ${POSTGRES_DB:-goclaw}
|
|
||||||
volumes:
|
|
||||||
- db-data:/var/lib/postgresql
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-goclaw} -d ${POSTGRES_DB:-goclaw}"]
|
|
||||||
interval: 5s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 10
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
goclaw-data:
|
|
||||||
goclaw-workspace:
|
|
||||||
db-data:
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
# Copy to .env and fill in. Never commit .env.
|
|
||||||
#
|
|
||||||
# cp .env.example .env
|
|
||||||
|
|
||||||
# Full public URL of the dashboard, e.g. https://hermes.example.com.
|
|
||||||
HERMES_DASHBOARD_PUBLIC_URL=https://hermes.example.com
|
|
||||||
|
|
||||||
# Dashboard login. Generate the password with: openssl rand -base64 24
|
|
||||||
HERMES_DASHBOARD_USERNAME=hermes
|
|
||||||
HERMES_DASHBOARD_PASSWORD=
|
|
||||||
# Signs dashboard sessions; keep it stable. Generate with: openssl rand -hex 32
|
|
||||||
HERMES_DASHBOARD_SECRET=
|
|
||||||
|
|
||||||
# Provider keys normally live in /opt/data/.env, set from the dashboard.
|
|
||||||
# Uncomment here and in compose.yml to pass one from the environment instead.
|
|
||||||
# OPENROUTER_API_KEY=
|
|
||||||
# ANTHROPIC_API_KEY=
|
|
||||||
# OPENAI_API_KEY=
|
|
||||||
# GOOGLE_API_KEY=
|
|
||||||
@@ -1,120 +0,0 @@
|
|||||||
# hermes
|
|
||||||
|
|
||||||
[Hermes Agent](https://github.com/NousResearch/hermes-agent): an autonomous AI
|
|
||||||
agent with persistent memory, scheduling and chat-platform gateways, from Nous
|
|
||||||
Research's official image, with its built-in web dashboard.
|
|
||||||
|
|
||||||
Laid out after upstream's
|
|
||||||
[Docker guide](https://github.com/NousResearch/hermes-agent/blob/main/website/docs/user-guide/docker.md):
|
|
||||||
one container, one data volume at `/opt/data`, `gateway run` as the command,
|
|
||||||
and `HERMES_DASHBOARD=1` so the image's s6 supervisor also runs the dashboard
|
|
||||||
on port `9119`.
|
|
||||||
|
|
||||||
## Setup
|
|
||||||
|
|
||||||
1. Set `HERMES_DASHBOARD_PUBLIC_URL`, `HERMES_DASHBOARD_PASSWORD` and
|
|
||||||
`HERMES_DASHBOARD_SECRET`.
|
|
||||||
2. Map the domain to port `9119` and deploy.
|
|
||||||
3. Open the domain, log in with `HERMES_DASHBOARD_USERNAME` /
|
|
||||||
`HERMES_DASHBOARD_PASSWORD`, and add a model provider under Config.
|
|
||||||
|
|
||||||
## Environment
|
|
||||||
|
|
||||||
| Variable | Default | Purpose |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| `HERMES_DASHBOARD_PUBLIC_URL` | — | Full public URL, e.g. `https://hermes.example.com` |
|
|
||||||
| `HERMES_DASHBOARD_USERNAME` / `HERMES_DASHBOARD_PASSWORD` | `hermes` / — | Dashboard login |
|
|
||||||
| `HERMES_DASHBOARD_SECRET` | — | Signs dashboard sessions |
|
|
||||||
| `OPENROUTER_API_KEY`, `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `GOOGLE_API_KEY` | optional | Provider keys passed from the environment |
|
|
||||||
|
|
||||||
The dashboard refuses to start on a non-loopback bind without an auth
|
|
||||||
provider, so the password is required. The username/password provider is the
|
|
||||||
one that needs no outside identity service; upstream describes it as meant
|
|
||||||
for trusted networks and recommends OAuth (Nous Portal) or self-hosted OIDC
|
|
||||||
for a public domain.
|
|
||||||
|
|
||||||
`HERMES_DASHBOARD_PUBLIC_URL` adds the domain to the dashboard's Host and
|
|
||||||
WebSocket Origin guard, which rejects requests for any other host.
|
|
||||||
`HERMES_DASHBOARD_SECRET` keeps sessions valid across restarts; without it
|
|
||||||
each restart signs with a new random key. It must decode to at least 16
|
|
||||||
bytes (base64, hex, or raw text), or the password provider does not load and
|
|
||||||
the dashboard refuses to start; `openssl rand -hex 32` is long enough.
|
|
||||||
|
|
||||||
Hermes loads `/opt/data/.env` over the process environment, so a key set in
|
|
||||||
both takes the file's value. Provider keys are commented out because upstream
|
|
||||||
keeps them in that file, written from the dashboard.
|
|
||||||
|
|
||||||
## Chat platforms
|
|
||||||
|
|
||||||
Telegram and the other gateway platforms are configured in the dashboard
|
|
||||||
under Messaging, which writes `TELEGRAM_BOT_TOKEN`, `TELEGRAM_ALLOWED_USERS`,
|
|
||||||
`TELEGRAM_GROUP_ALLOWED_CHATS` and their equivalents to `/opt/data/.env` and
|
|
||||||
restarts the gateway. They are not passed from Coolify, so that file is the
|
|
||||||
one place they are set.
|
|
||||||
|
|
||||||
The Coolify environment cannot be the source for them. The gateway runs with
|
|
||||||
`multiplex_profiles` on by default, and in that mode its allow-lists read
|
|
||||||
only the profile's `.env`, never the container environment: with
|
|
||||||
`TELEGRAM_ALLOWED_USERS` set only in Coolify, the bot connects but blocks
|
|
||||||
every user. Turning multiplexing off (`GATEWAY_MULTIPLEX_PROFILES=false`)
|
|
||||||
would make the environment count again, but the file still wins for any key
|
|
||||||
it holds, and the dashboard's Messaging setup writes those keys there, so a
|
|
||||||
Coolify value would be silently overridden the first time the dashboard is
|
|
||||||
used.
|
|
||||||
|
|
||||||
To edit a value by hand, change it in `/opt/data/.env` from a shell in the
|
|
||||||
container, as the `hermes` user, then run
|
|
||||||
`/opt/hermes/.venv/bin/hermes gateway restart`.
|
|
||||||
|
|
||||||
In groups the bot answers only when tagged: `telegram.require_mention` is
|
|
||||||
`true` in `/opt/data/config.yaml`, set with
|
|
||||||
`/opt/hermes/.venv/bin/hermes config set telegram.require_mention true`
|
|
||||||
followed by a gateway restart. A group message then needs an `@botusername`
|
|
||||||
mention, a `/command@botusername`, or a reply to one of the bot's messages;
|
|
||||||
DMs are not gated. Upstream defaults it to `false`, answering every group
|
|
||||||
message the bot can see. It lives in the volume for the same reason as the
|
|
||||||
allow-lists: the gateway reads it from the profile, not the container
|
|
||||||
environment.
|
|
||||||
|
|
||||||
The bot still reads the untagged group messages as context:
|
|
||||||
`telegram.observe_unmentioned_group_messages` is `true`, set the same way.
|
|
||||||
Hermes stores each skipped message, labelled with its sender, in one session
|
|
||||||
shared by the chat or topic, and hands those messages to the model as a
|
|
||||||
context-only block when it is next addressed. This needs Telegram privacy mode
|
|
||||||
off for the bot (`can_read_all_group_messages` in `getMe`), and it covers only
|
|
||||||
the chats in `TELEGRAM_GROUP_ALLOWED_CHATS`. In those chats, everyone shares
|
|
||||||
one conversation with the bot, overriding `group_sessions_per_user`; every
|
|
||||||
message lands in `state.db`, and each reply carries the observed backlog to the
|
|
||||||
model provider.
|
|
||||||
|
|
||||||
## Scheduled jobs
|
|
||||||
|
|
||||||
Cron deliveries carry only the job's own output: `cron.wrap_response` is
|
|
||||||
`false` in `/opt/data/config.yaml`, set with
|
|
||||||
`/opt/hermes/.venv/bin/hermes config set cron.wrap_response false`. Upstream
|
|
||||||
defaults it to `true`, which wraps every delivery in a
|
|
||||||
`Cronjob Response: <name>` header with the job ID and a "To stop or manage
|
|
||||||
this job" footer. It applies to every job; with it off, a job is managed by
|
|
||||||
asking the bot by name.
|
|
||||||
|
|
||||||
## Storage
|
|
||||||
|
|
||||||
| Volume | Mount | Holds |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| `hermes-data` | `/opt/data` | `HERMES_HOME`: config, `.env`, sessions, memory, skills, logs, and the agent's working files |
|
|
||||||
|
|
||||||
`/opt/data` is the image's own home for all mutable state: it is the `hermes`
|
|
||||||
user's home directory, the image declares it a volume, and the dashboard's
|
|
||||||
start script uses it directly. The agent's file tools may only write under
|
|
||||||
it. The `hermes` user keeps the image's default uid, `10000`, and the image fixes
|
|
||||||
the volume's ownership for that user at start.
|
|
||||||
|
|
||||||
## Resources
|
|
||||||
|
|
||||||
The 4 GB memory and 2 CPU limits are upstream's own compose example.
|
|
||||||
|
|
||||||
## Image
|
|
||||||
|
|
||||||
`nousresearch/hermes-agent:latest` moves only on stable releases, roughly
|
|
||||||
weekly; upstream publishes no major tag. The agent's code lives in the image,
|
|
||||||
not a volume, so a new release takes effect on the next pull and recreate.
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
services:
|
|
||||||
hermes:
|
|
||||||
image: nousresearch/hermes-agent:latest
|
|
||||||
restart: unless-stopped
|
|
||||||
command: gateway run
|
|
||||||
environment:
|
|
||||||
- HERMES_DASHBOARD=1
|
|
||||||
- HERMES_DASHBOARD_PUBLIC_URL=${HERMES_DASHBOARD_PUBLIC_URL:?required}
|
|
||||||
- HERMES_DASHBOARD_BASIC_AUTH_USERNAME=${HERMES_DASHBOARD_USERNAME:-hermes}
|
|
||||||
- HERMES_DASHBOARD_BASIC_AUTH_PASSWORD=${HERMES_DASHBOARD_PASSWORD:?required}
|
|
||||||
- HERMES_DASHBOARD_BASIC_AUTH_SECRET=${HERMES_DASHBOARD_SECRET:?required}
|
|
||||||
# - OPENROUTER_API_KEY=${OPENROUTER_API_KEY:-}
|
|
||||||
# - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-}
|
|
||||||
# - OPENAI_API_KEY=${OPENAI_API_KEY:-}
|
|
||||||
# - GOOGLE_API_KEY=${GOOGLE_API_KEY:-}
|
|
||||||
volumes:
|
|
||||||
- hermes-data:/opt/data
|
|
||||||
deploy:
|
|
||||||
resources:
|
|
||||||
limits:
|
|
||||||
memory: 4G
|
|
||||||
cpus: "2.0"
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
hermes-data:
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
# Copy to .env and fill in. Never commit .env.
|
|
||||||
#
|
|
||||||
# cp .env.example .env
|
|
||||||
|
|
||||||
# Admin key for the proxy API and the virtual keys it issues. Must start with sk-.
|
|
||||||
# Generate one with: echo "sk-$(openssl rand -hex 24)"
|
|
||||||
LITELLM_MASTER_KEY=
|
|
||||||
|
|
||||||
# Admin UI login.
|
|
||||||
UI_USERNAME=admin
|
|
||||||
UI_PASSWORD=
|
|
||||||
|
|
||||||
# PostgreSQL credentials, used by the database and by LiteLLM's DATABASE_URL.
|
|
||||||
POSTGRES_USER=litellm
|
|
||||||
POSTGRES_PASSWORD=
|
|
||||||
POSTGRES_DB=litellm
|
|
||||||
|
|
||||||
# OpenRouter backs openrouter-free, the target of every model_group_alias in
|
|
||||||
# config.yaml. Gemini backs gemini-embedding-001 and the Gemini chat models.
|
|
||||||
OPENROUTER_API_KEY=
|
|
||||||
GEMINI_API_KEY=
|
|
||||||
|
|
||||||
# Keep models added in the admin UI in PostgreSQL.
|
|
||||||
STORE_MODEL_IN_DB=True
|
|
||||||
|
|
||||||
LITELLM_MODE=PRODUCTION
|
|
||||||
LITELLM_LOG=ERROR
|
|
||||||
|
|
||||||
# Provider keys for the other models in config.yaml. Uncomment here and in
|
|
||||||
# compose.yml to enable a provider; an unset key disables only its models.
|
|
||||||
# TOKENROUTER_API_KEY=
|
|
||||||
# ORCAROUTER_API_KEY=
|
|
||||||
# KIMI_API_KEY=
|
|
||||||
# MINIMAX_API_KEY=
|
|
||||||
# KILO_API_KEY=
|
|
||||||
# OPENCODE_API_KEY=
|
|
||||||
# NVIDIA_NIM_API_KEY=
|
|
||||||
# OLLAMA_API_KEY=
|
|
||||||
# Cloudflare Workers AI, for the commented-out Cloudflare model in config.yaml.
|
|
||||||
# CLOUDFLARE_API_KEY=
|
|
||||||
# CLOUDFLARE_ACCOUNT_ID=
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
FROM ghcr.io/berriai/litellm-database:main-stable
|
|
||||||
|
|
||||||
# Proxy config: model list, routing and cache settings.
|
|
||||||
COPY config.yaml /app/config.yaml
|
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
# litellm
|
|
||||||
|
|
||||||
[LiteLLM](https://docs.litellm.ai) proxy: one OpenAI-compatible endpoint in
|
|
||||||
front of many LLM providers, with virtual keys, spend tracking and an admin UI
|
|
||||||
at `/ui`.
|
|
||||||
|
|
||||||
Three containers: `litellm`, `db` (PostgreSQL) for keys, teams, budgets and spend
|
|
||||||
logs, and `cache` (Redis) for the response cache.
|
|
||||||
|
|
||||||
## Setup
|
|
||||||
|
|
||||||
1. Set `LITELLM_MASTER_KEY`, `UI_PASSWORD`, `POSTGRES_PASSWORD`,
|
|
||||||
`OPENROUTER_API_KEY` and `GEMINI_API_KEY`. Uncomment any other provider key
|
|
||||||
whose models you use, in both `compose.yml` and the environment.
|
|
||||||
2. Map the domain to port `4000` and deploy. Prisma migrations run on start.
|
|
||||||
3. Open `/ui` and log in with `UI_USERNAME` / `UI_PASSWORD`.
|
|
||||||
|
|
||||||
Health check: `GET /health/liveliness`, also the compose healthcheck.
|
|
||||||
|
|
||||||
## Models
|
|
||||||
|
|
||||||
`config.yaml` holds the model list, the `model_group_alias` routing and the
|
|
||||||
proxy settings. The `Dockerfile` copies it to `/app/config.yaml`, so editing it
|
|
||||||
rebuilds and redeploys the service.
|
|
||||||
|
|
||||||
It is built into the image rather than bind-mounted because Coolify does not
|
|
||||||
place repository files beside the compose file it runs: a `./config.yaml` bind
|
|
||||||
mount finds nothing there, and Docker mounts an empty directory in its place. Every provider key in it is an `os.environ/` reference,
|
|
||||||
so the file carries no secrets.
|
|
||||||
|
|
||||||
Models can also be added in the admin UI. `STORE_MODEL_IN_DB` keeps those in
|
|
||||||
PostgreSQL, so they survive a redeploy without touching the file.
|
|
||||||
|
|
||||||
## Environment
|
|
||||||
|
|
||||||
| Variable | Default | Purpose |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| `LITELLM_MASTER_KEY` | — | Admin API key; must start with `sk-` |
|
|
||||||
| `UI_USERNAME` / `UI_PASSWORD` | `admin` / — | Admin UI login |
|
|
||||||
| `POSTGRES_USER` / `POSTGRES_PASSWORD` / `POSTGRES_DB` | `litellm` / — / `litellm` | Database credentials, read by both containers |
|
|
||||||
| `OPENROUTER_API_KEY` | empty | OpenRouter models, including `openrouter-free`, the alias target |
|
|
||||||
| `GEMINI_API_KEY` | empty | Gemini chat models and `gemini-embedding-001` |
|
|
||||||
| `STORE_MODEL_IN_DB` | `True` | Keep UI-added models in PostgreSQL |
|
|
||||||
| `LITELLM_MODE` | `PRODUCTION` | Skips loading a `.env` inside the container |
|
|
||||||
| `LITELLM_LOG` | `ERROR` | Log level |
|
|
||||||
| `TOKENROUTER_API_KEY`, `ORCAROUTER_API_KEY`, `KIMI_API_KEY`, `MINIMAX_API_KEY`, `KILO_API_KEY`, `OPENCODE_API_KEY`, `NVIDIA_NIM_API_KEY`, `OLLAMA_API_KEY` | optional | Keys for the other providers in `config.yaml` |
|
|
||||||
| `CLOUDFLARE_API_KEY` / `CLOUDFLARE_ACCOUNT_ID` | optional | For the commented-out Cloudflare model |
|
|
||||||
|
|
||||||
`LITELLM_MASTER_KEY` and `UI_PASSWORD` fail fast if unset: the master key is
|
|
||||||
the only thing between the domain and every provider key behind the proxy.
|
|
||||||
|
|
||||||
`REDIS_HOST` and `REDIS_PORT` are fixed in `compose.yml`, as properties of this
|
|
||||||
layout. Redis runs without a password; it is reachable only on the stack's
|
|
||||||
own network.
|
|
||||||
|
|
||||||
OpenRouter and Gemini stay active because the rest of this setup leans on
|
|
||||||
them: every `model_group_alias` routes to `openrouter-free`, and
|
|
||||||
`gemini-embedding-001` serves embeddings. The other providers are optional. An
|
|
||||||
unset key disables only the models that reference it — LiteLLM starts and
|
|
||||||
passes its health check with every provider key unset — so a provider is
|
|
||||||
enabled by uncommenting its line, without touching `config.yaml`.
|
|
||||||
|
|
||||||
## Storage
|
|
||||||
|
|
||||||
| Volume | Mount | Holds |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| `db-data` | `/var/lib/postgresql/data` | The database |
|
|
||||||
| `cache-data` | `/data` | Redis append-only file |
|
|
||||||
|
|
||||||
## Images
|
|
||||||
|
|
||||||
The `Dockerfile` builds on `ghcr.io/berriai/litellm-database:main-stable`, the variant with the Prisma
|
|
||||||
client for PostgreSQL built in. `main-stable` is upstream's moving stable tag.
|
|
||||||
|
|
||||||
`postgres:16-alpine` and `redis:7-alpine` track their major versions. Moving
|
|
||||||
PostgreSQL to a new major needs a dump and restore; the data directory does
|
|
||||||
not carry across majors.
|
|
||||||
|
|
||||||
## Resources
|
|
||||||
|
|
||||||
`litellm` is capped at 4 GB of memory. Gunicorn runs two workers and recycles
|
|
||||||
each after 1000 requests, which bounds the slow memory growth of long-lived
|
|
||||||
LiteLLM workers.
|
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
services:
|
|
||||||
litellm:
|
|
||||||
build: .
|
|
||||||
restart: unless-stopped
|
|
||||||
environment:
|
|
||||||
- LITELLM_MASTER_KEY=${LITELLM_MASTER_KEY:?required}
|
|
||||||
- UI_USERNAME=${UI_USERNAME:-admin}
|
|
||||||
- UI_PASSWORD=${UI_PASSWORD:?required}
|
|
||||||
- DATABASE_URL=postgresql://${POSTGRES_USER:-litellm}:${POSTGRES_PASSWORD:?required}@db:5432/${POSTGRES_DB:-litellm}
|
|
||||||
- REDIS_HOST=cache
|
|
||||||
- REDIS_PORT=6379
|
|
||||||
- OPENROUTER_API_KEY=${OPENROUTER_API_KEY:-}
|
|
||||||
- GEMINI_API_KEY=${GEMINI_API_KEY:-}
|
|
||||||
- STORE_MODEL_IN_DB=${STORE_MODEL_IN_DB:-True}
|
|
||||||
- LITELLM_MODE=${LITELLM_MODE:-PRODUCTION}
|
|
||||||
- LITELLM_LOG=${LITELLM_LOG:-ERROR}
|
|
||||||
# - TOKENROUTER_API_KEY=${TOKENROUTER_API_KEY:-}
|
|
||||||
# - ORCAROUTER_API_KEY=${ORCAROUTER_API_KEY:-}
|
|
||||||
# - KIMI_API_KEY=${KIMI_API_KEY:-}
|
|
||||||
# - MINIMAX_API_KEY=${MINIMAX_API_KEY:-}
|
|
||||||
# - KILO_API_KEY=${KILO_API_KEY:-}
|
|
||||||
# - OPENCODE_API_KEY=${OPENCODE_API_KEY:-}
|
|
||||||
# - NVIDIA_NIM_API_KEY=${NVIDIA_NIM_API_KEY:-}
|
|
||||||
# - OLLAMA_API_KEY=${OLLAMA_API_KEY:-}
|
|
||||||
# - CLOUDFLARE_API_KEY=${CLOUDFLARE_API_KEY:-}
|
|
||||||
# - CLOUDFLARE_ACCOUNT_ID=${CLOUDFLARE_ACCOUNT_ID:-}
|
|
||||||
command:
|
|
||||||
- --config
|
|
||||||
- /app/config.yaml
|
|
||||||
- --port
|
|
||||||
- "4000"
|
|
||||||
- --run_gunicorn
|
|
||||||
- --num_workers
|
|
||||||
- "2"
|
|
||||||
- --max_requests_before_restart
|
|
||||||
- "1000"
|
|
||||||
depends_on:
|
|
||||||
db:
|
|
||||||
condition: service_healthy
|
|
||||||
cache:
|
|
||||||
condition: service_healthy
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD", "python", "-c", "import urllib.request;urllib.request.urlopen('http://127.0.0.1:4000/health/liveliness',timeout=5)"]
|
|
||||||
interval: 30s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 3
|
|
||||||
start_period: 30s
|
|
||||||
deploy:
|
|
||||||
resources:
|
|
||||||
limits:
|
|
||||||
memory: 4G
|
|
||||||
|
|
||||||
# PostgreSQL for keys, teams, budgets and spend logs.
|
|
||||||
db:
|
|
||||||
image: postgres:16-alpine
|
|
||||||
restart: unless-stopped
|
|
||||||
environment:
|
|
||||||
- POSTGRES_USER=${POSTGRES_USER:-litellm}
|
|
||||||
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD:?required}
|
|
||||||
- POSTGRES_DB=${POSTGRES_DB:-litellm}
|
|
||||||
volumes:
|
|
||||||
- db-data:/var/lib/postgresql/data
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD-SHELL", "pg_isready -h localhost -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
|
|
||||||
interval: 5s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 3
|
|
||||||
|
|
||||||
# Redis for the response cache and router state.
|
|
||||||
cache:
|
|
||||||
image: redis:7-alpine
|
|
||||||
restart: unless-stopped
|
|
||||||
command: redis-server --appendonly yes
|
|
||||||
volumes:
|
|
||||||
- cache-data:/data
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD", "redis-cli", "ping"]
|
|
||||||
interval: 5s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 3
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
db-data:
|
|
||||||
cache-data:
|
|
||||||
@@ -1,156 +0,0 @@
|
|||||||
model_list:
|
|
||||||
- model_name: kimi-k3-free
|
|
||||||
litellm_params:
|
|
||||||
model: openai/moonshotai/kimi-k3-free
|
|
||||||
api_base: https://api.tokenrouter.com/v1
|
|
||||||
api_key: os.environ/TOKENROUTER_API_KEY
|
|
||||||
|
|
||||||
- model_name: glm-5.3-flash-free
|
|
||||||
litellm_params:
|
|
||||||
model: openai/z-ai/glm-5.3-flash-free
|
|
||||||
api_base: https://api.orcarouter.ai/v1
|
|
||||||
api_key: os.environ/ORCAROUTER_API_KEY
|
|
||||||
|
|
||||||
- model_name: kimi-for-coding
|
|
||||||
litellm_params:
|
|
||||||
model: anthropic/kimi-for-coding
|
|
||||||
api_base: https://api.kimi.com/coding
|
|
||||||
api_key: os.environ/KIMI_API_KEY
|
|
||||||
|
|
||||||
- model_name: MiniMax-M3
|
|
||||||
litellm_params:
|
|
||||||
model: minimax/MiniMax-M3
|
|
||||||
api_key: os.environ/MINIMAX_API_KEY
|
|
||||||
|
|
||||||
- model_name: gemma-4-31b-it:free
|
|
||||||
litellm_params:
|
|
||||||
model: openrouter/google/gemma-4-31b-it:free
|
|
||||||
api_key: os.environ/OPENROUTER_API_KEY
|
|
||||||
|
|
||||||
- model_name: openrouter-free
|
|
||||||
litellm_params:
|
|
||||||
model: openrouter/openrouter/free
|
|
||||||
api_key: os.environ/OPENROUTER_API_KEY
|
|
||||||
|
|
||||||
- model_name: orcarouter-free
|
|
||||||
litellm_params:
|
|
||||||
model: openai/orcarouter/free
|
|
||||||
api_base: https://api.orcarouter.ai/v1
|
|
||||||
api_key: os.environ/ORCAROUTER_API_KEY
|
|
||||||
|
|
||||||
- model_name: kilo-free
|
|
||||||
litellm_params:
|
|
||||||
model: openai/kilo-auto/free
|
|
||||||
api_base: https://api.kilo.ai/api/gateway
|
|
||||||
api_key: os.environ/KILO_API_KEY
|
|
||||||
|
|
||||||
- model_name: deepseek-v4-flash-free
|
|
||||||
litellm_params:
|
|
||||||
model: openai/deepseek-v4-flash-free
|
|
||||||
api_base: https://opencode.ai/zen/v1
|
|
||||||
api_key: os.environ/OPENCODE_API_KEY
|
|
||||||
|
|
||||||
- model_name: glm4.7
|
|
||||||
litellm_params:
|
|
||||||
model: nvidia_nim/z-ai/glm4.7
|
|
||||||
api_key: os.environ/NVIDIA_NIM_API_KEY
|
|
||||||
|
|
||||||
- model_name: gemini-embedding-001
|
|
||||||
litellm_params:
|
|
||||||
model: gemini/gemini-embedding-001
|
|
||||||
api_key: os.environ/GEMINI_API_KEY
|
|
||||||
dimensions: 1536
|
|
||||||
|
|
||||||
- model_name: gemini-2.5-flash
|
|
||||||
litellm_params:
|
|
||||||
model: gemini/gemini-2.5-flash
|
|
||||||
api_key: os.environ/GEMINI_API_KEY
|
|
||||||
|
|
||||||
- model_name: gemma-4-31b-it
|
|
||||||
litellm_params:
|
|
||||||
model: gemini/gemma-4-31b-it
|
|
||||||
api_key: os.environ/GEMINI_API_KEY
|
|
||||||
|
|
||||||
- model_name: gemma-4-26b-a4b-it
|
|
||||||
litellm_params:
|
|
||||||
model: gemini/gemma-4-26b-a4b-it
|
|
||||||
api_key: os.environ/GEMINI_API_KEY
|
|
||||||
|
|
||||||
# - model_name: gemini/*
|
|
||||||
# litellm_params:
|
|
||||||
# model: gemini/*
|
|
||||||
# api_key: os.environ/GEMINI_API_KEY
|
|
||||||
|
|
||||||
- model_name: gemma4:31b-cloud
|
|
||||||
litellm_params:
|
|
||||||
model: ollama/gemma4:31b-cloud
|
|
||||||
api_base: https://ollama.com
|
|
||||||
api_key: os.environ/OLLAMA_API_KEY
|
|
||||||
|
|
||||||
# - model_name: glm-4.7-flash
|
|
||||||
# litellm_params:
|
|
||||||
# model: cloudflare/@cf/zai-org/glm-4.7-flash
|
|
||||||
# api_key: os.environ/CLOUDFLARE_API_KEY
|
|
||||||
# account_id: os.environ/CLOUDFLARE_ACCOUNT_ID
|
|
||||||
|
|
||||||
# - model_name: deepseek-v4-pro
|
|
||||||
# litellm_params:
|
|
||||||
# model: openai/deepseek-v4-pro
|
|
||||||
# api_base: https://opencode.ai/zen/go/v1
|
|
||||||
# api_key: os.environ/OPENCODE_API_KEY
|
|
||||||
|
|
||||||
# - model_name: qwen3.7-max
|
|
||||||
# litellm_params:
|
|
||||||
# model: anthropic/qwen3.7-max
|
|
||||||
# api_base: https://opencode.ai/zen/go/v1
|
|
||||||
# api_key: os.environ/OPENCODE_API_KEY
|
|
||||||
|
|
||||||
general_settings:
|
|
||||||
proxy_batch_write_at: 60
|
|
||||||
store_model_in_db: true
|
|
||||||
store_prompts_in_spend_logs: false
|
|
||||||
|
|
||||||
router_settings:
|
|
||||||
model_group_alias:
|
|
||||||
claude: openrouter-free
|
|
||||||
sonnet: openrouter-free
|
|
||||||
opus: openrouter-free
|
|
||||||
haiku: openrouter-free
|
|
||||||
opencode: openrouter-free
|
|
||||||
goclaw: openrouter-free
|
|
||||||
openclaw: openrouter-free
|
|
||||||
hermes: openrouter-free
|
|
||||||
redis_host: os.environ/REDIS_HOST
|
|
||||||
redis_port: os.environ/REDIS_PORT
|
|
||||||
redis_password: os.environ/REDIS_PASSWORD
|
|
||||||
enable_pre_call_check: true
|
|
||||||
|
|
||||||
litellm_settings:
|
|
||||||
master_key: os.environ/LITELLM_MASTER_KEY
|
|
||||||
check_provider_endpoint: true
|
|
||||||
set_verbose: false
|
|
||||||
json_logs: true
|
|
||||||
log_raw_request_response: false
|
|
||||||
# turn_off_message_logging: false
|
|
||||||
# redact_user_api_key_info: false
|
|
||||||
service_callback: ["prometheus_system"]
|
|
||||||
drop_params: true
|
|
||||||
# max_budget: 100
|
|
||||||
# budget_duration: 30d
|
|
||||||
num_retries: 1
|
|
||||||
request_timeout: 120
|
|
||||||
telemetry: false
|
|
||||||
cache: true
|
|
||||||
cache_params:
|
|
||||||
type: redis
|
|
||||||
host: os.environ/REDIS_HOST
|
|
||||||
port: os.environ/REDIS_PORT
|
|
||||||
password: os.environ/REDIS_PASSWORD
|
|
||||||
namespace: "litellm_cache"
|
|
||||||
ttl: 600
|
|
||||||
success_callback:
|
|
||||||
# - "langfuse"
|
|
||||||
- "prometheus"
|
|
||||||
failure_callback:
|
|
||||||
# - "langfuse"
|
|
||||||
- "prometheus"
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
# Copy to .env and fill in. Never commit .env.
|
|
||||||
#
|
|
||||||
# cp .env.example .env
|
|
||||||
|
|
||||||
# Admin account, created on first start only.
|
|
||||||
# Generate a password with: openssl rand -base64 24
|
|
||||||
NEXTCLOUD_ADMIN_USER=admin
|
|
||||||
NEXTCLOUD_ADMIN_PASSWORD=
|
|
||||||
|
|
||||||
# Public hostname, without scheme. The only trusted domain and the CLI URL.
|
|
||||||
NEXTCLOUD_DOMAIN=nextcloud.example.com
|
|
||||||
|
|
||||||
# Space-separated proxy addresses or CIDRs allowed to set X-Forwarded-* headers.
|
|
||||||
TRUSTED_PROXIES=10.0.0.0/8 172.16.0.0/12 192.168.0.0/16
|
|
||||||
|
|
||||||
# PostgreSQL credentials, used by the database and by the first-start install.
|
|
||||||
POSTGRES_DB=nextcloud
|
|
||||||
POSTGRES_USER=nextcloud
|
|
||||||
POSTGRES_PASSWORD=
|
|
||||||
|
|
||||||
# PHP limits for the web server.
|
|
||||||
PHP_MEMORY_LIMIT=1G
|
|
||||||
PHP_UPLOAD_LIMIT=16G
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
# nextcloud
|
|
||||||
|
|
||||||
[Nextcloud](https://github.com/nextcloud/docker) Server: file sync and share,
|
|
||||||
calendar, contacts and office apps, with web, desktop and mobile clients.
|
|
||||||
|
|
||||||
## Containers
|
|
||||||
|
|
||||||
| Service | Image | Internal port | Role |
|
|
||||||
| --- | --- | --- | --- |
|
|
||||||
| `nextcloud` | `nextcloud:35-apache` | 80 | Web app |
|
|
||||||
| `cron` | `nextcloud:35-apache` | none | Background jobs, via `/cron.sh` |
|
|
||||||
| `db` | `postgres:18-alpine` | 5432 | Metadata, users and shares |
|
|
||||||
| `cache` | `redis:8-alpine` | 6379 | File locking and the distributed cache |
|
|
||||||
|
|
||||||
In Coolify, give `nextcloud` the domain from `NEXTCLOUD_DOMAIN` on port `80`.
|
|
||||||
`nextcloud` and `cron` wait for `db` and `cache` to pass their health checks.
|
|
||||||
|
|
||||||
## Setup
|
|
||||||
|
|
||||||
1. Set `NEXTCLOUD_DOMAIN`, `NEXTCLOUD_ADMIN_PASSWORD` and `POSTGRES_PASSWORD`.
|
|
||||||
2. Map the domain to port `80` and deploy. The first start installs Nextcloud
|
|
||||||
and creates the admin account.
|
|
||||||
3. Log in with `NEXTCLOUD_ADMIN_USER` / `NEXTCLOUD_ADMIN_PASSWORD`. The first
|
|
||||||
run of `cron` switches background jobs to **Cron** on its own.
|
|
||||||
|
|
||||||
Office editing is an app from the app store plus an external document server,
|
|
||||||
both set up in the admin UI, not in this compose file.
|
|
||||||
|
|
||||||
## Variables
|
|
||||||
|
|
||||||
| Variable | Default | Purpose |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| `NEXTCLOUD_ADMIN_USER` / `NEXTCLOUD_ADMIN_PASSWORD` | `admin` / — | Admin account |
|
|
||||||
| `NEXTCLOUD_DOMAIN` | — | Public hostname, without scheme |
|
|
||||||
| `TRUSTED_PROXIES` | private IPv4 ranges | Proxies allowed to set `X-Forwarded-*` |
|
|
||||||
| `POSTGRES_DB` / `POSTGRES_USER` / `POSTGRES_PASSWORD` | `nextcloud` / `nextcloud` / — | Database credentials, read by `db` and by the install |
|
|
||||||
| `PHP_MEMORY_LIMIT` | `1G` | PHP `memory_limit` |
|
|
||||||
| `PHP_UPLOAD_LIMIT` | `16G` | PHP `upload_max_filesize` and `post_max_size` |
|
|
||||||
|
|
||||||
`NEXTCLOUD_DOMAIN` fills `NEXTCLOUD_TRUSTED_DOMAINS` and `OVERWRITECLIURL`.
|
|
||||||
Nextcloud rejects requests for any host not on the trusted list.
|
|
||||||
|
|
||||||
The admin and database variables are read only by the first-start install.
|
|
||||||
The installer creates its own database role and writes it to `config.php`, so
|
|
||||||
changing them later changes nothing; use the web UI or `occ`.
|
|
||||||
|
|
||||||
## Storage
|
|
||||||
|
|
||||||
| Volume | Mount | Holds |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| `nextcloud-html` | `/var/www/html` | Nextcloud code, apps, `config.php` and user files |
|
|
||||||
| `db-data` | `/var/lib/postgresql` | The database |
|
|
||||||
| `cache-data` | `/data` | Redis locks and cache |
|
|
||||||
|
|
||||||
`cron` mounts the same volume at the same path as `nextcloud`; the two must
|
|
||||||
match for background jobs to see the same installation.
|
|
||||||
|
|
||||||
The Redis contents are rebuilt after a restart, but the `redis` image declares
|
|
||||||
`/data` a volume, so without a named one Docker creates an anonymous volume on
|
|
||||||
every recreate.
|
|
||||||
|
|
||||||
## Choices
|
|
||||||
|
|
||||||
- **Behind a TLS-terminating proxy.** The proxy speaks HTTP to port 80, so
|
|
||||||
`OVERWRITEPROTOCOL=https` keeps generated links and redirects on HTTPS.
|
|
||||||
`APACHE_DISABLE_REWRITE_IP=1` with `TRUSTED_PROXIES` makes Nextcloud read
|
|
||||||
the client IP and host from `X-Forwarded-*`, which brute-force protection
|
|
||||||
and the admin overview's proxy check rely on. No port is published, so only
|
|
||||||
containers on the app's networks can reach port 80; the private ranges cover
|
|
||||||
whatever subnet the proxy network gets.
|
|
||||||
- **Larger PHP limits.** The image defaults both to `512M`. The web UI and
|
|
||||||
the desktop and mobile clients upload in chunks, but WebDAV clients that send
|
|
||||||
a file in one request hit `PHP_UPLOAD_LIMIT`, and preview generation for big
|
|
||||||
images needs more memory.
|
|
||||||
- **`cron` container.** Nextcloud's recommended background job mode is system
|
|
||||||
cron; the image ships `/cron.sh`, which runs `cron.php` as `www-data` every
|
|
||||||
five minutes.
|
|
||||||
- **`nextcloud:35-apache`.** The major tag takes point releases; Nextcloud
|
|
||||||
can only upgrade one major at a time, so a new major is a deliberate change.
|
|
||||||
The `apache` variant serves PHP itself, with no separate web server.
|
|
||||||
- **`postgres:18-alpine`** is the version the Nextcloud 35 admin manual
|
|
||||||
recommends. Postgres 18 images keep data under `/var/lib/postgresql/18/`,
|
|
||||||
so the volume mounts at `/var/lib/postgresql`. A new Postgres major cannot
|
|
||||||
read an older data directory without a dump and restore.
|
|
||||||
- **`redis:8-alpine`** matches the Redis line upstream's example uses.
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
services:
|
|
||||||
nextcloud:
|
|
||||||
image: nextcloud:35-apache
|
|
||||||
restart: unless-stopped
|
|
||||||
environment:
|
|
||||||
- NEXTCLOUD_ADMIN_USER=${NEXTCLOUD_ADMIN_USER:-admin}
|
|
||||||
- NEXTCLOUD_ADMIN_PASSWORD=${NEXTCLOUD_ADMIN_PASSWORD:?required}
|
|
||||||
- NEXTCLOUD_TRUSTED_DOMAINS=${NEXTCLOUD_DOMAIN:?required}
|
|
||||||
- OVERWRITEPROTOCOL=https
|
|
||||||
- OVERWRITECLIURL=https://${NEXTCLOUD_DOMAIN:?required}
|
|
||||||
- TRUSTED_PROXIES=${TRUSTED_PROXIES:-10.0.0.0/8 172.16.0.0/12 192.168.0.0/16}
|
|
||||||
- APACHE_DISABLE_REWRITE_IP=1
|
|
||||||
- POSTGRES_HOST=db
|
|
||||||
- POSTGRES_DB=${POSTGRES_DB:-nextcloud}
|
|
||||||
- POSTGRES_USER=${POSTGRES_USER:-nextcloud}
|
|
||||||
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD:?required}
|
|
||||||
- REDIS_HOST=cache
|
|
||||||
- PHP_MEMORY_LIMIT=${PHP_MEMORY_LIMIT:-1G}
|
|
||||||
- PHP_UPLOAD_LIMIT=${PHP_UPLOAD_LIMIT:-16G}
|
|
||||||
volumes:
|
|
||||||
- nextcloud-html:/var/www/html
|
|
||||||
depends_on:
|
|
||||||
db:
|
|
||||||
condition: service_healthy
|
|
||||||
cache:
|
|
||||||
condition: service_healthy
|
|
||||||
|
|
||||||
# Runs Nextcloud background jobs every five minutes.
|
|
||||||
cron:
|
|
||||||
image: nextcloud:35-apache
|
|
||||||
restart: unless-stopped
|
|
||||||
entrypoint: /cron.sh
|
|
||||||
volumes:
|
|
||||||
- nextcloud-html:/var/www/html
|
|
||||||
depends_on:
|
|
||||||
db:
|
|
||||||
condition: service_healthy
|
|
||||||
cache:
|
|
||||||
condition: service_healthy
|
|
||||||
|
|
||||||
# PostgreSQL for file metadata, users and shares.
|
|
||||||
db:
|
|
||||||
image: postgres:18-alpine
|
|
||||||
restart: unless-stopped
|
|
||||||
environment:
|
|
||||||
- POSTGRES_DB=${POSTGRES_DB:-nextcloud}
|
|
||||||
- POSTGRES_USER=${POSTGRES_USER:-nextcloud}
|
|
||||||
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD:?required}
|
|
||||||
volumes:
|
|
||||||
- db-data:/var/lib/postgresql
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD-SHELL", "pg_isready -h localhost -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
|
|
||||||
interval: 5s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 10
|
|
||||||
|
|
||||||
# Redis for file locking and the distributed cache.
|
|
||||||
cache:
|
|
||||||
image: redis:8-alpine
|
|
||||||
restart: unless-stopped
|
|
||||||
volumes:
|
|
||||||
- cache-data:/data
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD", "redis-cli", "ping"]
|
|
||||||
interval: 5s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 10
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
nextcloud-html:
|
|
||||||
db-data:
|
|
||||||
cache-data:
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
# Copy to .env and fill in. Never commit .env.
|
|
||||||
#
|
|
||||||
# cp .env.example .env
|
|
||||||
|
|
||||||
# Signs login sessions and encrypts stored secrets. Keep it stable.
|
|
||||||
# Generate one with: openssl rand -hex 32
|
|
||||||
WEBUI_SECRET_KEY=
|
|
||||||
|
|
||||||
# Let every user see every model, without per-model access grants.
|
|
||||||
BYPASS_MODEL_ACCESS_CONTROL=true
|
|
||||||
|
|
||||||
# Connections seeded on first start; afterwards they are managed in the admin UI.
|
|
||||||
# OPENAI_API_BASE_URL=
|
|
||||||
# OPENAI_API_KEY=
|
|
||||||
# OLLAMA_BASE_URL=
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
# open-webui
|
|
||||||
|
|
||||||
[Open WebUI](https://docs.openwebui.com): a chat UI for OpenAI-compatible and
|
|
||||||
Ollama model providers, with users, chat history, documents and RAG.
|
|
||||||
|
|
||||||
One container, serving on port `8080`. It stores everything in SQLite and a
|
|
||||||
local vector database under `/app/backend/data`.
|
|
||||||
|
|
||||||
## Setup
|
|
||||||
|
|
||||||
1. Set `WEBUI_SECRET_KEY`.
|
|
||||||
2. Map the domain to port `8080` and deploy.
|
|
||||||
3. Open the domain. The first account to sign up becomes the admin.
|
|
||||||
4. Add model connections in **Admin Settings → Connections**.
|
|
||||||
|
|
||||||
Health check: `GET /`, also the compose healthcheck.
|
|
||||||
|
|
||||||
## Environment
|
|
||||||
|
|
||||||
| Variable | Default | Purpose |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| `WEBUI_SECRET_KEY` | — | Signs login tokens and encrypts stored secrets |
|
|
||||||
| `BYPASS_MODEL_ACCESS_CONTROL` | `true` | Every user sees every model |
|
|
||||||
| `OPENAI_API_BASE_URL` / `OPENAI_API_KEY` | optional | OpenAI-compatible connection seeded on first start |
|
|
||||||
| `OLLAMA_BASE_URL` | optional | Ollama connection seeded on first start |
|
|
||||||
|
|
||||||
`WEBUI_SECRET_KEY` is required. Without it, `start.sh` generates a key into
|
|
||||||
`/app/backend/.webui_secret_key`, outside the data volume, so every
|
|
||||||
recreated container gets a new key: everyone is logged out, and anything
|
|
||||||
encrypted with the old key can no longer be read.
|
|
||||||
|
|
||||||
`BYPASS_MODEL_ACCESS_CONTROL` is on because this is a single-person instance:
|
|
||||||
models do not need to be shared with users one by one.
|
|
||||||
|
|
||||||
The connection variables are optional because Open WebUI keeps its
|
|
||||||
connections in the database. They seed it only on first start, so once
|
|
||||||
connections exist the admin UI is where they change.
|
|
||||||
|
|
||||||
## Storage
|
|
||||||
|
|
||||||
| Volume | Mount | Holds |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| `open-webui-data` | `/app/backend/data` | `webui.db`, uploads, the vector database, and model cache |
|
|
||||||
|
|
||||||
## Image
|
|
||||||
|
|
||||||
`ghcr.io/open-webui/open-webui:latest` is the latest release. Upstream
|
|
||||||
publishes no major tag; `main` is built from every commit on the development
|
|
||||||
branch, so `latest` is the stable moving tag.
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
services:
|
|
||||||
open-webui:
|
|
||||||
image: ghcr.io/open-webui/open-webui:latest
|
|
||||||
restart: unless-stopped
|
|
||||||
environment:
|
|
||||||
- WEBUI_SECRET_KEY=${WEBUI_SECRET_KEY:?required}
|
|
||||||
- BYPASS_MODEL_ACCESS_CONTROL=${BYPASS_MODEL_ACCESS_CONTROL:-true}
|
|
||||||
# - OPENAI_API_BASE_URL=${OPENAI_API_BASE_URL:-}
|
|
||||||
# - OPENAI_API_KEY=${OPENAI_API_KEY:-}
|
|
||||||
# - OLLAMA_BASE_URL=${OLLAMA_BASE_URL:-}
|
|
||||||
volumes:
|
|
||||||
- open-webui-data:/app/backend/data
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD", "curl", "-f", "http://127.0.0.1:8080"]
|
|
||||||
interval: 5s
|
|
||||||
timeout: 30s
|
|
||||||
retries: 10
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
open-webui-data:
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
# Copy to .env and fill in. Never commit .env.
|
|
||||||
#
|
|
||||||
# cp .env.example .env
|
|
||||||
|
|
||||||
# Gateway token: the Control UI login and the key for every API and WebSocket call.
|
|
||||||
# Generate one with: openssl rand -hex 32
|
|
||||||
OPENCLAW_GATEWAY_TOKEN=
|
|
||||||
|
|
||||||
# Public origin of the Control UI, scheme included, no trailing slash.
|
|
||||||
OPENCLAW_PUBLIC_ORIGIN=https://openclaw.example.com
|
|
||||||
|
|
||||||
# Address range the reverse proxy connects from, in CIDR form.
|
|
||||||
OPENCLAW_TRUSTED_PROXIES=10.0.0.0/16
|
|
||||||
|
|
||||||
# At least one model provider; OpenRouter is the active one.
|
|
||||||
OPENROUTER_API_KEY=
|
|
||||||
|
|
||||||
TZ=Asia/Ho_Chi_Minh
|
|
||||||
|
|
||||||
# Other providers. Uncomment here and in compose.yml to enable one.
|
|
||||||
# ANTHROPIC_API_KEY=
|
|
||||||
# OPENAI_API_KEY=
|
|
||||||
# GEMINI_API_KEY=
|
|
||||||
# XAI_API_KEY=
|
|
||||||
# GROQ_API_KEY=
|
|
||||||
# MISTRAL_API_KEY=
|
|
||||||
# CEREBRAS_API_KEY=
|
|
||||||
# VENICE_API_KEY=
|
|
||||||
# MOONSHOT_API_KEY=
|
|
||||||
# KIMI_API_KEY=
|
|
||||||
# MINIMAX_API_KEY=
|
|
||||||
# ZAI_API_KEY=
|
|
||||||
# AI_GATEWAY_API_KEY=
|
|
||||||
# OPENCODE_API_KEY=
|
|
||||||
# SYNTHETIC_API_KEY=
|
|
||||||
# COPILOT_GITHUB_TOKEN=
|
|
||||||
# XIAOMI_API_KEY=
|
|
||||||
# OLLAMA_API_KEY=
|
|
||||||
# Speech to text.
|
|
||||||
# DEEPGRAM_API_KEY=
|
|
||||||
# AWS Bedrock.
|
|
||||||
# AWS_ACCESS_KEY_ID=
|
|
||||||
# AWS_SECRET_ACCESS_KEY=
|
|
||||||
# AWS_SESSION_TOKEN=
|
|
||||||
# AWS_REGION=us-east-1
|
|
||||||
|
|
||||||
# Chat channels.
|
|
||||||
# TELEGRAM_BOT_TOKEN=
|
|
||||||
# DISCORD_BOT_TOKEN=
|
|
||||||
# SLACK_BOT_TOKEN=
|
|
||||||
# SLACK_APP_TOKEN=
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
FROM ghcr.io/openclaw/openclaw:latest-browser
|
|
||||||
|
|
||||||
# Gateway config seeded into the state volume on first start.
|
|
||||||
COPY --chown=node:node openclaw.json /home/node/.openclaw/openclaw.json
|
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
# openclaw
|
|
||||||
|
|
||||||
[OpenClaw](https://docs.openclaw.ai): a personal AI agent gateway with a web
|
|
||||||
Control UI, chat-channel bots and browser automation. Runs the project's
|
|
||||||
official image, in its `-browser` variant with Chromium built in.
|
|
||||||
|
|
||||||
Laid out after upstream's [Docker guide](https://docs.openclaw.ai/install/docker)
|
|
||||||
and its `docker-compose.yml`: one gateway container with the same command,
|
|
||||||
path variables, three volumes, healthcheck, `init`, dropped capabilities and
|
|
||||||
`host.docker.internal` mapping, serving the gateway and Control UI on port
|
|
||||||
`18789`. Upstream's `openclaw-cli` companion service is left out; the CLI runs
|
|
||||||
inside the gateway container with `docker exec`.
|
|
||||||
|
|
||||||
## Setup
|
|
||||||
|
|
||||||
1. Set `OPENCLAW_GATEWAY_TOKEN`, `OPENCLAW_PUBLIC_ORIGIN` and
|
|
||||||
`OPENROUTER_API_KEY`.
|
|
||||||
2. Map the domain to port `18789` and deploy.
|
|
||||||
3. Open the domain and connect with the gateway token. Each new browser is
|
|
||||||
then approved once from inside the container:
|
|
||||||
`node openclaw.mjs devices approve`.
|
|
||||||
4. Pick a default model in the Control UI. The image's default is an OpenAI
|
|
||||||
model, which needs `OPENAI_API_KEY`.
|
|
||||||
|
|
||||||
Health check: `docker-healthcheck.js` from the image, which calls `/healthz`,
|
|
||||||
on upstream's compose timings.
|
|
||||||
|
|
||||||
## Environment
|
|
||||||
|
|
||||||
| Variable | Default | Purpose |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| `OPENCLAW_GATEWAY_TOKEN` | — | Control UI login and API/WebSocket key |
|
|
||||||
| `OPENCLAW_PUBLIC_ORIGIN` | — | Public origin, e.g. `https://openclaw.example.com` |
|
|
||||||
| `OPENCLAW_TRUSTED_PROXIES` | `10.0.0.0/16` | Range the reverse proxy connects from |
|
|
||||||
| `OPENROUTER_API_KEY` | empty | Model provider |
|
|
||||||
| `TZ` | `Asia/Ho_Chi_Minh` | Timezone for logs and schedules |
|
|
||||||
| Other provider keys, `AWS_*` | optional | Additional model providers |
|
|
||||||
| `TELEGRAM_BOT_TOKEN`, `DISCORD_BOT_TOKEN`, `SLACK_*` | optional | Chat channels |
|
|
||||||
|
|
||||||
`OPENCLAW_GATEWAY_TOKEN` is required: the gateway binds to all interfaces, and
|
|
||||||
the token is what keeps the Control UI and API closed. Provider and channel
|
|
||||||
keys are read from the environment, so the provider set is changed by
|
|
||||||
uncommenting lines.
|
|
||||||
|
|
||||||
`NODE_COMPILE_CACHE` and `OPENCLAW_NO_RESPAWN=1` are the values `doctor`
|
|
||||||
recommends on ARM and small Linux hosts: a compile cache that speeds up
|
|
||||||
repeated CLI runs, and gateway restarts that stay inside the process instead
|
|
||||||
of handing off to a supervisor, since Docker's restart policy already
|
|
||||||
supervises the container.
|
|
||||||
|
|
||||||
`OPENCLAW_TRUSTED_PROXIES` must cover the address Coolify's Traefik connects
|
|
||||||
from. Traefik joins each app's network with an address from the Docker
|
|
||||||
address pool, which on this host is `10.0.0.0/16` in `/24` slices; the gateway
|
|
||||||
answers every proxied request from an untrusted address with 403
|
|
||||||
`proxy_attribution_required`.
|
|
||||||
|
|
||||||
## Config
|
|
||||||
|
|
||||||
The rest of OpenClaw's settings live in `openclaw.json` on the state volume
|
|
||||||
and are edited in the Control UI or with `node openclaw.mjs config set`.
|
|
||||||
|
|
||||||
Upstream's guide finishes setup with one-off commands run before the gateway
|
|
||||||
first starts: `onboard`, then `config set` for `gateway.mode` and
|
|
||||||
`gateway.bind`. Coolify has no step that runs a command against an app's
|
|
||||||
volume before it starts, so the `Dockerfile` does the equivalent: it copies
|
|
||||||
`openclaw.json` into the image's state directory, and Docker seeds an empty
|
|
||||||
named volume from it on first start. The file holds those two settings, the
|
|
||||||
port, and the two reverse-proxy settings from upstream's gateway reference,
|
|
||||||
`publicOrigin` and `trustedProxies`, as `${VAR}` references that OpenClaw
|
|
||||||
resolves from the environment at load. The image's start-up `doctor --fix`
|
|
||||||
keeps the references when it rewrites the file. Without `gateway.mode` a
|
|
||||||
fresh volume crash-loops.
|
|
||||||
|
|
||||||
The seed applies only to a fresh volume. Editing `openclaw.json` in the
|
|
||||||
repository later changes nothing for an existing deployment; change the live
|
|
||||||
config instead.
|
|
||||||
|
|
||||||
## Storage
|
|
||||||
|
|
||||||
| Volume | Mount | Holds |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| `openclaw-state` | `/home/node/.openclaw` | `openclaw.json`, sessions, credentials, agent state |
|
|
||||||
| `openclaw-workspace` | `/home/node/.openclaw/workspace` | Files the agent works on |
|
|
||||||
| `openclaw-secrets` | `/home/node/.config/openclaw` | Auth-profile secrets |
|
|
||||||
|
|
||||||
The three mounts are upstream's own. `/home/node` as a whole is not mounted:
|
|
||||||
the bundled Chromium lives in `/home/node/.cache`, and a volume there would
|
|
||||||
freeze it at the first image's version. The image runs as the non-root `node`
|
|
||||||
user.
|
|
||||||
|
|
||||||
## Image
|
|
||||||
|
|
||||||
`ghcr.io/openclaw/openclaw:latest-browser` is the latest stable release with
|
|
||||||
Playwright Chromium built in, published by the project's release automation.
|
|
||||||
Upstream publishes no major tag.
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
services:
|
|
||||||
openclaw:
|
|
||||||
build: .
|
|
||||||
restart: unless-stopped
|
|
||||||
init: true
|
|
||||||
command: ["node", "dist/index.js", "gateway", "--bind", "lan", "--port", "18789"]
|
|
||||||
environment:
|
|
||||||
- OPENCLAW_GATEWAY_TOKEN=${OPENCLAW_GATEWAY_TOKEN:?required}
|
|
||||||
- OPENCLAW_PUBLIC_ORIGIN=${OPENCLAW_PUBLIC_ORIGIN:?required}
|
|
||||||
- OPENCLAW_TRUSTED_PROXIES=${OPENCLAW_TRUSTED_PROXIES:-10.0.0.0/16}
|
|
||||||
- HOME=/home/node
|
|
||||||
- OPENCLAW_HOME=/home/node
|
|
||||||
- OPENCLAW_STATE_DIR=/home/node/.openclaw
|
|
||||||
- OPENCLAW_CONFIG_PATH=/home/node/.openclaw/openclaw.json
|
|
||||||
- OPENCLAW_CONFIG_DIR=/home/node/.openclaw
|
|
||||||
- OPENCLAW_WORKSPACE_DIR=/home/node/.openclaw/workspace
|
|
||||||
- OPENCLAW_GATEWAY_PORT=18789
|
|
||||||
- TERM=xterm-256color
|
|
||||||
- NODE_COMPILE_CACHE=/var/tmp/openclaw-compile-cache
|
|
||||||
- OPENCLAW_NO_RESPAWN=1
|
|
||||||
- OPENROUTER_API_KEY=${OPENROUTER_API_KEY:-}
|
|
||||||
- TZ=${TZ:-Asia/Ho_Chi_Minh}
|
|
||||||
# - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-}
|
|
||||||
# - OPENAI_API_KEY=${OPENAI_API_KEY:-}
|
|
||||||
# - GEMINI_API_KEY=${GEMINI_API_KEY:-}
|
|
||||||
# - XAI_API_KEY=${XAI_API_KEY:-}
|
|
||||||
# - GROQ_API_KEY=${GROQ_API_KEY:-}
|
|
||||||
# - MISTRAL_API_KEY=${MISTRAL_API_KEY:-}
|
|
||||||
# - CEREBRAS_API_KEY=${CEREBRAS_API_KEY:-}
|
|
||||||
# - VENICE_API_KEY=${VENICE_API_KEY:-}
|
|
||||||
# - MOONSHOT_API_KEY=${MOONSHOT_API_KEY:-}
|
|
||||||
# - KIMI_API_KEY=${KIMI_API_KEY:-}
|
|
||||||
# - MINIMAX_API_KEY=${MINIMAX_API_KEY:-}
|
|
||||||
# - ZAI_API_KEY=${ZAI_API_KEY:-}
|
|
||||||
# - AI_GATEWAY_API_KEY=${AI_GATEWAY_API_KEY:-}
|
|
||||||
# - OPENCODE_API_KEY=${OPENCODE_API_KEY:-}
|
|
||||||
# - SYNTHETIC_API_KEY=${SYNTHETIC_API_KEY:-}
|
|
||||||
# - COPILOT_GITHUB_TOKEN=${COPILOT_GITHUB_TOKEN:-}
|
|
||||||
# - XIAOMI_API_KEY=${XIAOMI_API_KEY:-}
|
|
||||||
# - OLLAMA_API_KEY=${OLLAMA_API_KEY:-}
|
|
||||||
# - DEEPGRAM_API_KEY=${DEEPGRAM_API_KEY:-}
|
|
||||||
# - AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID:-}
|
|
||||||
# - AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY:-}
|
|
||||||
# - AWS_SESSION_TOKEN=${AWS_SESSION_TOKEN:-}
|
|
||||||
# - AWS_REGION=${AWS_REGION:-us-east-1}
|
|
||||||
# - TELEGRAM_BOT_TOKEN=${TELEGRAM_BOT_TOKEN:-}
|
|
||||||
# - DISCORD_BOT_TOKEN=${DISCORD_BOT_TOKEN:-}
|
|
||||||
# - SLACK_BOT_TOKEN=${SLACK_BOT_TOKEN:-}
|
|
||||||
# - SLACK_APP_TOKEN=${SLACK_APP_TOKEN:-}
|
|
||||||
volumes:
|
|
||||||
- openclaw-state:/home/node/.openclaw
|
|
||||||
- openclaw-workspace:/home/node/.openclaw/workspace
|
|
||||||
- openclaw-secrets:/home/node/.config/openclaw
|
|
||||||
cap_drop:
|
|
||||||
- NET_RAW
|
|
||||||
- NET_ADMIN
|
|
||||||
security_opt:
|
|
||||||
- no-new-privileges:true
|
|
||||||
extra_hosts:
|
|
||||||
- host.docker.internal:host-gateway
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD", "node", "dist/docker-healthcheck.js"]
|
|
||||||
interval: 30s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 5
|
|
||||||
start_period: 20s
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
openclaw-state:
|
|
||||||
openclaw-workspace:
|
|
||||||
openclaw-secrets:
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
{
|
|
||||||
"gateway": {
|
|
||||||
"mode": "local",
|
|
||||||
"bind": "lan",
|
|
||||||
"port": 18789,
|
|
||||||
"publicOrigin": "${OPENCLAW_PUBLIC_ORIGIN}",
|
|
||||||
"trustedProxies": ["${OPENCLAW_TRUSTED_PROXIES}"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
# Copy to .env and fill in. Never commit .env.
|
|
||||||
#
|
|
||||||
# cp .env.example .env
|
|
||||||
|
|
||||||
# Public hostname, without scheme.
|
|
||||||
OC_DOMAIN=opencloud.example.com
|
|
||||||
|
|
||||||
# Password of the built-in admin account, set on first start only.
|
|
||||||
# Generate one with: openssl rand -base64 24
|
|
||||||
INITIAL_ADMIN_PASSWORD=
|
|
||||||
|
|
||||||
# OC_LOG_LEVEL=info
|
|
||||||
# PROXY_ENABLE_BASIC_AUTH=false
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
# opencloud
|
|
||||||
|
|
||||||
[OpenCloud](https://docs.opencloud.eu/) Server: file sync and share with web,
|
|
||||||
desktop and mobile clients, spaces and WebDAV.
|
|
||||||
|
|
||||||
One container, `opencloud`, running every OpenCloud service in one process,
|
|
||||||
including the built-in identity provider, user directory and NATS. Files and
|
|
||||||
metadata live on disk, so there is no database container.
|
|
||||||
|
|
||||||
## Setup
|
|
||||||
|
|
||||||
1. Set `OC_DOMAIN` and `INITIAL_ADMIN_PASSWORD`.
|
|
||||||
2. Map the domain to port `9200` over HTTPS and deploy.
|
|
||||||
3. Log in as `admin` with `INITIAL_ADMIN_PASSWORD`.
|
|
||||||
|
|
||||||
Health check: `GET http://127.0.0.1:9205/healthz`, the proxy's debug endpoint,
|
|
||||||
also the compose healthcheck. It listens on loopback only.
|
|
||||||
|
|
||||||
## Environment
|
|
||||||
|
|
||||||
| Variable | Default | Purpose |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| `OC_DOMAIN` | — | Public hostname, without scheme; becomes `OC_URL` |
|
|
||||||
| `INITIAL_ADMIN_PASSWORD` | — | Password of the `admin` account |
|
|
||||||
| `OC_LOG_LEVEL` | `info` | Optional. Log level |
|
|
||||||
| `PROXY_ENABLE_BASIC_AUTH` | `false` | Optional. Basic auth for WebDAV clients without OpenID Connect |
|
|
||||||
|
|
||||||
`OC_URL` must be the exact HTTPS URL the browser uses: the built-in identity
|
|
||||||
provider uses it as its issuer and redirect target.
|
|
||||||
|
|
||||||
`INITIAL_ADMIN_PASSWORD` is read only on first start, when the admin account
|
|
||||||
is created. Changing it later does not change the password; use the web UI.
|
|
||||||
|
|
||||||
`PROXY_TLS=false`, `PROXY_HTTP_ADDR` and `OC_INSECURE=false` are fixed in
|
|
||||||
`compose.yml`. The platform proxy terminates TLS and forwards plain HTTP to
|
|
||||||
port `9200`, and the certificate it serves is a real one, so certificate
|
|
||||||
checks stay on.
|
|
||||||
|
|
||||||
## Storage
|
|
||||||
|
|
||||||
| Volume | Mount | Holds |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| `opencloud-config` | `/etc/opencloud` | `opencloud.yaml`, with the generated secrets |
|
|
||||||
| `opencloud-data` | `/var/lib/opencloud` | User files, spaces, the user directory and search index |
|
|
||||||
|
|
||||||
The two volumes belong together. `opencloud.yaml` holds the secrets the data
|
|
||||||
was written with; a data volume restored without its config volume, or the
|
|
||||||
reverse, does not start cleanly. Back them up as a pair.
|
|
||||||
|
|
||||||
The image creates both directories owned by uid `1000`, the user it runs as,
|
|
||||||
so fresh named volumes are writable without an init step.
|
|
||||||
|
|
||||||
## Choices
|
|
||||||
|
|
||||||
- **`opencloud init || true; opencloud server`.** `init` writes
|
|
||||||
`opencloud.yaml` with random secrets on first start and fails harmlessly
|
|
||||||
once it exists, as in upstream's own compose.
|
|
||||||
- **No config files.** OpenCloud's built-in CSP and app list cover the web UI
|
|
||||||
and the built-in identity provider. Upstream's `csp.yaml` and `apps.yaml`
|
|
||||||
only add origins for an external identity provider, office server and
|
|
||||||
optional web apps.
|
|
||||||
- **No office integration.** Editing documents in the browser needs a
|
|
||||||
separate office server on its own domain, OpenCloud's collaboration service,
|
|
||||||
and a custom `csp.yaml` allowing that domain. The office server also needs
|
|
||||||
extra kernel capabilities and a WOPI proof key. None of that can be switched
|
|
||||||
on by variables alone, so it is left out.
|
|
||||||
- **`opencloudeu/opencloud:7`.** The `opencloud` repository carries the
|
|
||||||
production releases, and `7` tracks the 7.x line. The `opencloud-rolling`
|
|
||||||
repository, which upstream's compose defaults to, ships a new major every
|
|
||||||
few months.
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
services:
|
|
||||||
opencloud:
|
|
||||||
image: opencloudeu/opencloud:7
|
|
||||||
restart: unless-stopped
|
|
||||||
# Writes the config with generated secrets on first start, then runs the server.
|
|
||||||
entrypoint: ["/bin/sh"]
|
|
||||||
command: ["-c", "opencloud init || true; opencloud server"]
|
|
||||||
environment:
|
|
||||||
- OC_URL=https://${OC_DOMAIN:?required}
|
|
||||||
- IDM_ADMIN_PASSWORD=${INITIAL_ADMIN_PASSWORD:?required}
|
|
||||||
- PROXY_TLS=false
|
|
||||||
- PROXY_HTTP_ADDR=0.0.0.0:9200
|
|
||||||
- OC_INSECURE=false
|
|
||||||
# - OC_LOG_LEVEL=${OC_LOG_LEVEL:-info}
|
|
||||||
# - PROXY_ENABLE_BASIC_AUTH=${PROXY_ENABLE_BASIC_AUTH:-false}
|
|
||||||
volumes:
|
|
||||||
- opencloud-config:/etc/opencloud
|
|
||||||
- opencloud-data:/var/lib/opencloud
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD", "curl", "-fsS", "http://127.0.0.1:9205/healthz"]
|
|
||||||
interval: 30s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 5
|
|
||||||
start_period: 60s
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
opencloud-config:
|
|
||||||
opencloud-data:
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
# Copy to .env and fill in. Never commit .env.
|
|
||||||
#
|
|
||||||
# cp .env.example .env
|
|
||||||
|
|
||||||
# Public hostname, without scheme. Also the only trusted domain.
|
|
||||||
OWNCLOUD_DOMAIN=owncloud.example.com
|
|
||||||
|
|
||||||
# Admin account, created on first start only.
|
|
||||||
# Generate a password with: openssl rand -base64 24
|
|
||||||
OWNCLOUD_ADMIN_USERNAME=admin
|
|
||||||
OWNCLOUD_ADMIN_PASSWORD=
|
|
||||||
|
|
||||||
# MariaDB credentials, used by the database and by ownCloud.
|
|
||||||
DB_NAME=owncloud
|
|
||||||
DB_USER=owncloud
|
|
||||||
DB_PASSWORD=
|
|
||||||
DB_ROOT_PASSWORD=
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
# owncloud
|
|
||||||
|
|
||||||
[ownCloud](https://doc.owncloud.com/server/11.0/admin_manual/installation/installing_with_docker.html)
|
|
||||||
Server: file sync and share, with web, desktop and mobile clients.
|
|
||||||
|
|
||||||
Three containers: `owncloud`, `db` (MariaDB) for metadata, users and shares, and
|
|
||||||
`cache` (Redis) for file locking and the distributed cache.
|
|
||||||
|
|
||||||
## Setup
|
|
||||||
|
|
||||||
1. Set `OWNCLOUD_DOMAIN`, `OWNCLOUD_ADMIN_PASSWORD`, `DB_PASSWORD` and
|
|
||||||
`DB_ROOT_PASSWORD`.
|
|
||||||
2. Map the domain to port `8080` and deploy. The first start installs
|
|
||||||
ownCloud and creates the admin account.
|
|
||||||
3. Log in with `OWNCLOUD_ADMIN_USERNAME` / `OWNCLOUD_ADMIN_PASSWORD`.
|
|
||||||
|
|
||||||
Health check: `/usr/bin/healthcheck`, the image's own script, also the compose
|
|
||||||
healthcheck. It requests `/status.php` on port `8080`.
|
|
||||||
|
|
||||||
Every start runs `occ upgrade` when `config.php` on `owncloud-data` says
|
|
||||||
ownCloud is installed, and a fresh install otherwise. A new image version is
|
|
||||||
therefore migrated on the next deploy, against whatever database `db` holds.
|
|
||||||
|
|
||||||
## Environment
|
|
||||||
|
|
||||||
| Variable | Default | Purpose |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| `OWNCLOUD_DOMAIN` | — | Public hostname, without scheme |
|
|
||||||
| `OWNCLOUD_ADMIN_USERNAME` / `OWNCLOUD_ADMIN_PASSWORD` | `admin` / — | Admin account |
|
|
||||||
| `DB_NAME` / `DB_USER` / `DB_PASSWORD` | `owncloud` / `owncloud` / — | Database credentials, read by both containers |
|
|
||||||
| `DB_ROOT_PASSWORD` | — | MariaDB root password |
|
|
||||||
|
|
||||||
`OWNCLOUD_DOMAIN` also fills `OWNCLOUD_TRUSTED_DOMAINS`; ownCloud rejects
|
|
||||||
requests for any host not on that list.
|
|
||||||
|
|
||||||
TLS ends at the proxy, so ownCloud sees plain HTTP. `OWNCLOUD_OVERWRITE_PROTOCOL`
|
|
||||||
makes the URLs it generates `https`, and `OWNCLOUD_PROTOCOL` does the same for
|
|
||||||
the URL `occ` and cron jobs use in links.
|
|
||||||
|
|
||||||
The admin variables are read only by the first-start install. Changing them
|
|
||||||
later does not change the account; use the web UI or `occ user:resetpassword`.
|
|
||||||
|
|
||||||
The database credentials are stored inside the MariaDB data directory when it
|
|
||||||
is first created. Changing them in the environment afterwards breaks the
|
|
||||||
connection rather than changing the password.
|
|
||||||
|
|
||||||
## Storage
|
|
||||||
|
|
||||||
| Volume | Mount | Holds |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| `owncloud-data` | `/mnt/data` | User files, apps, `config.php` |
|
|
||||||
| `db-data` | `/var/lib/mysql` | The database |
|
|
||||||
| `cache-data` | `/data` | Redis locks and cache |
|
|
||||||
|
|
||||||
The Redis volume follows ownCloud's own compose. Its contents are rebuilt
|
|
||||||
after a restart, but the `redis` image declares `/data` a volume, so without a
|
|
||||||
named one Docker creates an anonymous volume on every recreate.
|
|
||||||
|
|
||||||
## Images
|
|
||||||
|
|
||||||
`owncloud/server:11` tracks the 11.x line, so patch releases arrive on the next
|
|
||||||
deploy and a move to a new major stays a deliberate upgrade. ownCloud's own
|
|
||||||
compose pins a full version instead.
|
|
||||||
|
|
||||||
`mariadb:10.11` is the release ownCloud 11's compose uses and the top of the
|
|
||||||
MariaDB range ownCloud supports (10.2 to 10.11); a newer major is outside it.
|
|
||||||
`MARIADB_AUTO_UPGRADE=1`, also from ownCloud's compose, runs `mariadb-upgrade`
|
|
||||||
when the server version changes. A MariaDB data directory cannot move back to
|
|
||||||
an older major, so going down a version means a dump and restore into an empty
|
|
||||||
volume, not an image change. Back up `db-data` before changing the tag.
|
|
||||||
|
|
||||||
`redis:7` is the version ownCloud's compose uses.
|
|
||||||
|
|
||||||
ownCloud 11 runs on PHP 8. Apps installed into `/mnt/data/apps` from the
|
|
||||||
marketplace under 10.x were built for PHP 7, so they are removed or replaced
|
|
||||||
with PHP 8 versions before moving a 10.x install to 11.
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
services:
|
|
||||||
owncloud:
|
|
||||||
image: owncloud/server:11
|
|
||||||
restart: unless-stopped
|
|
||||||
environment:
|
|
||||||
- OWNCLOUD_DOMAIN=${OWNCLOUD_DOMAIN:?required}
|
|
||||||
- OWNCLOUD_TRUSTED_DOMAINS=${OWNCLOUD_DOMAIN:?required}
|
|
||||||
- OWNCLOUD_PROTOCOL=https
|
|
||||||
- OWNCLOUD_OVERWRITE_PROTOCOL=https
|
|
||||||
- OWNCLOUD_ADMIN_USERNAME=${OWNCLOUD_ADMIN_USERNAME:-admin}
|
|
||||||
- OWNCLOUD_ADMIN_PASSWORD=${OWNCLOUD_ADMIN_PASSWORD:?required}
|
|
||||||
- OWNCLOUD_DB_TYPE=mysql
|
|
||||||
- OWNCLOUD_DB_HOST=db
|
|
||||||
- OWNCLOUD_DB_NAME=${DB_NAME:-owncloud}
|
|
||||||
- OWNCLOUD_DB_USERNAME=${DB_USER:-owncloud}
|
|
||||||
- OWNCLOUD_DB_PASSWORD=${DB_PASSWORD:?required}
|
|
||||||
- OWNCLOUD_MYSQL_UTF8MB4=true
|
|
||||||
- OWNCLOUD_REDIS_ENABLED=true
|
|
||||||
- OWNCLOUD_REDIS_HOST=cache
|
|
||||||
volumes:
|
|
||||||
- owncloud-data:/mnt/data
|
|
||||||
depends_on:
|
|
||||||
db:
|
|
||||||
condition: service_healthy
|
|
||||||
cache:
|
|
||||||
condition: service_healthy
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD", "/usr/bin/healthcheck"]
|
|
||||||
interval: 30s
|
|
||||||
timeout: 10s
|
|
||||||
retries: 5
|
|
||||||
|
|
||||||
# MariaDB for file metadata, users and shares.
|
|
||||||
db:
|
|
||||||
image: mariadb:10.11
|
|
||||||
restart: unless-stopped
|
|
||||||
command:
|
|
||||||
- --character-set-server=utf8mb4
|
|
||||||
- --collation-server=utf8mb4_bin
|
|
||||||
- --max-allowed-packet=128M
|
|
||||||
- --innodb-log-file-size=64M
|
|
||||||
environment:
|
|
||||||
- MYSQL_ROOT_PASSWORD=${DB_ROOT_PASSWORD:?required}
|
|
||||||
- MYSQL_DATABASE=${DB_NAME:-owncloud}
|
|
||||||
- MYSQL_USER=${DB_USER:-owncloud}
|
|
||||||
- MYSQL_PASSWORD=${DB_PASSWORD:?required}
|
|
||||||
- MARIADB_AUTO_UPGRADE=1
|
|
||||||
volumes:
|
|
||||||
- db-data:/var/lib/mysql
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
|
|
||||||
interval: 5s
|
|
||||||
timeout: 20s
|
|
||||||
retries: 10
|
|
||||||
|
|
||||||
# Redis for file locking and the distributed cache.
|
|
||||||
cache:
|
|
||||||
image: redis:7
|
|
||||||
restart: unless-stopped
|
|
||||||
command: ["--databases", "1"]
|
|
||||||
volumes:
|
|
||||||
- cache-data:/data
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD", "redis-cli", "ping"]
|
|
||||||
interval: 10s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 5
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
owncloud-data:
|
|
||||||
db-data:
|
|
||||||
cache-data:
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
# Copy to .env and fill in. Never commit .env.
|
|
||||||
#
|
|
||||||
# cp .env.example .env
|
|
||||||
|
|
||||||
# Container hostname, shown as the host label in the web UI. Also passed in as
|
|
||||||
# HOST -- the name zsh's prompt shows.
|
|
||||||
SERVICE_HOSTNAME=paseo
|
|
||||||
|
|
||||||
# Password for the daemon API and web UI. Also the paseo user's login password,
|
|
||||||
# so it is what `sudo` asks for inside a terminal.
|
|
||||||
# Generate one with: openssl rand -base64 24
|
|
||||||
PASEO_PASSWORD=
|
|
||||||
|
|
||||||
# Comma-separated DNS names allowed to reach the daemon. The domain mapped in
|
|
||||||
# Coolify/Dokploy must be listed here. IPs and localhost are always allowed.
|
|
||||||
PASEO_HOSTNAMES=
|
|
||||||
|
|
||||||
# Proxy source IPs whose X-Forwarded-Proto the daemon trusts.
|
|
||||||
PASEO_TRUSTED_PROXIES=uniquelocal
|
|
||||||
|
|
||||||
# Agent CLIs to install on start, if not already present. Space- or
|
|
||||||
# comma-separated, from: claude codex opencode copilot omp pi. Leave empty to
|
|
||||||
# install none.
|
|
||||||
AGENTS=claude codex
|
|
||||||
|
|
||||||
# Git identity for agents and terminals, as author and committer. git reads
|
|
||||||
# these directly, so no `git config` step is needed.
|
|
||||||
GIT_NAME=
|
|
||||||
GIT_EMAIL=
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
# Adds a C toolchain, sudo, zsh and nano to the official image. See README.md,
|
|
||||||
# which carries the reasoning for everything here.
|
|
||||||
FROM ghcr.io/getpaseo/paseo:latest
|
|
||||||
|
|
||||||
# --- system packages -------------------------------------------------------
|
|
||||||
RUN apt-get update \
|
|
||||||
&& apt-get install -y --no-install-recommends \
|
|
||||||
build-essential sudo \
|
|
||||||
zsh nano \
|
|
||||||
&& rm -rf /var/lib/apt/lists/* \
|
|
||||||
&& usermod -aG sudo paseo
|
|
||||||
|
|
||||||
# --- agent cli path --------------------------------------------------------
|
|
||||||
# Puts the $HOME directories the agent and tool installers write to on PATH.
|
|
||||||
ENV PATH=/home/paseo/.local/bin:/home/paseo/.opencode/bin:$PATH
|
|
||||||
|
|
||||||
# --- entrypoint ------------------------------------------------------------
|
|
||||||
# Wraps the image's entrypoint with the root-stage setup -- see entrypoint.sh.
|
|
||||||
COPY --chmod=0755 entrypoint.sh /usr/local/bin/entrypoint
|
|
||||||
ENTRYPOINT ["/usr/bin/tini", "--", "/usr/local/bin/entrypoint"]
|
|
||||||
-226
@@ -1,226 +0,0 @@
|
|||||||
# paseo
|
|
||||||
|
|
||||||
[Paseo](https://paseo.sh) — self-hosted daemon and web UI for running coding
|
|
||||||
agents. Built from a local `Dockerfile` that adds a C toolchain, `sudo`, `zsh`
|
|
||||||
and `nano` to the [official image](https://paseo.sh/docs/docker), which ships
|
|
||||||
none of it. The agent CLIs are not baked in; `entrypoint.sh` installs them into
|
|
||||||
`$HOME` on start, see [Agents](#agents). `gh`, `glab`, `uv` and Python are
|
|
||||||
installed by hand into `$HOME`, see [Tools](#tools).
|
|
||||||
|
|
||||||
## Setup
|
|
||||||
|
|
||||||
1. Point the domain at port `6767` and deploy.
|
|
||||||
2. Open the domain. At the pairing screen enter the host **with the port**,
|
|
||||||
then the `PASEO_PASSWORD` value:
|
|
||||||
|
|
||||||
```
|
|
||||||
paseo.example.com:443
|
|
||||||
```
|
|
||||||
|
|
||||||
3. List the agents you want in `AGENTS`; the first start installs them.
|
|
||||||
Log in to each — see [Agents](#agents).
|
|
||||||
4. Install the [tools](#tools) you want, then `gh auth login` and
|
|
||||||
`glab auth login`.
|
|
||||||
|
|
||||||
The port must be typed by hand. The UI rejects a bare hostname, and the
|
|
||||||
auto-connect hint does not help: the daemon builds it from the `Host` header,
|
|
||||||
browsers drop the default `:443`, and the UI discards a hint with no port. What
|
|
||||||
you see instead is its `localhost:6767` placeholder, which in a browser means
|
|
||||||
your own machine.
|
|
||||||
|
|
||||||
## Environment
|
|
||||||
|
|
||||||
| Variable | Purpose |
|
|
||||||
| --- | --- |
|
|
||||||
| `PASEO_PASSWORD` | Web UI and API login, and the `paseo` user's `sudo` password. Generate with `openssl rand -base64 24`. |
|
|
||||||
| `PASEO_HOSTNAMES` | Domains allowed to reach the daemon, comma-separated. Your domain must be listed. |
|
|
||||||
| `PASEO_TRUSTED_PROXIES` | Set to `uniquelocal`, or the UI loads but never connects. |
|
|
||||||
| `AGENTS` | Agent CLIs to install on start if missing, space- or comma-separated. Empty installs none. See [Agents](#agents). |
|
|
||||||
| `SERVICE_HOSTNAME` | Container hostname, shown as the host label in the UI and in the shell prompt. Without it the label is a random container ID. |
|
|
||||||
| `GIT_NAME` / `GIT_EMAIL` | Git author and committer identity for agents and terminals. |
|
|
||||||
|
|
||||||
`PASEO_TRUSTED_PROXIES` matches the proxy's *source IP*, so hostnames are
|
|
||||||
rejected. The daemon trusts `X-Forwarded-Proto` from loopback only, but
|
|
||||||
Coolify's Traefik reaches it from the Docker bridge network — so it reads the
|
|
||||||
request as plain HTTP, tells the UI to use `ws://` on an `https://` page, and
|
|
||||||
the browser blocks that as mixed content. `uniquelocal` covers the private
|
|
||||||
ranges Docker uses. An exact CIDR works too, but Coolify assigns a fresh subnet
|
|
||||||
per project.
|
|
||||||
|
|
||||||
`SERVICE_HOSTNAME` is used twice: as the container's `hostname:`, which is
|
|
||||||
where the daemon takes its host label from, and as the `HOST` variable inside
|
|
||||||
it. Coolify injects `HOST=0.0.0.0` into every compose app, and zsh seeds `$HOST`
|
|
||||||
and the `%m`/`%M` prompt escapes from that variable rather than calling
|
|
||||||
`gethostname()` — so the prompt would read `0`, the first dot-separated field
|
|
||||||
of `0.0.0.0`. Paseo itself never reads `HOST` — it binds `PASEO_LISTEN` — so
|
|
||||||
overriding it only affects the prompt. bash is unaffected; its `\h` uses the
|
|
||||||
real hostname.
|
|
||||||
|
|
||||||
`SHELL=/bin/zsh` and `TZ=Asia/Ho_Chi_Minh` are written into `compose.yml`
|
|
||||||
directly rather than read from `.env`, which is why neither is in the table.
|
|
||||||
They are properties of this setup, not of whoever deploys it — and every
|
|
||||||
interactive shell exports `SHELL`, so a value routed through `.env` could be
|
|
||||||
replaced by the deploying terminal's own shell, handing Paseo's terminals a
|
|
||||||
path the image lacks. Paseo reads `$SHELL` for its terminals and falls back to
|
|
||||||
`/bin/sh` (dash) otherwise; it ignores the user's login shell, so `chsh` has no
|
|
||||||
effect. That is what pins zsh here.
|
|
||||||
|
|
||||||
## Agents
|
|
||||||
|
|
||||||
The image installs none of them. `entrypoint.sh` does, on start, for every name
|
|
||||||
in `AGENTS` whose command does not already run. The default is
|
|
||||||
`AGENTS=claude codex`; the other four are opt-in.
|
|
||||||
|
|
||||||
| Agent | Name in `AGENTS` | Installer it runs | Log in with |
|
|
||||||
| --- | --- | --- | --- |
|
|
||||||
| Claude Code | `claude` | `claude.ai/install.sh` | `claude` |
|
|
||||||
| Codex | `codex` | `chatgpt.com/codex/install.sh` | `codex login` |
|
|
||||||
| opencode | `opencode` | `opencode.ai/install` | `opencode auth login` |
|
|
||||||
| Copilot CLI | `copilot` | `gh.io/copilot-install` | `/login` inside `copilot` |
|
|
||||||
| Oh My Pi | `omp` | `omp.sh/install` | `omp` |
|
|
||||||
| Pi | `pi` | `pi.dev/install.sh` | `pi` |
|
|
||||||
|
|
||||||
Each vendor's own installer, run as `paseo` through `gosu`, landing in `$HOME`
|
|
||||||
— `~/.local/bin` for most, `~/.opencode/bin` for opencode. `$HOME` is the
|
|
||||||
`paseo-home` volume, so the binaries and the logins both survive a redeploy.
|
|
||||||
Pi and Oh My Pi are separate projects sharing an ancestor; their commands do
|
|
||||||
not collide.
|
|
||||||
|
|
||||||
**First start.** These are fat static binaries, a few hundred MB each — Claude
|
|
||||||
Code around 216 MB, `omp` around 208 MB — so the default two hold the daemon
|
|
||||||
back by a minute or more, and all six by several. Later starts skip everything
|
|
||||||
already present. An agent that fails to install, or a name that is not in the
|
|
||||||
table, is logged and skipped rather than taking the container with it, so a bad
|
|
||||||
release or a network blip cannot leave you without a shell.
|
|
||||||
|
|
||||||
**Presence check.** The start asks each command for `--version` and reinstalls
|
|
||||||
only on exit codes 126 and 127, the shell's own codes for a binary it could not
|
|
||||||
execute. A `curl | bash` cut short — a network drop, or the platform stopping
|
|
||||||
the container mid-download — leaves a truncated binary on the volume, which a
|
|
||||||
file-existence check would accept on every later start while the daemon
|
|
||||||
advertised an agent that fails on every call. An agent that runs but does not
|
|
||||||
understand `--version` exits with some other code and counts as present, so
|
|
||||||
nothing assumes all six support the flag.
|
|
||||||
|
|
||||||
**Installing by hand.** Run the installer in a terminal inside Paseo as
|
|
||||||
`paseo` — never under `sudo`, where it targets root's home and lands outside
|
|
||||||
the volume, and where Claude Code's refuses outright. Both install directories
|
|
||||||
are on the image's `PATH`, so the daemon picks an agent up as soon as its
|
|
||||||
installer finishes — no redeploy, no restart. The installers pull in any
|
|
||||||
runtime they need, into `$HOME` as well. `omp` is the one to know about: it is
|
|
||||||
Bun-compiled, and with no Bun on `PATH` its installer takes the prebuilt
|
|
||||||
binary. Ask for the source build (`--source`) and it installs Bun to `~/.bun`
|
|
||||||
first.
|
|
||||||
|
|
||||||
**Why on start and not in the `Dockerfile`.** `paseo` cannot write
|
|
||||||
`/usr/local`, so a CLI installed there can never apply its own update — every
|
|
||||||
one of these ships an updater (`claude update`, `codex update`, `omp update`,
|
|
||||||
…) that rewrites its own binary, and it fails on permissions; Claude Code nags
|
|
||||||
about it at startup. And a build-time install into `/home/paseo` would only
|
|
||||||
ever reach a *new* volume: Docker seeds a named volume from the image once, at
|
|
||||||
creation, and never again. Rebuild with a seventh agent added and nobody with
|
|
||||||
an existing `paseo-home` would get it. The start-time check fires on every
|
|
||||||
fresh volume, and the agents update themselves in place afterwards.
|
|
||||||
|
|
||||||
## Storage
|
|
||||||
|
|
||||||
| Volume | Mount | Holds |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| `paseo-home` | `/home/paseo` | Daemon state, agent CLIs and their configs and credentials (`.claude`, `.codex`, `.config/*`) |
|
|
||||||
| `paseo-workspace` | `/workspace` | Code the agents work on |
|
|
||||||
|
|
||||||
The agent CLIs, `gh` and `glab` all keep their config under `/home/paseo` — the
|
|
||||||
base image points `CLAUDE_CONFIG_DIR`, `CODEX_HOME` and the `XDG_*` variables
|
|
||||||
into it — so every login survives a redeploy, as do dotfiles such as a `.zshrc`
|
|
||||||
or an oh-my-zsh install. Anything written outside `$HOME` (`chsh`,
|
|
||||||
`apt install`) is lost on rebuild.
|
|
||||||
|
|
||||||
## Tools
|
|
||||||
|
|
||||||
`gh`, `glab`, `uv` and Python are not in the image. Install them from a
|
|
||||||
terminal inside Paseo, as `paseo` and not under `sudo`, into `~/.local/bin`.
|
|
||||||
That directory is already on the image's `PATH` and lives on the `paseo-home`
|
|
||||||
volume, so each tool survives a redeploy and the daemon sees it at once. To
|
|
||||||
update one, run its block again (`uv self update` for `uv`).
|
|
||||||
|
|
||||||
```sh
|
|
||||||
mkdir -p ~/.local/bin
|
|
||||||
arch=$(dpkg --print-architecture)
|
|
||||||
|
|
||||||
# gh, from GitHub's release tarball
|
|
||||||
v=$(curl -fsSL https://api.github.com/repos/cli/cli/releases/latest \
|
|
||||||
| sed -n 's/.*"tag_name": "v\([^"]*\)".*/\1/p')
|
|
||||||
curl -fsSL "https://github.com/cli/cli/releases/download/v${v}/gh_${v}_linux_${arch}.tar.gz" \
|
|
||||||
| tar -xz -C ~/.local --strip-components=1 "gh_${v}_linux_${arch}/bin/gh"
|
|
||||||
|
|
||||||
# glab, from GitLab's release tarball
|
|
||||||
v=$(curl -fsSL https://gitlab.com/api/v4/projects/gitlab-org%2Fcli/releases/permalink/latest \
|
|
||||||
| sed -n 's/.*"tag_name":"v\([^"]*\)".*/\1/p')
|
|
||||||
curl -fsSL "https://gitlab.com/gitlab-org/cli/-/releases/v${v}/downloads/glab_${v}_linux_${arch}.tar.gz" \
|
|
||||||
| tar -xz -C ~/.local bin/glab
|
|
||||||
|
|
||||||
# uv, then Python through it; --default adds python and python3
|
|
||||||
curl -LsSf https://astral.sh/uv/install.sh | sh
|
|
||||||
uv python install 3.12 --default
|
|
||||||
```
|
|
||||||
|
|
||||||
## Image
|
|
||||||
|
|
||||||
| Package | Why |
|
|
||||||
| --- | --- |
|
|
||||||
| `build-essential` | The C toolchain npm's node-gyp addons and Python C extensions shell out to for `gcc` and `make` |
|
|
||||||
| `sudo` | Root inside a terminal; `PASEO_PASSWORD` is the password |
|
|
||||||
| `zsh` | The shell Paseo's terminals run, see [Environment](#environment) |
|
|
||||||
| `nano` | An editor for a terminal |
|
|
||||||
|
|
||||||
The `Dockerfile` and `entrypoint.sh` only say what each step does. The
|
|
||||||
reasoning:
|
|
||||||
|
|
||||||
- Only system packages are in the image. Single-binary tools — `gh`,
|
|
||||||
`glab`, `uv`, Python — go in `$HOME` like the agent CLIs, where they persist
|
|
||||||
and `paseo` can update them. A build-time install into `/home/paseo` would be
|
|
||||||
masked by the volume, and one under `/usr/local` cannot be updated by
|
|
||||||
`paseo`.
|
|
||||||
- No agent CLIs, and no runtime only they need (`omp`'s Bun). Under
|
|
||||||
`/usr/local` they cannot self-update; in `$HOME` they can, and they persist
|
|
||||||
anyway. See [Agents](#agents).
|
|
||||||
- No language toolchain beyond `build-essential` is in the image, because
|
|
||||||
none is wanted often enough to pay for a rebuild — install Go, a JVM or
|
|
||||||
anything else into `$HOME` from a terminal, where it persists on the
|
|
||||||
`paseo-home` volume like the agent CLIs do.
|
|
||||||
- `git` and `curl` are already in the base image. `sudo` is not, despite
|
|
||||||
Debian's `base-passwd` shipping an empty `sudo` group, so the apt layer adds
|
|
||||||
it and puts `paseo` in the group.
|
|
||||||
- The image stays root. `entrypoint.sh`, installed as
|
|
||||||
`/usr/local/bin/entrypoint`, runs *before* the base image's
|
|
||||||
`paseo-docker-entrypoint` and then hands over to it: that one ends in
|
|
||||||
`exec gosu paseo` and never returns, and every job here — `chpasswd`, and
|
|
||||||
`gosu paseo` for the agent installs — needs root.
|
|
||||||
- The `paseo` password is set on every start rather than at build, so it never
|
|
||||||
lands in an image layer, and because `/etc/shadow` is in the image rather
|
|
||||||
than on a volume and reverts on each recreate. It is piped, not passed as an
|
|
||||||
argument, since arguments are visible in `ps`; `chpasswd` splits on the first
|
|
||||||
colon, so a colon in the password is fine. A failure there is logged and the
|
|
||||||
start continues, because `chpasswd` rejects a multi-line value and under
|
|
||||||
`set -e` that would otherwise take the whole service down rather than just
|
|
||||||
the password. An empty `PASEO_PASSWORD` leaves the account locked and `sudo`
|
|
||||||
unusable.
|
|
||||||
- `/home/paseo` is not `chown`ed before the agent installs. The base image
|
|
||||||
declares it a volume and ships it owned by uid 1000, so a fresh named volume
|
|
||||||
is seeded with that ownership; the base entrypoint also chowns it, along
|
|
||||||
with `PASEO_HOME`, `CLAUDE_CONFIG_DIR`, `CODEX_HOME` and the XDG directories,
|
|
||||||
whenever one of them is owned by root.
|
|
||||||
- `chpasswd` and `gosu` are called by absolute path. The agent `PATH` entries
|
|
||||||
come first in the image's `PATH`, including root's, and they live on a
|
|
||||||
volume that anything running as `paseo` — an agent, by design — can write to;
|
|
||||||
a file planted there under one of those names would otherwise run as root on
|
|
||||||
the next start.
|
|
||||||
- Globbing is off (`set -f`) around the `AGENTS` loop. The list is split
|
|
||||||
unquoted, so a `*` in it would otherwise expand against `/workspace`, the
|
|
||||||
working directory, and report every file in it as an unknown agent.
|
|
||||||
- The agent `PATH` entries are `ENV` in the image, not a shell rc entry: the
|
|
||||||
daemon probes for each provider's binary with `which` in its own environment,
|
|
||||||
which never sources an rc file, and would otherwise report every
|
|
||||||
self-installed agent as unavailable while a terminal ran it fine. They are
|
|
||||||
spelled `/home/paseo/...` because `ENV` only expands variables the
|
|
||||||
`Dockerfile` itself set earlier.
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
services:
|
|
||||||
paseo:
|
|
||||||
build: .
|
|
||||||
restart: unless-stopped
|
|
||||||
hostname: ${SERVICE_HOSTNAME}
|
|
||||||
environment:
|
|
||||||
- PASEO_PASSWORD=${PASEO_PASSWORD}
|
|
||||||
- PASEO_HOSTNAMES=${PASEO_HOSTNAMES}
|
|
||||||
- PASEO_TRUSTED_PROXIES=${PASEO_TRUSTED_PROXIES}
|
|
||||||
- SHELL=/bin/zsh
|
|
||||||
- AGENTS=${AGENTS}
|
|
||||||
- TZ=Asia/Ho_Chi_Minh
|
|
||||||
- GIT_AUTHOR_NAME=${GIT_NAME}
|
|
||||||
- GIT_AUTHOR_EMAIL=${GIT_EMAIL}
|
|
||||||
- GIT_COMMITTER_NAME=${GIT_NAME}
|
|
||||||
- GIT_COMMITTER_EMAIL=${GIT_EMAIL}
|
|
||||||
- HOST=${SERVICE_HOSTNAME}
|
|
||||||
volumes:
|
|
||||||
- 'paseo-home:/home/paseo'
|
|
||||||
- 'paseo-workspace:/workspace'
|
|
||||||
volumes:
|
|
||||||
paseo-home:
|
|
||||||
paseo-workspace:
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# Runs as root ahead of the image's own entrypoint: sets the paseo user's
|
|
||||||
# login password, then installs any agent CLI named in AGENTS whose command
|
|
||||||
# does not already run. See README.md.
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
if [[ "$(id -u)" != "0" ]]; then
|
|
||||||
exec /usr/local/bin/paseo-docker-entrypoint "$@"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ -n "${PASEO_PASSWORD:-}" ]]; then
|
|
||||||
printf 'paseo:%s\n' "$PASEO_PASSWORD" | /usr/sbin/chpasswd \
|
|
||||||
|| echo "entrypoint: could not set the paseo password, continuing" >&2
|
|
||||||
fi
|
|
||||||
|
|
||||||
agent_installer() {
|
|
||||||
case "$1" in
|
|
||||||
claude) echo 'curl -fsSL https://claude.ai/install.sh | bash' ;;
|
|
||||||
codex) echo 'curl -fsSL https://chatgpt.com/codex/install.sh | sh' ;;
|
|
||||||
opencode) echo 'curl -fsSL https://opencode.ai/install | bash' ;;
|
|
||||||
copilot) echo 'curl -fsSL https://gh.io/copilot-install | bash' ;;
|
|
||||||
omp) echo 'curl -fsSL https://omp.sh/install | sh' ;;
|
|
||||||
pi) echo 'curl -fsSL https://pi.dev/install.sh | sh' ;;
|
|
||||||
esac
|
|
||||||
}
|
|
||||||
|
|
||||||
agents="${AGENTS:-}"
|
|
||||||
|
|
||||||
set -f
|
|
||||||
|
|
||||||
for agent in ${agents//,/ }; do
|
|
||||||
installer="$(agent_installer "$agent")"
|
|
||||||
|
|
||||||
if [[ -z "$installer" ]]; then
|
|
||||||
echo "entrypoint: no such agent CLI: $agent" >&2
|
|
||||||
continue
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Skip the agent when its command runs.
|
|
||||||
rc=0
|
|
||||||
/usr/sbin/gosu paseo bash -c '"$0" --version' "$agent" >/dev/null 2>&1 \
|
|
||||||
|| rc=$?
|
|
||||||
|
|
||||||
if (( rc != 126 && rc != 127 )); then
|
|
||||||
continue
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "entrypoint: installing $agent"
|
|
||||||
/usr/sbin/gosu paseo bash -c "$installer" \
|
|
||||||
|| echo "entrypoint: $agent failed to install, continuing" >&2
|
|
||||||
done
|
|
||||||
|
|
||||||
exec /usr/local/bin/paseo-docker-entrypoint "$@"
|
|
||||||
Whitespace-only changes.
@@ -1,6 +0,0 @@
|
|||||||
# Copy to .env and fill in. Never commit .env.
|
|
||||||
#
|
|
||||||
# cp .env.example .env
|
|
||||||
|
|
||||||
# Application token from the TraffMonetizer dashboard, under "Your token".
|
|
||||||
TOKEN=
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
# traffmonetizer
|
|
||||||
|
|
||||||
[TraffMonetizer](https://traffmonetizer.com) bandwidth-sharing client, defined
|
|
||||||
in `compose.yml` with `restart: always`. It
|
|
||||||
only makes outbound connections, so there is no port to map a domain to.
|
|
||||||
|
|
||||||
`restart: always` instead of `unless-stopped` is on purpose: the client should
|
|
||||||
be restarted as often as possible. Docker brings it back even after a manual
|
|
||||||
stop once the daemon or host restarts. The Coolify app also has no watch path
|
|
||||||
for the same reason, so every push to the repository redeploys it.
|
|
||||||
|
|
||||||
The image tag is `arm64v8`. Change it to match the host architecture.
|
|
||||||
|
|
||||||
## Environment
|
|
||||||
|
|
||||||
| Variable | Purpose |
|
|
||||||
| --- | --- |
|
|
||||||
| `TOKEN` | Application token from the TraffMonetizer dashboard. Passed both as an environment variable and on the `start accept --token` command line. |
|
|
||||||
|
|
||||||
## Storage
|
|
||||||
|
|
||||||
None — the client keeps no state worth persisting.
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
services:
|
|
||||||
traffmonetizer:
|
|
||||||
image: 'traffmonetizer/cli_v2:arm64v8'
|
|
||||||
restart: always
|
|
||||||
command: 'start accept --token ${TOKEN}'
|
|
||||||
environment:
|
|
||||||
- 'TOKEN=${TOKEN}'
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
# Copy to .env and fill in. Never commit .env.
|
|
||||||
#
|
|
||||||
# cp .env.example .env
|
|
||||||
|
|
||||||
# Login for the web desktop.
|
|
||||||
# Generate one with: openssl rand -base64 24
|
|
||||||
CUSTOM_USER=
|
|
||||||
PASSWORD=
|
|
||||||
|
|
||||||
TZ=Asia/Ho_Chi_Minh
|
|
||||||
|
|
||||||
# Browser tab title.
|
|
||||||
# TITLE=Webtop
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
FROM lscr.io/linuxserver/webtop:ubuntu-xfce
|
|
||||||
|
|
||||||
# Workspace directory owned by the abc user (PUID/PGID 1000).
|
|
||||||
RUN mkdir -p /workspace && chown 1000:1000 /workspace
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
# webtop
|
|
||||||
|
|
||||||
[Webtop](https://docs.linuxserver.io/images/docker-webtop): a full Ubuntu
|
|
||||||
XFCE desktop in the browser, from the LinuxServer image, streamed by Selkies.
|
|
||||||
|
|
||||||
Comes with Go, Node.js 24, Python 3 and zsh via LinuxServer mods, the
|
|
||||||
`code-server-npmglobal` mod so `npm install -g` lands under `/config` and
|
|
||||||
persists, plus `bubblewrap`, `gh`, `git`, `glab`, `unzip` and `zip` through
|
|
||||||
`INSTALL_PACKAGES`. The `code-server-*` mods carry that name upstream but are
|
|
||||||
plain Ubuntu installers that work on any LinuxServer Ubuntu image.
|
|
||||||
|
|
||||||
Mods install into each new container, so the first start after a recreate
|
|
||||||
takes a few minutes longer before the desktop answers.
|
|
||||||
|
|
||||||
## Setup
|
|
||||||
|
|
||||||
1. Set `CUSTOM_USER` and `PASSWORD`.
|
|
||||||
2. Map the domain to port `3000` and deploy.
|
|
||||||
3. Open the domain and log in with `CUSTOM_USER` / `PASSWORD`.
|
|
||||||
|
|
||||||
Port `3000` serves plain HTTP and must sit behind the proxy, which adds HTTPS.
|
|
||||||
The image's own HTTPS port `3001`, with a self-signed certificate, is unused.
|
|
||||||
|
|
||||||
## Environment
|
|
||||||
|
|
||||||
| Variable | Default | Purpose |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| `CUSTOM_USER` / `PASSWORD` | — | Login for the web desktop |
|
|
||||||
| `TZ` | `Asia/Ho_Chi_Minh` | Desktop timezone |
|
|
||||||
| `TITLE` | optional | Browser tab title |
|
|
||||||
|
|
||||||
`CUSTOM_USER` and `PASSWORD` are required. Without `PASSWORD` the image serves the desktop, with a
|
|
||||||
shell and `sudo`, to anyone who opens the domain.
|
|
||||||
|
|
||||||
`PUID`/`PGID` are pinned to `1000` in `compose.yml`; the `Dockerfile` depends
|
|
||||||
on that (see Storage).
|
|
||||||
|
|
||||||
## Docker access
|
|
||||||
|
|
||||||
The `universal-docker` mod installs the Docker CLI but no daemon, so the host
|
|
||||||
socket is bind-mounted at `/var/run/docker.sock`. Containers started from
|
|
||||||
inside are siblings on the host, not children: bind mounts in them resolve
|
|
||||||
against host paths, so a path under `/config` will not exist unless the same
|
|
||||||
path exists on the host.
|
|
||||||
|
|
||||||
The socket belongs to the host's `docker` group. At startup the mod reads the
|
|
||||||
socket's GID, creates a group with it if none exists and adds `abc` to it. The
|
|
||||||
desktop starts as `abc` afterwards, with that group, so `docker` works without
|
|
||||||
`sudo` in any terminal it opens. `CUSTOM_USER` only names the web login; the
|
|
||||||
Linux user is still `abc`. Handing a container the
|
|
||||||
socket is equivalent to giving it root on the host, accepted here because this
|
|
||||||
is a single-user desktop. The `:ro` flag only marks the socket file read-only;
|
|
||||||
it does not restrict the Docker API.
|
|
||||||
|
|
||||||
## Storage
|
|
||||||
|
|
||||||
| Volume | Mount | Holds |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| `webtop-config` | `/config` | Home directory: desktop settings, browser profiles, CLI logins, apps installed with `proot-apps` |
|
|
||||||
| `webtop-workspace` | `/workspace` | Code and files you work on |
|
|
||||||
|
|
||||||
The `Dockerfile` exists only to make `/workspace` writable. The image's init
|
|
||||||
chowns `/config` to the `abc` user but not other paths, so a named volume on
|
|
||||||
`/workspace` would come up `root:root`. Creating the directory in the image,
|
|
||||||
owned by `1000:1000`, fixes that: Docker seeds an empty named volume from the
|
|
||||||
image directory, ownership included.
|
|
||||||
|
|
||||||
Software installed with `apt` lives in the container and is lost when it is
|
|
||||||
recreated. Anything that must survive goes under `/config`, through
|
|
||||||
`proot-apps` or a user-level install.
|
|
||||||
|
|
||||||
## Resources
|
|
||||||
|
|
||||||
`shm_size: 1gb` is upstream's recommendation for every desktop image; browsers
|
|
||||||
and the video encoder run out of shared memory at Docker's 64 MB default.
|
|
||||||
|
|
||||||
## Image
|
|
||||||
|
|
||||||
`ubuntu-xfce` is LinuxServer's moving tag for the Ubuntu XFCE flavour.
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
services:
|
|
||||||
webtop:
|
|
||||||
build: .
|
|
||||||
restart: unless-stopped
|
|
||||||
shm_size: 1gb
|
|
||||||
environment:
|
|
||||||
- CUSTOM_USER=${CUSTOM_USER:?required}
|
|
||||||
- PASSWORD=${PASSWORD:?required}
|
|
||||||
- PUID=1000
|
|
||||||
- PGID=1000
|
|
||||||
- 'DOCKER_MODS=linuxserver/mods:universal-package-install|linuxserver/mods:universal-docker|linuxserver/mods:code-server-golang|linuxserver/mods:code-server-nodejs|linuxserver/mods:code-server-npmglobal|linuxserver/mods:code-server-python3|linuxserver/mods:code-server-zsh'
|
|
||||||
- INSTALL_PACKAGES=bubblewrap|gh|git|glab|unzip|zip
|
|
||||||
- NODEJS_MOD_VERSION=24
|
|
||||||
- TZ=${TZ:-Asia/Ho_Chi_Minh}
|
|
||||||
# - TITLE=${TITLE:-Webtop}
|
|
||||||
volumes:
|
|
||||||
- webtop-config:/config
|
|
||||||
- webtop-workspace:/workspace
|
|
||||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
webtop-config:
|
|
||||||
webtop-workspace:
|
|
||||||
Reference in new issue
Block a user