Compare commits

..
203 Commits
Author SHA1 Message Date
tiennm99 938248d84f feat(code-server): add Chromium and a 1 GB /dev/shm for headless browser automation 2026-10-14 21:57:33 +07:00
tiennm99 7a0a5384ee feat(code-server): add command-line utilities to the image
Install network, DNS, search, lint, database-client and maintenance tools
from Debian so every fresh deploy has them without a setup step.
2026-10-12 20:00:32 +07:00
tiennm99 5b42dc2505 chore: remove code-server-lsio, collabora, onlyoffice and seafile
Drop the four service directories and their rows from the root index, and
replace code-server-lsio in the naming, workspace and environment rules with
a generic example.
2026-10-12 11:28:51 +07:00
tiennm99 fedff373fc docs: keep composes to common third-party services 2026-10-11 11:08:34 +07:00
tiennm99 b3861f4c2f feat(gitea): move the database to PostgreSQL 18 2026-10-11 09:26:34 +07:00
tiennm99 8b33bd9135 feat(owncloud): upgrade to ownCloud 11 with MariaDB 10.11 and Redis 7 2026-10-11 09:20:28 +07:00
tiennm99 87604a2bd7 fix(couchbase): use the community edition image 2026-10-11 03:17:10 +07:00
tiennm99 51dea06fdd feat: add nextcloud, seafile, opencloud, collabora and onlyoffice services 2026-10-11 03:17:10 +07:00
tiennm99 9259273668 docs(skills): use the single miti-coolify MCP for both servers
miti-sg's Coolify now manages miti-jp too, so the skills search one MCP
server instead of two. Drop the stale gitea-mirror watch-path exception;
the new miti-jp app has its watch path set.
2026-10-11 02:33:02 +07:00
tiennm99 e6553e371a chore(skills): move gitea-mirror-maintenance to the workspace skills 2026-10-08 08:04:01 +07:00
tiennm99 c040eb25cf feat(gitea): disable open registration 2026-10-08 07:59:47 +07:00
tiennm99 3ea1b39e44 refactor: name supporting services and volumes by role
Rename database services to db and cache services to cache, with their
volumes as db-data and cache-data, in litellm, owncloud and goclaw. Rename
the couchbase and open-webui volumes to <service>-data. Data on miti-sg was
copied into the new volumes beforehand; the old volumes are kept.
2026-10-07 19:26:27 +07:00
tiennm99 29f52dfcb3 docs(hermes): record cron.wrap_response off so deliveries carry only the job output 2026-10-07 17:48:10 +07:00
tiennm99 672c686e5a docs(hermes): keep untagged group messages as context for mention-only replies 2026-10-07 17:09:33 +07:00
tiennm99 ac973a782f docs: record watch-path exceptions and Coolify env override precedence 2026-10-07 09:56:54 +07:00
tiennm99 fa4339562c docs(webtop): PASSWORD is the web login only, not a sudo password 2026-10-06 17:45:22 +07:00
tiennm99 e489b96793 docs: allow short main service names for verbose or generic images 2026-10-06 17:45:22 +07:00
tiennm99 59f8d94b97 docs: split agent rules into .claude/rules and list example role names
CLAUDE.md keeps the overview and deployment rules; naming, images and
comments, workspace services, and environment and secrets move to topic
files that load with it. Naming gains example role names for supporting
containers, as examples rather than a fixed list.
2026-10-06 17:31:31 +07:00
tiennm99 09506ae84f docs(webtop): abc can use docker without sudo
The universal-docker mod adds abc to the socket's group before the
desktop starts as abc.
2026-10-06 17:22:20 +07:00
tiennm99 bfd9fdbfd3 refactor(paseo): install gh, glab, uv and Python into home
Drop them from the image; the README gives the commands to install each
into ~/.local/bin on the home volume. System packages stay in the image.
2026-10-06 17:22:20 +07:00
tiennm99 7aafbd2d8b docs: name supporting services by role and track renames
Supporting containers are db, cache or a short role name; services
declare no networks since Coolify creates one per app. TODO lists the
services whose names still differ.
2026-10-06 17:22:20 +07:00
tiennm99 f38a3a2871 docs: revise install location and naming rules
Workspace services install tools into the home volume unless the package
is more than a binary. Prefer official images and treat upstream compose
files as a starting point. Name volumes <service>-<content> and networks
<main service>-network.
2026-10-06 17:09:38 +07:00
tiennm99 d889338306 fix(alloy): drop docker log lines older than Loki accepts
loki.source.docker resumes from a one-second position and Docker's since
is inclusive, so each restart re-reads a container's last logged second.
For idle containers those lines are older than 7 days and Grafana Cloud
Loki rejects the batch with 400 timestamp too old. Drop them before
loki.write; Loki would not have stored them anyway.
2026-10-06 16:56:46 +07:00
tiennm99 feb6f035c8 fix(alloy): mount /run/udev for diskstats device properties
node-exporter's diskstats collector reads udev device properties from
/run/udev/data; without the mount it logs an error and drops those labels.
2026-10-06 16:50:22 +07:00
tiennm99 fc85962f45 fix: restore HOST override in zsh workspace services
Coolify injects HOST=0.0.0.0 and zsh's prompt reads $HOST instead of the
real hostname, so hostname: alone shows user@0. Set HOST=${SERVICE_HOSTNAME}
again in code-server, code-server-lsio and paseo.
2026-10-06 16:45:13 +07:00
tiennm99 56111e9219 fix(alloy): pass the Grafana token as GCLOUD_RW_API_KEY
Fleet Management's self-monitoring pipelines read the token from that name;
without it their remote-write fails with 401 invalid token.
2026-10-06 16:45:13 +07:00
tiennm99 422eab8499 chore: drop HOST overrides, move known issue to docs, tidy comments and gitignores
- Remove HOST=${SERVICE_HOSTNAME} from code-server, code-server-lsio and paseo; hostname: alone sets the name
- Move SERVICE_HOSTNAME to the top of their .env.example to match compose order
- Drop the openclaw ARM64 Chromium note from its README; docs/openclaw covers it
- List CUSTOM_USER in webtop's setup step
- Trim reasoning from diun and paseo comments
- Delete stale gitea and gitea-mirror .gitignore files
- Add TODO.md for remaining naming and README issues
2026-10-06 16:32:32 +07:00
tiennm99 042d6bd6c0 chore: drop container labels from cadvisor, require webtop CUSTOM_USER, trim env template comments 2026-10-06 16:19:14 +07:00
tiennm99 4c5c6ed17e chore: name couchbase's service after its image 2026-10-06 16:05:42 +07:00
tiennm99 e4c74ef5cd chore: align alloy, diun and couchbase with repo conventions
alloy: move to latest tags (no moving major tag upstream), trim comments to what each line does.
diun: order .env.example like compose.yml.
couchbase: add .env.example, drop the version key, explain the published ports.
2026-10-06 16:03:37 +07:00
tiennm99 0637a02051 chore: drop container_name from couchbase 2026-10-06 15:57:40 +07:00
tiennm99 272db135b7 chore: drop container_name from alloy and traffmonetizer 2026-10-06 15:57:10 +07:00
tiennm99 98b0924ca7 chore: drop traffmonetizer version key, document its restart policy, add alloy .env.example 2026-10-06 15:55:36 +07:00
tiennm99 ee2f92de19 refactor(code-server): install Docker CLI, plugins and gh into home
- Drop the Docker and GitHub apt repositories and the Ruby build headers
  from the image; keep build-essential, bubblewrap, zip and unzip.
- Document home installs for the Docker CLI with its Compose and Buildx
  plugins, gh, Go, Rust, Python, Node and Java, ordered so each installer
  writes to ~/.zshrc and SDKMAN stays last.
- Drop the Ruby, GitLab CLI and jq instructions.
2026-10-06 14:43:56 +07:00
tiennm99 c1ef9e7680 feat(code-server): install Docker CLI and gh from vendor apt repos
- Install the Docker CLI with its Compose and Buildx plugins, and the GitHub
  CLI, from Docker's and GitHub's signed apt repositories; drop their home
  installs and the GitLab CLI from the README.
- Select the shell with SHELL and the start folder with DEFAULT_WORKSPACE
  instead of chsh and working_dir.
- Add an optional CODE_SERVER_APP_NAME.
- Document installing Java with SDKMAN.
2026-10-06 13:39:31 +07:00
tiennm99 03a2016f23 fix(code-server-lsio): require PASSWORD and correct the Docker group note 2026-10-06 13:39:26 +07:00
tiennm99 4c37d45377 feat(code-server): join the Docker socket's group at startup and add build tools
Add an entrypoint wrapper that reads the socket's GID, adds coder to a
matching group and re-execs the image's entrypoint under it, so docker
works without sudo on any host. Install build-essential and the headers
rbenv needs to build Ruby, and make zsh the login shell. Document home
installs for rbenv, Docker Compose and Buildx plugins, and jq.
2026-10-06 12:23:22 +07:00
tiennm99 56b1c5f931 fix(code-server): pass SERVICE_HOSTNAME as HOST so the zsh prompt shows the hostname 2026-10-06 11:11:39 +07:00
tiennm99 088579890a feat(code-server): add code-server from the official codercom image 2026-10-06 10:19:29 +07:00
tiennm99 18935e01a7 refactor: rename code-server-linuxserver to code-server-lsio and set naming rules for compose names 2026-10-06 08:58:11 +07:00
tiennm99 a7137fcdc4 refactor: rename the code-server service directory to code-server-linuxserver 2026-10-06 08:49:38 +07:00
tiennm99 85b8a5b1ef docs(hermes): document replying only when tagged in Telegram groups 2026-10-06 08:49:35 +07:00
tiennm99 614875f295 fix(gitea-mirror): run the health check every 5 minutes to stay under GitHub's anonymous rate limit 2026-10-05 18:44:51 +07:00
tiennm99 beaf805dcc refactor(gitea-mirror): name the Bun idle timeout variable HTTP_IDLE_TIMEOUT 2026-10-05 18:35:05 +07:00
tiennm99 82892042dc fix(hermes): configure chat platforms in /opt/data/.env, not Coolify
The gateway's allow-lists read only the profile .env under the default
multiplex mode, so Telegram values passed from the environment left every
user blocked. Drop the passthrough and document the dashboard as the one
place to set them, plus the dashboard secret's 16-byte minimum.
2026-10-05 17:35:34 +07:00
tiennm99 d4256617f4 feat(openclaw): set the startup env doctor recommends on ARM hosts
Add NODE_COMPILE_CACHE and OPENCLAW_NO_RESPAWN, and record the remaining
doctor notes and the ARM64 browser path override in docs/openclaw.
2026-10-05 16:33:27 +07:00
tiennm99 0a7e8f9783 feat(gitea-mirror): split gitea-mirror out of the gitea stack into its own service 2026-10-05 15:59:04 +07:00
tiennm99 5caa497d7f refactor: rename the gitea-mirror service directory to gitea 2026-10-05 15:54:03 +07:00
tiennm99 d0eb7c8aa9 docs: fix stale references after removing services 2026-10-05 14:54:37 +07:00
tiennm99 d5d7121180 chore: remove the opencode service 2026-10-05 14:52:03 +07:00
tiennm99 2831651bd2 fix(owncloud): give redis a named data volume, as upstream does 2026-10-05 14:52:03 +07:00
tiennm99 1cbbf271ce refactor: align openclaw and hermes with their official Docker guides 2026-10-05 14:52:03 +07:00
tiennm99 77c12d5397 feat(hermes): pass Telegram gateway settings through 2026-10-05 14:18:36 +07:00
tiennm99 0d0a48221b refactor: switch openclaw and hermes to their official images 2026-10-05 13:22:01 +07:00
tiennm99 8d4c3d14a4 feat: add openclaw and hermes agent templates 2026-10-05 11:22:18 +07:00
tiennm99 7616ff9f89 feat(owncloud): add ownCloud with MariaDB and Redis 2026-10-05 10:49:47 +07:00
tiennm99 0b660eb106 feat(webtop): add dev toolchain mods and Docker access 2026-10-05 10:26:55 +07:00
tiennm99 1bda841d1c chore(webtop): default CUSTOM_USER to miti99 2026-10-05 10:00:22 +07:00
tiennm99 9cc483f63b feat(webtop): add Ubuntu XFCE web desktop 2026-10-05 09:54:03 +07:00
tiennm99 8bd14da5ca feat(open-webui): add Open WebUI chat interface 2026-10-05 09:03:35 +07:00
tiennm99 40a234e2ff fix(litellm): build config.yaml into the image instead of bind-mounting it 2026-10-05 00:21:32 +07:00
tiennm99 8291a3408e feat(litellm): add LiteLLM proxy with PostgreSQL and Redis 2026-10-04 23:08:09 +07:00
tiennm99 f544979a91 feat: add migrate-service skill for moving Coolify deployments onto this repo 2026-10-04 22:24:09 +07:00
tiennm99 dd7c951143 chore: drop required-env header comments from compose files 2026-10-04 21:53:29 +07:00
tiennm99 01102bdf06 refactor: comment out optional env vars in diun and code-server 2026-10-04 21:53:29 +07:00
tiennm99 5844ef5611 refactor(goclaw): comment out optional MiniMax and trace env vars 2026-10-04 21:50:15 +07:00
tiennm99 1d2010bbe4 docs: list optional env vars commented out in compose files 2026-10-04 21:50:15 +07:00
tiennm99 acb3628dc8 feat(goclaw): pass through auto-upgrade and MiniMax env, add healthcheck 2026-10-04 21:42:01 +07:00
tiennm99 a0b5e15d9d chore: ignore local plans directory 2026-10-04 18:25:07 +07:00
tiennm99 198329c008 docs: move service issue notes from READMEs into docs/<service>
Service READMEs now cover only what the service is and how to deploy it.
Known issues, log noise and troubleshooting move to docs/<service>/, named
after the service directory, so editing them never redeploys the service.
Drop alloy's validate workflow, which never ran from a subdirectory.
2026-10-04 09:46:08 +07:00
tiennm99 b6952d84df refactor: move gitea-mirror maintenance skill to the repo root
A skill under gitea-mirror/ redeployed the stack on every edit, since the
Coolify watch path is gitea-mirror/**, and only loaded once a session touched
that directory. Service directories now hold deploy files only; skills live
in the root .claude/skills/.
2026-10-04 09:26:22 +07:00
tiennm99 e92ed7914d feat(gitea-mirror): clean up mirrors left by renamed and deleted repos
Add cleanup-renamed-repos.sh, which groups Gitea mirrors and gitea-mirror
rows by GitHub repo id, keeps the copy at the current name (renaming a
case-only mismatch in place), deletes the rest and re-imports.

cleanup-archived-repos.sh now deletes every mirror whose GitHub source 404s
when the source belongs to the gh user or an org it administers, keeps
third-party ones, and aborts on a GitHub rate limit.
2026-10-04 09:06:15 +07:00
tiennm99 bd0fe78994 fix(gitea-mirror): keep gitea-mirror waiting as long as Gitea clones
Gitea's migrate API stays silent until the clone ends, and Bun's fetch
drops a connection idle for 5 minutes. gitea-mirror then marked large
repositories failed while Gitea kept cloning, and a later retry took the
half-made repository for a finished mirror. GITEA_CLONE_TIMEOUT now also
sets BUN_CONFIG_HTTP_IDLE_TIMEOUT.
2026-10-03 13:38:02 +07:00
tiennm99 efd0848143 feat(gitea-mirror): add cleanup of archived copies of deleted repos
Deletes the archived-* Gitea copies gitea-mirror keeps when a source in the
given owners disappears, and removes their tracking rows so they are not
re-mirrored. Dry run by default.
2026-10-03 13:09:05 +07:00
tiennm99 4576b4dfd5 fix(gitea-mirror): match failed mirrors by source name in detection
gitea-mirror clears mirroredLocation when a mirror fails, so failed repos
were dropped from the status map and reported as status unknown.
2026-10-03 13:09:05 +07:00
tiennm99 9a9e4cddf7 refactor(gitea-mirror): run mirror maintenance through tea and the gitea-mirror API
The skill reached Gitea on localhost and the mirror database inside the
container, which only worked on a local host. It now uses tea for Gitea
and the gitea-mirror API key for repository status and retries, in bash.
Private upstreams are no longer probed anonymously, which reported them
as deleted.
2026-10-03 11:23:32 +07:00
tiennm99 a84e158738 feat(gitea-mirror): raise the Gitea clone timeout to one hour
Gitea's default migrate and fetch timeouts cut multi-gigabyte mirrors off
mid-clone. GITEA_CLONE_TIMEOUT sets both, defaulting to 3600 seconds.
2026-10-03 11:23:32 +07:00
tiennm99 d4882c6f3e fix(gitea-mirror): read auth and encryption secrets from the environment
Data encrypted under one secret is unreadable under another, so the
secrets must move with the data instead of being regenerated by the image.
2026-10-03 10:07:34 +07:00
tiennm99 3c737b05cd chore: add sources dir and debug-service skill, make Coolify primary
sources/ holds gitignored upstream checkouts for debugging a service
against its real code; the debug-service skill walks through it. Coolify
is now the primary deployment target and Dokploy optional.
2026-10-03 09:58:52 +07:00
tiennm99 67da7cd870 feat(gitea-mirror): serve gitea and gitea-mirror through the proxy
Drop the localhost-bound ports, read the database password and both
public URLs from the environment, pin gitea to its major tag, add a gitea
health check and disable gitea's SSH server.
2026-10-03 09:58:52 +07:00
tiennm99 b9042fb191 refactor: rename remaining docker-compose.yml files to compose.yml 2026-09-30 15:46:52 +07:00
tiennm99 ea5079fd4e feat(opencode): pass OPENCODE_API_KEY for OpenCode Go and Zen 2026-09-30 15:46:52 +07:00
tiennm99 4c66ec76e6 docs: keep each service README inside its own directory
A service README now describes only its own service: no links to other
services or to the root, and no restating of the shared conventions that
the root README and CLAUDE.md already carry. Each service is a separate
Coolify app on a <service>/** watch path, so a cross-link made editing one
service redeploy another. The rule is recorded at the root; the alloy
compose comment now points at a heading that exists.
2026-09-29 20:00:15 +07:00
tiennm99 daf18abf70 refactor(opencode): rename service directory from opencode-web to opencode 2026-09-29 19:36:11 +07:00
tiennm99 d211e42a4a chore(code-server): install bubblewrap 2026-09-28 15:54:27 +07:00
tiennm99 1d98f2ea95 chore(diun): check for updates once a day at midnight UTC 2026-09-21 08:41:48 +07:00
tiennm99 9da8546d19 feat(goclaw): add multi-tenant AI agent gateway with pgvector postgres 2026-09-20 14:34:57 +07:00
tiennm99 c2b508c171 chore: set restart: unless-stopped on every service
Coolify injects the same value when a compose service omits one, but Dokploy
runs the file as written, so in its default compose mode an omitted policy
leaves the container down after a crash or a host reboot.
2026-09-20 14:34:57 +07:00
tiennm99 b626c5b70d chore(diun): track moving image tags instead of exact pins
Diun follows the upstream major tag 4; the socket proxy follows latest,
since that project publishes no moving major tag.
2026-09-20 12:50:04 +07:00
tiennm99 ad122ab9f2 chore: drop the openhands service
Remove the directory and its row from the root table, the related-services link
in opencode-web, and the workspace-volume convention's reference to it.
2026-09-19 17:44:55 +07:00
tiennm99 24cefe80a2 feat(diun): add diun template reading the Docker API through a proxy
Diun needs the Docker API to enumerate containers and inspect each one's
image. Mounting the socket into it directly is host-root-equivalent, and :ro on
a socket mount is cosmetic, so the socket goes into a docker-socket-proxy
sidecar and Diun reaches it at tcp://dockerproxy:2375. POST is revoked there,
so container create and exec return 403.

CONTAINERS and IMAGES are both required: with CONTAINERS alone the provider
loads and enumerates containers, then every ImageInspect returns 403 and
nothing is analysed. Verified against a live watch cycle — 25 images analysed,
no errors.

Pin crazymax/diun:4.33 rather than :latest, since this is the service whose job
is to talk to the daemon.
2026-09-19 17:18:31 +07:00
tiennm99 c2502d5f44 chore: drop ollama, netdata, per-service licenses and stale plans
Rename gitea-mirror-local to gitea-mirror.
2026-09-19 12:43:05 +07:00
tiennm99 e6dcdfef48 docs(plans): add openhands/opencode-web plan and paseo docs audit 2026-09-18 17:43:03 +07:00
tiennm99 14a5720f7a chore: drop code-server-base
code-server covers the remaining use.
2026-09-18 17:38:07 +07:00
tiennm99 a63c121075 feat(code-server): give the workspace its own volume
The workspace moves off the config volume onto code-server-workspace, so
wiping editor state and wiping code are separate acts.

The image only ever chowns the literal path /config/workspace, and reads
DEFAULT_WORKSPACE to pick the folder to open, so a named volume on /workspace
would come up root-owned and unwritable. Creating the directory in a local
Dockerfile seeds the volume with the right ownership instead.
2026-09-18 17:38:07 +07:00
tiennm99 ff9ec68b0b fix(alloy): drop read_only so the inline compose config can be created
Compose materialises a configs: entry with inline content by writing it into
the container and refuses to do so on a read-only service: "cannot create
config ... : `file` is the sole supported option". The container was created
without /etc/alloy/config.alloy and the deployment failed at start.

Keeping the config inline matters more than the read-only rootfs, so the flag
and its tmpfs go. Every other control stays: no privileged, cap_drop ALL with
only DAC_OVERRIDE added, no-new-privileges, the socket proxy, and the limits.
2026-09-18 17:36:12 +07:00
tiennm99 0ef059dc31 refactor(alloy): drop privileged and proxy the docker socket read-only
Replace privileged: true with cap_drop ALL plus DAC_OVERRIDE, no-new-privileges,
a read-only rootfs and memory/pid limits. DAC_OVERRIDE is what lets the uid-0
entrypoint create its storage directory and read the journal and /rootfs; every
other capability stays dropped.

Route prometheus.exporter.cadvisor, discovery.docker and loki.source.docker
through a docker-socket-proxy sidecar on 127.0.0.1:2375 instead of bind-mounting
the socket. POST is refused there, so container create and exec are no longer
reachable. NETWORKS is granted because Docker SD resolves network names per
container and returns no targets without it.

Add an alloy validate step to CI and boot the test container with the shipped
capability set, read-only rootfs and proxy rather than --privileged.
2026-09-18 17:28:08 +07:00
tiennm99 99f4f8b820 feat: add openhands and opencode-web services
openhands runs each agent session in a container it spawns through the host
docker socket, so the socket is mounted read-write and host.docker.internal is
resolved. No host workspace is exposed.

opencode-web serves the opencode agent as a browser UI. The vendor image ships
only the opencode binary on bare Alpine, so a local Dockerfile adds bash, git,
curl and an ssh client.
2026-09-18 17:04:04 +07:00
tiennm99 3574c55e0a chore(code-server): mount the docker socket read-only
The flag does not restrict Docker API access; the README says so.
2026-09-18 16:40:01 +07:00
tiennm99 a82cfcc677 refactor(paseo): leave the home chown to the base entrypoint
The base image ships /home/paseo owned by uid 1000 and declares it a
volume, so a fresh named volume is seeded with that ownership, and the
base entrypoint chowns it and the agent config directories when they are
not.
2026-09-18 14:36:53 +07:00
tiennm99 c20aed29cb feat(paseo)!: drop go and sdkman, rename AGENT_CLIS to AGENTS
The image installs python, gh, glab, build-essential, sudo, zsh and nano
only; go or a jvm goes into $HOME from a terminal instead.

SHELL and TZ are written into compose.yml rather than read from .env, so
the deploying shell's own values can no longer win over them.

The entrypoint calls chpasswd, chown and gosu by absolute path, tolerates
a chpasswd failure instead of taking the start down with it, turns
globbing off around the agent loop, and counts an agent as installed only
when its binary actually runs.
2026-09-18 11:39:09 +07:00
tiennm99 ac00eb9674 style: order environment variables by how much the service needs them
environment: blocks were in no particular order. They now run must-have ->
should-have -> optional, with related variables kept adjacent as a group that
takes the tier of its most important member: PUID/PGID, PASSWORD with
SUDO_PASSWORD, DOCKER_MODS ahead of the INSTALL_PACKAGES and
NODEJS_MOD_VERSION that configure it, the four GIT_* entries, the PASEO_*
daemon settings.

Each .env.example is reordered to match its compose file. The names do not map
one to one -- PASSWORD feeds both PASSWORD and SUDO_PASSWORD, SERVICE_HOSTNAME
feeds HOST -- so an entry sits where the first compose entry reading it sits.

The HOST comment in both compose files is dropped; the READMEs already carry
that explanation in full. CLAUDE.md records the ordering convention.

alloy and gitea-mirror-local are untouched: every variable there is required,
so the tiers collapse and the existing grouping is the better one.
2026-09-18 10:47:56 +07:00
tiennm99 ea46992e64 chore(code-server): drop the pnpm mod and trim installed packages
pnpm is not used anywhere -- npm is the package manager everywhere -- so the
mod that installs it is dead weight on every container start.

INSTALL_PACKAGES loses apache2-utils, bfs, ffmpeg, imagemagick, librsvg2-bin,
lsof, psmisc and ugrep, and gains glab. Each entry is re-resolved by apt on
every start, so the list is kept to what is actually reached for.
2026-09-18 10:47:46 +07:00
tiennm99 6924ba0424 docs: keep personal values out of the examples
Record the convention in CLAUDE.md: .env.example is a template, so its values
stay generic and the real ones are set per deployment in Coolify or Dokploy.
Note the naming trap alongside it -- Compose interpolation reads the deploying
shell's environment before the .env file, so a variable must not collide with
one the shell already exports.

The alloy README's example host is made generic to match.
2026-09-18 09:38:35 +07:00
tiennm99 2a4e635e09 fix: show the container hostname in the shell prompt
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().
The prompt read "0", the first dot-separated field of 0.0.0.0, even though the
container hostname itself was set correctly.

Pass the hostname in as HOST alongside the hostname: key, both from a single
SERVICE_HOSTNAME variable. Neither service reads HOST itself -- code-server
binds [::]:8443, Paseo binds PASEO_LISTEN -- so this only affects the prompt.

Not named HOSTNAME: Compose interpolation lets the deploying shell's
environment win over the .env file, and HOSTNAME is set in every container,
including the one Coolify runs in.

PASEO_LABEL is renamed to SERVICE_HOSTNAME; it was never a Paseo variable.
The example git identity is blanked out along with it.
2026-09-18 09:35:04 +07:00
tiennm99 f02de334e5 feat(paseo): install SDKMAN on start, and rename the wrapper entrypoint
SDKMAN goes into ~/.sdkman whenever that directory is missing, the same way
the agent CLIs arrive: as paseo, through gosu, onto the volume, where `sdk
install` can write and the candidates persist. No variable gates it.

The chown of /home/paseo moves out of the AGENT_CLIS guard, since SDKMAN now
needs it even when no agent is named, and the agent loop reads AGENT_CLIS into
a local first so `set -u` does not trip on it being unset.

SDKMAN_DIR is set in the image, and the current/bin of java, scala, gradle,
maven and sbt joins PATH: `sdk` itself is a shell function from the rc hook and
so exists in terminals only, while the daemon and an agent's non-interactive
commands read no rc file and need the binaries on PATH.

paseo-sudo-entrypoint was named for the one thing it used to do. It is
/usr/local/bin/entrypoint now, matching the source file.
2026-09-17 14:21:25 +07:00
tiennm99 07991dabaf feat(code-server): mount the host docker socket
Bind-mount /var/run/docker.sock so the universal-docker mod's CLI has a
daemon to talk to, and document the sibling-container and permission
caveats in the service README.
2026-09-17 14:20:57 +07:00
tiennm99 f09325b2fc feat(code-server-base): add a stock code-server template
The LinuxServer image as it ships, with no mods and no extra packages,
as the starting point for a new instance.
2026-09-17 13:16:20 +07:00
tiennm99 feaf4b7e4c feat(paseo): install the agent CLIs named in AGENT_CLIS on start
The entrypoint runs each vendor's installer through gosu paseo for every name
in AGENT_CLIS whose command is not already on PATH, so a fresh paseo-home
volume comes up with agents ready. Defaults to claude and codex; the other
four are opt-in.

On start rather than in the Dockerfile: Docker seeds a named volume from the
image once, at creation, so a build-time install into /home/paseo would only
ever reach a volume that did not exist yet. The check also chowns /home/paseo
first, since a freshly created volume can arrive owned by root and the base
entrypoint has not run its own chown by then.

Unknown names and failed installs are logged and skipped rather than taking
the container down with them.

entrypoint.sh loses its explanations to the README along the way.
2026-09-17 10:36:51 +07:00
tiennm99 696c314726 docs: extend the comment rule to every file in a service directory
It was scoped to compose files and Dockerfiles, which left scripts out.
2026-09-17 10:36:51 +07:00
tiennm99 f39ca227ce feat(paseo): put the agent CLI install dirs on the image PATH
The daemon probes each provider's binary with `which` in its own
environment, which comes from the image and never sources a shell rc, so
agents installed into $HOME were reported unavailable.
2026-09-17 10:09:02 +07:00
tiennm99 55e8f7321d feat(paseo): install glab, and stop preinstalling the agent CLIs
The paseo user cannot write /usr/local, so a baked-in agent CLI can never
apply its own update — every one of them ships an updater that expects to
rewrite its own binary, and Claude Code nags about the failure at startup.
Installed under $HOME they update themselves and still persist, since that is
the paseo-home volume. The README now lists each vendor's installer.

Bun goes with them: it was only there because omp is compiled against it.

glab arrives as the .deb from GitLab's releases page, pinned by GLAB_VERSION
because the URL carries the version. GitLab runs no apt repository and calls
Homebrew its only officially supported Linux package manager.

The Dockerfile drops its explanations along the way; they are in the README.
2026-09-17 09:44:12 +07:00
tiennm99 044d9b5594 docs: keep rationale in READMEs, not in compose files and Dockerfiles
Comments say what a section installs or configures. Why not the distro
package, why that directory, why a version is pinned — that belongs in the
service README, where it can be read in full.

Replaces the line under "Installing software in an image" that asked for the
opposite.
2026-09-17 09:44:12 +07:00
tiennm99 92d24c8e64 feat(paseo): preinstall the build-essential C toolchain
The image had no compiler at all -- gcc, g++, make, cc and ld were all
missing -- so cgo, npm's node-gyp addons and Python C extensions could
not build. Goes in the existing apt layer to keep a single apt-get
update.
2026-09-16 22:55:56 +07:00
tiennm99 aa02fd2f5b chore: drop the openvpn-as and tastyigniter services
Remove both directories and their rows from the services table.
2026-09-16 20:57:04 +07:00
tiennm99 23b9bf8bf6 docs: describe current state only, and fill in the stub READMEs
Drop the history and roadmap asides: which services predate the collection's
conventions, the unwired Open Web UI plan, the generic clone-and-troubleshoot
boilerplate. Services that publish ports or set `restart:` now simply say so.

couchbase, openvpn-as and traffmonetizer had two-line READMEs; give them the
ports, variables and storage the root README promises. traffmonetizer reads
${TOKEN} and had no .env.example, so add one.
2026-09-16 20:45:18 +07:00
tiennm99 04ccc93969 feat(paseo): add Copilot, Pi and Oh My Pi agent CLIs
Six agents now ship in the image. All come from npm; omp additionally needs
Bun, since its bin is Bun-compiled and opens with `#!/usr/bin/env bun`.
BUN_INSTALL puts Bun in /usr/local, clear of the paseo-home volume.
2026-09-16 20:40:39 +07:00
tiennm99 65ec872e62 feat(paseo): add Codex and opencode-ai agent CLIs 2026-09-16 20:35:00 +07:00
tiennm99 d6f7eb46cd feat(paseo): preinstall nano and set the git identity from the environment
GIT_NAME and GIT_EMAIL expand into the four GIT_AUTHOR_*/GIT_COMMITTER_*
variables, the same shape code-server already uses. Passing the identity as
environment rather than running `git config` means agents and terminals commit
correctly with no setup step, and it does not depend on ~/.gitconfig surviving
in the /home/paseo volume.
2026-09-16 17:57:16 +07:00
tiennm99 cb1d38f147 feat(paseo): give the paseo user sudo, authenticated with PASEO_PASSWORD
The base image installs no sudo and leaves paseo out of the sudo group, so
add both. The password cannot be baked in at build time -- it is a secret and
would land in a layer -- and it cannot be set after the base entrypoint, which
ends in `exec gosu paseo` and never returns. Wrap that entrypoint instead and
set the password while still root, on every start: /etc/shadow lives in the
image, not on the /home/paseo volume, so it reverts on each recreate.
2026-09-16 17:21:11 +07:00
tiennm99 914474953f feat(paseo): select the terminal shell through SHELL
Paseo spawns terminals with process.env.SHELL and falls back to /bin/sh,
which is dash. It never consults the login shell, so chsh has no effect --
and would be reverted by the next rebuild regardless.
2026-09-16 16:35:49 +07:00
tiennm99 b796f098ca refactor(paseo): split the Dockerfile into one layer per concern
System packages, Python, Go, gh and the agent CLIs each get their own
block. Ordered least-changing first, so a Claude Code bump no longer
re-runs the toolchain installs.
2026-09-16 16:30:47 +07:00
tiennm99 5a4348cde7 feat(paseo): add Go, Python, shell tooling, hostname and timezone
Go 1.26.8 from the official tarball and Python 3.12 via uv, since Debian
12 carries 1.19 and 3.11. Both land outside $HOME, which the paseo-home
volume would otherwise mask.

Hostname and timezone come from the environment rather than being fixed in
the compose file. Paseo uses the container hostname as the host label in
its web UI, so without one the UI shows a random container ID.
2026-09-16 16:24:26 +07:00
tiennm99 6590c59f40 feat(paseo): install the gh CLI
Debian does not package gh, so use GitHub's signed apt repo. Config
lands in /home/paseo/.config/gh, inside the paseo-home volume, so the
login survives a redeploy.
2026-09-16 15:59:41 +07:00
tiennm99 341469ff43 docs(paseo): add setup steps and tighten the README
The pairing screen needs an explicit port, which is the least obvious
part of getting connected. Lead with the setup steps and cut the
explanation around them down to what a reader has to act on.
2026-09-16 15:49:30 +07:00
tiennm99 5edb865597 fix(paseo): trust the platform proxy so the web UI can connect
The daemon trusts X-Forwarded-Proto from loopback only by default, but
Coolify's Traefik reaches it from the Docker bridge network. It therefore
reported the request as plain HTTP and handed the UI useTls: false, so the
UI built a ws:// URL on an https:// page. The browser blocked it as mixed
content and the UI fell back to its built-in localhost:6767 default.

uniquelocal covers the private ranges Docker uses. An exact CIDR is
tighter but Coolify assigns a fresh subnet per project.
2026-09-16 15:26:52 +07:00
tiennm99 c1e10c3663 feat(paseo): add paseo service with Claude Code preinstalled
The upstream image ships no agent CLIs, so build from a local Dockerfile
that layers Claude Code on top. npm delivers the same native binary as
the standalone installer, which cannot be used here: it writes to
$HOME/.local, and $HOME is /home/paseo, a volume mount that masks
anything baked in at build time.

Runs as root by design -- the entrypoint chowns the mounted volumes and
then drops to the unprivileged paseo user with gosu.
2026-09-16 15:06:01 +07:00
tiennm99 2de7bb68ab docs: record compose.yml naming convention for new services
New services take the current Compose spec filename. Existing
docker-compose.yml files stay as they are so unrelated changes do not
carry a rename.
2026-09-16 15:06:01 +07:00
tiennm99 41fe522c0d docs: point netdata related-links at sibling services 2026-08-18 16:35:19 +07:00
tiennm99 1c07109f56 docs: list all absorbed services in the services table 2026-08-18 16:22:29 +07:00
tiennm99 85b35e449d 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 34591b7e27 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 550d1d1741 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 0ab29485cb Initial commit 2026-08-14 08:57:46 +07:00
tiennm99 7a63d20424 feat: update 2025-03-25 22:04:48 +07:00
tiennm99 d1b1654cff feat: update 2025-03-15 07:48:27 +07:00
tiennm99 bf621b5315 Update docker-compose.yml 2025-03-15 07:38:20 +07:00
tiennm99 3e58b95b6c feat: update 2025-03-15 07:34:33 +07:00
tiennm99 12529023e1 feat: update 2025-03-15 07:19:10 +07:00
tiennm99 23501a2018 Update docker-compose.yml 2025-03-15 07:16:57 +07:00
tiennm99 247bb46f78 Update docker-compose.yml 2025-03-15 07:16:19 +07:00
tiennm99 fbcfaf47d8 fix: deploy fail 2025-03-15 06:54:24 +07:00
tiennm99 6481a83d2f feat: init 2025-03-15 06:46:20 +07:00
Tien Nguyen Minh 4148e42d36 Initial commit 2025-03-15 06:43:06 +07:00
tiennm99 df94b9f400 Clear and rewrite
Retire the initial setup; history continues from tastyigniter-docker-compose.
2026-08-04 22:51:17 +07:00
Tien Nguyen Minh b840934d09 [Add] sample docker-compose.yml 2024-11-30 11:33:17 +00:00
Tien Nguyen Minh 2079313d70 Initial commit 2024-11-30 18:25:45 +07:00
tiennm99 83e15050e3 Clear and rewrite
Retire the docker-run setup; history continues from traffmonetizer-docker-compose.
2026-08-04 22:42:34 +07:00
tiennm99 44d3d75546 fix: run tea outside git work tree so --login is honored
Invoke-Tea started its job in the caller's directory. When that is a git
work tree, tea infers the target from the local remote, and a remote that
matches no configured login makes it discard --login, fall back to the
first login, and fail with "remote repository required". Pin the job to a
neutral directory so the requested login always resolves.
2026-08-01 20:33:56 +07:00
tiennm99 0134b64cde feat: add gitea mirror maintenance skill
Adds a skill to audit the local Gitea mirror stack for repos whose pull
failed, then clean up only what is safe to delete.

Detection combines four signals, since none is sufficient alone: the Gitea
API (empty repos with no completed initial pull), an upstream reachability
probe, the mirror app database, and the gitea container log. The container
log reflects a retention window rather than history, so it is never the
sole basis for deletion.

Deletion is gated on a contradiction between Gitea and the mirror app:
a repo that is empty while the app records the pull as finished. Repos the
app is still cloning or has queued look identical by API fields alone
(empty, zero size, no mirror timestamp), so they are excluded to avoid
destroying work in progress. Repos that still hold content are reported
for retry and never deleted, so a transient fetch error cannot cost a
mirror.

Deleting a broken repo also resets its mirror-app row to pending;
without that the app never re-pulls it and the mirror is lost instead of
restored. Cleanup is dry-run by default and warns on a stale plan.
2026-07-26 19:43:50 +07:00
tiennm99 2a3c5ad89f chore: always pull latest gitea-mirror image 2026-07-25 23:51:02 +07:00
tiennm99 21de313dec docs: add README and sample environment values
Document the compose services, published ports, volumes, and first-run
setup. Fill .env.example with sample values and note that compose.yml
does not yet consume them.
2026-07-25 18:45:18 +07:00
tiennm99 25aba88d0c chore: add gitea mirror docker compose setup
Compose stack for Gitea with Postgres and gitea-mirror, plus an
environment template listing the required configuration keys.
2026-07-25 18:08:56 +07:00
tiennm99 872d9ba5ea fix: bump alloy to v1.16.1 and align CI validator image 2026-05-31 10:41:57 +07:00
tiennm99 bad7082ac3 docs(readme): add customization section and related cluster links 2026-05-11 21:45:31 +07:00
tiennm99 2752b8fb5a docs: expand README — features table, GPU notes, smoke test 2026-05-11 20:44:53 +07:00
tiennm99 28cf2bd1f9 docs: flesh out README 2026-05-11 20:15:20 +07:00
tiennm99 6e79c6851d docs: add README 2026-05-11 17:04:17 +07:00
tiennm99 40d7b0e3fc fix: ship cadvisor working_set/rss metrics so memory panels reflect real usage
container_memory_usage_bytes counts page cache attributed to the cgroup,
which makes disk-heavy containers (e.g. gitea) appear to use ~all host RAM.
Add container_memory_working_set_bytes (the metric Grafana's Docker
integration dashboard expects), plus container_memory_rss,
container_memory_cache, and container_spec_memory_limit_bytes for
breakdown and limit-percentage panels.
2026-04-29 09:59:19 +07:00
tiennm99 2a24eaf10b fix: drop explicit journal path and bump alloy to v1.16.0
`loki.source.journal "default"` no longer pins `path = "/var/log/journal"`.
Upstream omits the field, which lets Alloy default to BOTH
`/var/log/journal` (persistent) and `/run/log/journal` (volatile). The
explicit path silently dropped journal logs on hosts with volatile-only
storage. Matches the canonical Linux Node integration template.

Also bumps the image six minor versions to current stable. Doc records
the 2026-04-26 re-audit.
2026-04-26 10:15:29 +07:00
tiennm99 9399a8b115 feat: keep-list verbatim from each integration's Metrics section
Copies the exact 157-metric list from the Linux Node integration's
Metrics anchor as the cadvisor keep-list already does for Docker
(16 metrics). Replaces the earlier `drop node_scrape_collector_.+`
rule, which was the integration page's alternate snippet but didn't
ship the explicit allowlist users see in the docs.

`instance:node_num_cpu:sum` from the Metrics section is intentionally
omitted — it's a recording-rule output computed server-side by
Grafana Cloud's ruler, not produced by the agent.

Doc + README updated to point at the Metrics anchors directly so the
source of truth is unambiguous.
2026-04-26 09:54:43 +07:00
tiennm99 c1db79a359 docs: codify upstream-sources-only rule for config decisions
Adds docs/upstream-sources-of-truth.md as the binding policy for what
this repo follows when deciding metrics, labels, log pipelines, and
dashboards to ship.

Hard rule: only tier 1-4 official sources (Grafana Cloud integration
docs, github.com/grafana/*, github.com/prometheus/*, the user's own
authenticated Grafana Cloud API). No third-party Terraform exports,
community gists, blog posts, or AI summaries — even when names match.

Records a tier-1+2 audit confirming the current cadvisor allowlist
matches both the Docker integration page and grafana/jsonnet-libs
docker-mixin/docker.json. Notes that tier-4 verification against the
live stack's full integration dashboard set was not performed and is
the only known gap.
2026-04-26 09:50:20 +07:00
tiennm99 54ab1fed27 feat: align metric/log collection with upstream Grafana Cloud integrations
Linux-Node integration:
- replace curated keep-list of ~140 node_* metrics with the upstream
  drop rule (drops only node_scrape_collector_*); ships the full
  ~130+ metric set the integration dashboards expect.
- add loki.source.file for /var/log/{syslog,messages,*.log} alongside
  the existing journal scrape, matching the upstream config.
- broaden the /var/log mount to cover both pipelines (was journal only).

Docker integration:
- drop container_memory_working_set_bytes from the cadvisor allowlist;
  not part of the documented metric set.

README: refresh "What it collects" + "Mounts" tables, document the
syslog-vs-journald duplication caveat for rsyslog hosts.
2026-04-26 09:24:44 +07:00
tiennm99 fd8cf058b2 docs: add Coolify SSH session spam runbook
Document a Coolify-specific noise pattern observed in the journal
pipeline: ~300 root sessions/hour from the Coolify host's connection
checks. Verified against coollabsio/coolify v4.x source (Kernel.php,
ServerManagerJob, ServerCheckJob, SshMultiplexingHelper).

Includes:
- exact call flow and skip conditions per Coolify source
- triage commands and key-fingerprint matcher
- two mitigations: drop at Alloy (loki.process stage.drop) or enable
  Sentinel server-side to bypass the SSH polling entirely
- framing: Coolify-only, base setup unchanged
2026-04-26 09:21:11 +07:00
tiennm99 513f688ea0 ci: bind alloy smoke test to real port (clustering needs non-zero) 2026-04-25 19:13:22 +07:00
tiennm99 b1911c0538 ci: make alloy smoke-test robust to runtime errors
Previous step failed in CI because the bare alloy container had no
/var/log/journal, no docker.sock, etc., so loki.source.journal +
discovery.docker exited the process — and --rm wiped the container
before logs could be inspected.

Now: drop --rm, mount the same host paths the prod compose uses, plus
an empty /var/log/journal stand-in. Capture logs unconditionally and
fail only on config-level patterns ('unknown component',
'undefined reference', 'syntax error', etc.). Runtime/component
failures against dummy endpoints are tolerated.
2026-04-25 19:12:05 +07:00
tiennm99 8a7f156487 fix: address review findings (network ns, journal path, CI semantics)
- network_mode: host so prometheus.exporter.unix reports real host
  interfaces (eth0...) rather than the alloy container's veth pair.
- loki.source.journal: set path = "/var/log/journal" explicitly so it
  doesn't silently fall through to /run/log/journal on volatile-journal
  hosts.
- cadvisor keep-list: add container_memory_working_set_bytes (drives
  several panels on the standard Docker dashboard).
- Drop /dev/kmsg device + extra_hosts:host.docker.internal — neither is
  needed by the current keep-lists, and host-network mode makes the
  extra_hosts entry meaningless.
- CI: extend Alloy validation beyond `fmt` (syntax-only) by booting
  alloy with the embedded config and asserting it stays running, which
  catches bad component refs / wrong arg names that fmt accepts.
- README: refresh Mounts table + Security note to match.
2026-04-25 19:09:49 +07:00
tiennm99 fed5d6f8c7 fix: point node_exporter at host bind mounts; drop pid:host
prometheus.exporter.unix now reads /rootproc and /rootfs (the existing
host bind mounts) instead of the container's own namespace, so the
filesystem + process metrics actually describe the host. This makes
pid:host unnecessary, so remove it — privileged is enough and pid:host
exposes every host process inside the container.
2026-04-25 11:20:03 +07:00
tiennm99 a63d142b53 feat: update cadvisor keep-list and add pid:host + machine-id mount
cadvisor regex: drop fs_inodes/fs_limit/network_tcp_usage; add fs_reads
+ fs_writes + network_(receive|transmit)_(errors|packets_dropped)_total
to match the standard Grafana Cloud docker integration dashboard.

Compose:
- pid: host so prometheus.exporter.unix sees host /proc (cpu, mem,
  load, processes) instead of the container's namespace.
- mount /etc/machine-id so loki.source.journal has a stable host id.
2026-04-25 11:16:13 +07:00
tiennm99 ac4c785053 ci: add REMOTECFG_* dummy vars so compose config passes
The compose file gained three new :?required guards
(REMOTECFG_URL/ID/USER) that the validate workflow was not exporting.
2026-04-25 10:51:16 +07:00
tiennm99 94c63452eb feat: merge linux+docker alloy configs and source creds from env
Embed unified config.alloy via compose configs, combining node_exporter
+ journal (linux) and cadvisor + docker logs (docker) collectors into one
container. Add remotecfg block for grafana fleet-management. Replace
hardcoded credentials with sys.env() reads of nine shell variables
(ALLOY_HOSTNAME, REMOTECFG_*, PROM_*, LOKI_*, GRAFANA_TOKEN).
2026-04-25 10:49:07 +07:00
tiennm99 f009b37ca0 fix: pass /dev/kmsg via devices: so cgroup allows the read
bind-mounting /dev/kmsg under volumes: creates the node but leaves the
device-cgroup controller blocking the read (EPERM). cap_drop=[ALL]
clears the default device allow-list, so even with CAP_SYSLOG the
kernel refuses. moving it under devices: adds the cgroup allow rule
alongside the bind-mount, which is what cadvisor actually needs.
2026-04-24 14:35:40 +07:00
tiennm99 9ca8c2a65b fix: mount /dev/kmsg and /run/udev/data for cadvisor + diskstats
clears two startup warnings:
 - cadvisor "Could not configure a source for OOM detection" — needs
   /dev/kmsg bind-mount and CAP_SYSLOG (kernel.dmesg_restrict=1 default)
 - node-exporter "Failed to open /run/udev/data" — diskstats collector
   enriches node_disk_* with model/serial/WWN labels from udev

both mounted read-only. CAP_SYSLOG added alongside DAC_OVERRIDE.
2026-04-24 14:24:00 +07:00
tiennm99 01261b18b0 fix: drop docker logs older than 6d before sending to loki
grafana cloud loki rejects entries older than 7 days with HTTP 400.
loki.source.docker has no "tail since" option, so on first start it
replays logs from each container's start time — long-running
containers (coolify-proxy, traffmonetizer) produced weeks of backlog
that loki refused to ingest.

insert a loki.process drop stage (older_than=144h) between the docker
source and loki.write.gc so stale entries are filtered before egress.
journal source already bounded by max_age=12h — no filter needed there.
2026-04-24 14:20:50 +07:00
tiennm99 59bf58a412 fix: grant DAC_OVERRIDE so alloy can write its data dir
container runs as root (0:0) but cap_drop=[ALL] stripped DAC_OVERRIDE,
so mkdir on /var/lib/alloy/data (alloy-owned in the image) failed with
permission denied. add it back (only DAC_OVERRIDE, nothing else) and
mount /etc/machine-id ro for a stable journal host id. annotate every
volume with the component that uses it.
2026-04-24 13:56:37 +07:00
tiennm99 fe7e48bf19 chore: switch license from mit to apache 2.0
fetched via gh api /licenses/apache-2.0 (github-official template).
2026-04-24 09:59:09 +07:00
tiennm99 1a894935d1 fix: expand one-line rule blocks (river requires newline-separated attrs)
caught by new ci — alloy fmt rejected `rule { a = b c = d }` on one line.
river/flow config language requires each attribute on its own line.
2026-04-24 09:55:15 +07:00
tiennm99 2ff943190c ci: add mit license + validate workflow
workflow runs on push to main and all prs:
- docker compose config -q (yaml + env interpolation)
- yq extract embedded alloy config
- alloy fmt (syntax check)
2026-04-24 09:54:03 +07:00
tiennm99 ca22737dfb docs: add readme with quick-start, multi-host pattern, security posture, free-tier budget 2026-04-24 09:49:34 +07:00
tiennm99 f5955a82f0 refactor: simplify for standalone single-host deployment
- merge discovery.relabel "metrics" into prometheus.relabel "filter"
  (instance label + keep-filter in one component)
- move logs 'instance' label to loki.write external_labels (DRY, one source)
- drop alloy container log exclusion (~500kb/day trivial; adds visibility
  into alloy's own health via loki)

net: -5 lines, one fewer component, same functionality
2026-04-24 09:43:32 +07:00
tiennm99 b055d4f66b refactor: tighten filters, add missing metrics + journal labels
correctness:
- drop dead ALLOY_HOSTNAME env passthrough (alloy uses constants.hostname)
- tighten netdev regex so iface names like 'lore0' no longer match 'lo'
- widen alloy-container drop to /alloy.* (catches renamed variants)

more info for same cost (~80 extra series, still ~1.6k of 10k):
- add MemFree, disk IOPS (reads/writes_completed), disk saturation
  (io_time_weighted), inode tracking (filesystem_files, _files_free),
  network drops, container working_set memory, container_last_seen,
  machine_scrape_error
- add journal unit + level labels (log querying by service/severity)
- docker discovery refresh 60s -> 5s (fast pickup of new containers)

efficiency:
- loki batch_wait=5s, batch_size=1MiB (~5x fewer push requests)

layout:
- drop redundant expose: [12345] (other containers can reach it anyway)
2026-04-24 09:27:57 +07:00
tiennm99 d0fb74ca58 feat: initial single-container alloy setup for grafana cloud free
- node_exporter (host) + cadvisor (containers) via one scrape job
- docker container logs + systemd journal to loki
- filter regex keeps only metrics needed for standard dashboards
- sized for grafana cloud free tier (~10k active series cap)
- shell env driven (no .env file); fail-fast on missing vars
- least-privilege: cap_drop all, no privileged, no pid host, ui not published
2026-04-24 09:21:29 +07:00
tiennm99 baac61cd70 feature: init 2025-04-07 12:44:18 +07:00
Tien Nguyen Minh b90541ac8b Initial commit 2025-04-07 12:39:46 +07:00
tiennm99 a420279f2b Update docker-compose.yml 2025-03-25 21:58:42 +07:00
tiennm99 7efe3c00ce Update docker-compose.yml 2025-03-25 20:58:52 +07:00
tiennm99 10c8824b4d feat: use docker stable image 2025-03-24 21:18:44 +07:00
tiennm99 fc766b2c49 feat: remove config 2025-03-24 21:15:41 +07:00
tiennm99 a3c1ea7cc3 feat: update config 2025-03-24 21:06:03 +07:00
tiennm99 292770e716 feat: init 2025-03-24 20:56:09 +07:00
Tien Nguyen Minh 9a10d42a43 Initial commit 2025-03-24 20:51:11 +07:00
tiennm99 03fbd4d3a2 Create docker-compose.yml 2025-03-15 20:43:33 +07:00
Tien Nguyen Minh cb4052d445 Initial commit 2025-03-15 20:37:50 +07:00
tiennm99 c1b902e455 feat: init 2025-03-07 00:42:01 +07:00
Tien Nguyen Minh ad4db2e29a Initial commit 2025-03-07 00:40:52 +07:00
Tien Nguyen Minh 2d0982ec55 Update README.md 2024-05-03 16:38:43 +07:00
tiennm99 c5567c623e [Init] 2023-12-23 08:15:16 +07:00
Tien Nguyen Minh 3426cf1da6 Initial commit 2023-12-23 07:29:50 +07:00
tiennm99 0fad91affd [Fix] run script 2023-11-13 14:34:08 +07:00
tiennm99 1320a67a15 Update README.md 2023-11-11 18:22:57 +07:00
tiennm99 416407d8ac chmod +x run.sh 2023-11-11 18:07:53 +07:00
tiennm99 2746ddd730 [Init] 2023-11-11 17:30:58 +07:00
Tien Nguyen Minh 1b24169d58 Initial commit 2023-11-11 16:50:15 +07:00
78 changed files with 4186 additions and 54 deletions

No files matched your search

+60
View File
@@ -0,0 +1,60 @@
# 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
@@ -0,0 +1,33 @@
# 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
@@ -0,0 +1,61 @@
# 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
@@ -0,0 +1,20 @@
# 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
@@ -0,0 +1,110 @@
---
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
@@ -0,0 +1,117 @@
---
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.
+86
View File
@@ -0,0 +1,86 @@
#!/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,3 +21,10 @@ desktop.ini
*.swp
.idea/
.vscode/
# Upstream source checkouts for debugging; only the directory itself is kept
sources/*
!sources/.gitkeep
# Local agent plans and reports
/plans/
+88 -10
View File
@@ -4,16 +4,83 @@ Personal docker compose collection. One directory per service, each holding
`compose.yml`, its own `README.md`, a committed `.env.example`, and a
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
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
adding its README and a row to the root table.
## Deployment target
This is a deployment rule, not a style preference. Each service is a separate
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.
Services are deployed through Coolify and Dokploy, not plain `docker compose`
on a host. The platform owns the parts a standalone compose file would declare
itself.
Every Coolify app created from this repo sets its watch path to `<service>/**`.
After creating one, check `watch_paths` with the Coolify MCP `get_application`;
`null` means the app deploys on every push to the repository. Only these are
`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
@@ -22,16 +89,27 @@ unprompted:
- **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
and would additionally expose it on the host.
- **No `restart:` policy.** The platform manages the container lifecycle.
and would additionally expose it on the host. Coolify's `ports_exposes`
field (often a prefilled `3000`) is never read for compose apps; the proxy
port comes from the compose `expose:` entry or the domain's port. Leave it.
- **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
to be portable, standard, or turnkey. Prefer leaving a service minimal over
adding hardening or convention that the platform already provides.
## Secrets
`restart:` is the exception that is *not* omitted. Every service sets
`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.
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.
## Upstream sources
`sources/` is for upstream source checkouts used while debugging, cloned as
`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.
+58 -17
View File
@@ -1,19 +1,23 @@
# composes
My docker compose collection — one directory per service, each self-contained.
Tuned to my own setup rather than written as general-purpose templates.
These are tuned to my own setup, not written as general-purpose templates. They
are deployed through [Coolify](https://coolify.io) and
[Dokploy](https://dokploy.com), so they lean on the platform for things a
standalone compose file would normally declare:
Services are deployed through [Coolify](https://coolify.io), with
[Dokploy](https://dokploy.com) supported as an optional extra. The platform
owns what a standalone compose file would otherwise declare:
- **No published ports.** Both platforms attach the container to their proxy
network and map a domain directly to the internal port, so `ports:` is
unnecessary — and adding it would expose the host port as well.
- **No `restart:` policy.** The platform manages the container lifecycle.
- **No published ports.** The platform attaches the container to its proxy
network and maps a domain to the internal port. Publishing one would also
expose it on the host.
- **No `container_name:`.** Compose derives it from the directory.
Treat them as working examples rather than drop-in configs. Running one with
plain `docker compose` means adding whatever your setup needs.
Every container does set `restart: unless-stopped`. Coolify would inject the
same value on its own, but Dokploy leaves an omitted policy alone, which in its
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
@@ -25,15 +29,34 @@ plain `docker compose` means adding whatever your setup needs.
.env # real values, gitignored
```
Compose names the project after its directory, so `code-server/` comes up as
the `code-server` project with its own network and volumes.
Compose names the project after its directory, so `gitea/` comes up as
the `gitea` 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
In Coolify or Dokploy, point a Docker Compose resource at the service directory
and set the environment variables from its `.env.example`.
and set the environment variables from its `.env.example`. To move an existing
deployment and its data onto that resource, use the `migrate-service` skill in
`.claude/skills/`.
Locally, for a quick check:
Locally:
```sh
cd <service>
@@ -43,8 +66,8 @@ docker compose logs -f
docker compose down
```
`.env` is picked up automatically because it sits next to `compose.yml`.
Never commit it — the root `.gitignore` covers `.env`/`*.env` and re-includes
`.env` is picked up automatically because it sits next to `compose.yml`. Never
commit it — the root `.gitignore` covers `.env`/`*.env` and re-includes
`.env.example`.
## Services
@@ -53,4 +76,22 @@ Each links to its own README for variables, ports, and storage.
| Service | What it is |
| --- | --- |
| [code-server](code-server/README.md) | VS Code in the browser, as a remote dev box |
| [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 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
@@ -0,0 +1,23 @@
# 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
@@ -0,0 +1,153 @@
# 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
@@ -0,0 +1,292 @@
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"
}
}
+7 -3
View File
@@ -2,12 +2,16 @@
#
# cp .env.example .env
# Web UI login password. MUST NOT be blank -- the LinuxServer image serves
# code-server without authentication when PASSWORD is empty.
# Also used for SUDO_PASSWORD inside the container.
# Container hostname, also passed in as HOST -- the name zsh's prompt shows.
SERVICE_HOSTNAME=code-server
# Web UI login password. Required.
# Generate one with: openssl rand -base64 24
PASSWORD=
# Git identity baked into the container (author + committer).
GIT_NAME=
GIT_EMAIL=
# Name shown in the title bar and welcome page.
# CODE_SERVER_APP_NAME=code-server
+36
View File
@@ -0,0 +1,36 @@
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", "."]
+274 -11
View File
@@ -1,28 +1,291 @@
# code-server
[VS Code in the browser](https://github.com/linuxserver/docker-code-server),
from the LinuxServer image, set up as a full remote dev box.
[VS Code in the browser](https://github.com/coder/code-server), from Coder's
official `codercom/code-server` image.
Comes with Go, Node.js 24, Python 3, pnpm, and zsh via LinuxServer mods, plus
`gh`, `git`, `ffmpeg`, `imagemagick`, and other CLI tools through
`INSTALL_PACKAGES`. Git author/committer identity is injected from `.env`.
The image ships code-server on Debian with `git`, `zsh`, `curl`, `sudo` and a
few editors. The `Dockerfile` adds `build-essential`, `bubblewrap`, `zip`,
`unzip`, a set of command-line utilities and Chromium, and wraps the entrypoint so it starts in `/workspace` and `coder` can
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
| Variable | Purpose |
| --- | --- |
| `PASSWORD` | Web UI login, also the in-container sudo password. **A blank value disables authentication entirely.** |
| `PASSWORD` | Web UI login. Required. |
| `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`.
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
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.
Listens on `8080`; point the domain at it.
## Storage
Everything lives in the `code-server-config` named volume mounted at `/config`;
the default workspace is `/config/workspace`. Removing the volume wipes your
files, settings, and extensions.
| Volume | Mount | Holds |
| --- | --- | --- |
| `code-server-home` | `/home/coder` | Home directory: settings, extensions, shell history, CLI logins |
| `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.
+14 -13
View File
@@ -1,23 +1,24 @@
services:
code-server:
image: 'lscr.io/linuxserver/code-server:latest'
hostname: miti99-cs
build: .
restart: unless-stopped
hostname: ${SERVICE_HOSTNAME}
shm_size: 1gb
environment:
- PUID=1000
- PGID=1000
- PASSWORD=${PASSWORD:?required}
- TZ=Asia/Ho_Chi_Minh
- DEFAULT_WORKSPACE=/config/workspace
- 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}
- DEFAULT_WORKSPACE=/workspace
- SHELL=/bin/zsh
- GIT_AUTHOR_NAME=${GIT_NAME}
- GIT_AUTHOR_EMAIL=${GIT_EMAIL}
- GIT_COMMITTER_NAME=${GIT_NAME}
- GIT_COMMITTER_EMAIL=${GIT_EMAIL}
- HOST=${SERVICE_HOSTNAME}
# - CODE_SERVER_APP_NAME=${CODE_SERVER_APP_NAME:-code-server}
volumes:
- 'code-server-config:/config'
- 'code-server-home:/home/coder'
- 'code-server-workspace:/workspace'
- '/var/run/docker.sock:/var/run/docker.sock:ro'
volumes:
code-server-config:
code-server-home:
code-server-workspace:
+26
View File
@@ -0,0 +1,26 @@
#!/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
@@ -0,0 +1,5 @@
# 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
@@ -0,0 +1,31 @@
# 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
@@ -0,0 +1,16 @@
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
@@ -0,0 +1,9 @@
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
@@ -0,0 +1,75 @@
# 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
@@ -0,0 +1,37 @@
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:
@@ -0,0 +1,37 @@
# 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"}`.
@@ -0,0 +1,18 @@
# 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.
@@ -0,0 +1,103 @@
# 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
@@ -0,0 +1,103 @@
# 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
@@ -0,0 +1,18 @@
# 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
@@ -0,0 +1,39 @@
# 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
@@ -0,0 +1,9 @@
# 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
@@ -0,0 +1,4 @@
BETTER_AUTH_SECRET=
ENCRYPTION_SECRET=
GITEA_MIRROR_URL=https://gitea-mirror.example.com
HTTP_IDLE_TIMEOUT=3600
+66
View File
@@ -0,0 +1,66 @@
# 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
@@ -0,0 +1,21 @@
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
@@ -0,0 +1,3 @@
POSTGRES_PASSWORD=gitea
GITEA_ROOT_URL=https://gitea.example.com/
GITEA_CLONE_TIMEOUT=3600
+56
View File
@@ -0,0 +1,56 @@
# 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
@@ -0,0 +1,45 @@
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
@@ -0,0 +1,29 @@
# 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
@@ -0,0 +1,121 @@
# 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
@@ -0,0 +1,69 @@
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
@@ -0,0 +1,19 @@
# 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
@@ -0,0 +1,120 @@
# 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
@@ -0,0 +1,25 @@
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
@@ -0,0 +1,41 @@
# 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
@@ -0,0 +1,4 @@
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
@@ -0,0 +1,83 @@
# 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
@@ -0,0 +1,84 @@
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
@@ -0,0 +1,156 @@
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
@@ -0,0 +1,23 @@
# 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
@@ -0,0 +1,85 @@
# 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
@@ -0,0 +1,72 @@
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
@@ -0,0 +1,15 @@
# 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
@@ -0,0 +1,49 @@
# 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
@@ -0,0 +1,20 @@
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
@@ -0,0 +1,51 @@
# 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
@@ -0,0 +1,4 @@
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
@@ -0,0 +1,95 @@
# 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
@@ -0,0 +1,71 @@
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
@@ -0,0 +1,9 @@
{
"gateway": {
"mode": "local",
"bind": "lan",
"port": 18789,
"publicOrigin": "${OPENCLAW_PUBLIC_ORIGIN}",
"trustedProxies": ["${OPENCLAW_TRUSTED_PROXIES}"]
}
}
+13
View File
@@ -0,0 +1,13 @@
# 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
@@ -0,0 +1,70 @@
# 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
@@ -0,0 +1,28 @@
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
@@ -0,0 +1,17 @@
# 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
@@ -0,0 +1,76 @@
# 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
@@ -0,0 +1,72 @@
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
@@ -0,0 +1,29 @@
# 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
@@ -0,0 +1,20 @@
# 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
@@ -0,0 +1,226 @@
# 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
@@ -0,0 +1,23 @@
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
@@ -0,0 +1,53 @@
#!/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
@@ -0,0 +1,6 @@
# 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
@@ -0,0 +1,22 @@
# 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
@@ -0,0 +1,7 @@
services:
traffmonetizer:
image: 'traffmonetizer/cli_v2:arm64v8'
restart: always
command: 'start accept --token ${TOKEN}'
environment:
- 'TOKEN=${TOKEN}'
+13
View File
@@ -0,0 +1,13 @@
# 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
@@ -0,0 +1,4 @@
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
@@ -0,0 +1,79 @@
# 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
@@ -0,0 +1,23 @@
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: