Compare commits

..
4 Commits
Author SHA1 Message Date
tiennm99 0ed09bc0f5 docs: give each service its own README
Move the code-server details into code-server/README.md and reduce the
root README to shared conventions plus a table linking to each service.
2026-08-14 09:25:36 +07:00
tiennm99 f9a663b998 refactor(code-server): drop Codex access token
No longer used; removes the CODEX_ACCESS_TOKEN variable from the service
definition and its example env file.
2026-08-14 09:25:24 +07:00
tiennm99 e8ce5207e0 feat: add code-server service and repo scaffolding
Set up the per-service layout: each service directory holds compose.yml
with a committed .env.example and a gitignored .env.

Add code-server as the first service, plus a README and CLAUDE.md
documenting that these files target Coolify/Dokploy and deliberately
omit ports and restart policies.
2026-08-14 09:13:49 +07:00
tiennm99 8241914411 Initial commit 2026-08-14 08:57:46 +07:00
78 changed files with 54 additions and 4186 deletions

No files matched your search

-60
View File
@@ -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.
-33
View File
@@ -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.
-61
View File
@@ -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`.
-20
View File
@@ -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.
-110
View File
@@ -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.
-117
View File
@@ -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
-7
View File
@@ -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/
+10 -88
View File
@@ -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.
+17 -58
View File
@@ -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).
-23
View File
@@ -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
View File
@@ -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.
-292
View File
@@ -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"
}
}
+3 -7
View File
@@ -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
-36
View File
@@ -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
View File
@@ -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
View File
@@ -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:
-26
View File
@@ -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 "$@"
-5
View File
@@ -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.
-31
View File
@@ -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.
-16
View File
@@ -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:
-9
View File
@@ -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
-75
View File
@@ -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.
-37
View File
@@ -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)
-103
View File
@@ -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).
-18
View File
@@ -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.
-39
View File
@@ -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. |
-9
View File
@@ -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.
-4
View File
@@ -1,4 +0,0 @@
BETTER_AUTH_SECRET=
ENCRYPTION_SECRET=
GITEA_MIRROR_URL=https://gitea-mirror.example.com
HTTP_IDLE_TIMEOUT=3600
-66
View File
@@ -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 |
-21
View File
@@ -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:
-3
View File
@@ -1,3 +0,0 @@
POSTGRES_PASSWORD=gitea
GITEA_ROOT_URL=https://gitea.example.com/
GITEA_CLONE_TIMEOUT=3600
-56
View File
@@ -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 |
-45
View File
@@ -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:
-29
View File
@@ -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
-121
View File
@@ -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.
-69
View File
@@ -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:
-19
View File
@@ -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=
-120
View File
@@ -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.
-25
View File
@@ -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:
-41
View File
@@ -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=
-4
View File
@@ -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
-83
View File
@@ -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.
-84
View File
@@ -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:
-156
View File
@@ -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"
-23
View File
@@ -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
-85
View File
@@ -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.
-72
View File
@@ -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:
-15
View File
@@ -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=
-49
View File
@@ -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.
-20
View File
@@ -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:
-51
View File
@@ -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=
-4
View File
@@ -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
-95
View File
@@ -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.
-71
View File
@@ -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:
-9
View File
@@ -1,9 +0,0 @@
{
"gateway": {
"mode": "local",
"bind": "lan",
"port": 18789,
"publicOrigin": "${OPENCLAW_PUBLIC_ORIGIN}",
"trustedProxies": ["${OPENCLAW_TRUSTED_PROXIES}"]
}
}
-13
View File
@@ -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
-70
View File
@@ -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.
-28
View File
@@ -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:
-17
View File
@@ -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=
-76
View File
@@ -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.
-72
View File
@@ -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:
-29
View File
@@ -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=
-20
View File
@@ -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
View File
@@ -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.
-23
View File
@@ -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:
-53
View File
@@ -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 "$@"
View File
Whitespace-only changes.
-6
View File
@@ -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=
-22
View File
@@ -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.
-7
View File
@@ -1,7 +0,0 @@
services:
traffmonetizer:
image: 'traffmonetizer/cli_v2:arm64v8'
restart: always
command: 'start accept --token ${TOKEN}'
environment:
- 'TOKEN=${TOKEN}'
-13
View File
@@ -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
-4
View File
@@ -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
-79
View File
@@ -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.
-23
View File
@@ -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: