Supersedes the prior 'name everything after the repo' guidance, which caused
container-name collisions at fleet scale. The dev's compose now names nothing
repo-specific; the deploy layer injects identity and host paths.
deploy.md:
- replace the 'One name everywhere' danger callout with 'Your compose names
nothing repo-specific' (no container_name, no hardcoded names/paths)
- rewrite the compose example to the standard: svc service key,
restart: unless-stopped (required — host unit is oneshot), host paths via
${LOGS_DIR}/${CONFIG_DIR}/${MOUNTS_DIR} with :-./ local fallbacks, bare
'cache' volume. Keep user: "1337:1337" + HOME=/tmp (the services account
identity — not repo-specific)
- add 'How deploy fills it in' (injected env vars a dev can rely on + the
resulting docker ps names) and 'Onboarding a service' (deploy <host>
<workspace> <git-url> [name], deploy key handled via Gitea API)
- update paths to <name>, subprocess example to svc, checklist to the new
rules; drop docker CLI from secret rotation (host-side edit, we restart)
standards.md:
- add a 'Service compose' section: short convention + Right/Wrong tabs
(the nova before/after), linking to the Deploy guide for the full detail
Kept the services-account (1337) section and uid-agnostic Dockerfile notes.
Verified in-browser; mkdocs build --strict clean (cross-ref anchor resolves).
Signed-off-by: disqualifier <dev@disqualifier.me>
9.9 KiB
Deployment Guide
How a project gets onto rethink-net — our Ubuntu 26.x servers. Get your container to follow a few consistent rules and deploying is mostly handing us a
compose.yaml.
!!! tip "What can run here" APIs, websites, applets, bots, monitors.
!!! danger "Your compose names nothing repo-specific"
No container_name. No hardcoded names, workspace, or /srv paths. The
deploy layer injects identity and host paths at deploy time — your compose stays
generic so it can't collide with any other service on the fleet.
- service key is always **`svc`**
- named volumes use **bare names** (`cache`, not `myapp_cache`)
- host paths come from **`${LOGS_DIR}` / `${CONFIG_DIR}` / `${MOUNTS_DIR}`**
Naming things after your repo used to cause **container-name collisions** at
fleet scale — two repos shipping the same name clashed. The generic compose
below fixes that; copy it verbatim.
Docker — the services account
Every service runs containerized as the shared services account:
uid/gid 1337, fixed fleet-wide. Build your image to be uid-agnostic so it
runs cleanly as that account.
FROM python:3.12-slim
ENV HOME=/tmp # (1)!
WORKDIR /app
RUN apt-get update \
&& apt-get install -y --no-install-recommends git \
&& rm -rf /var/lib/apt/lists/* # (2)!
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt # (3)!
COPY . .
RUN chmod -R a+rwX /app # (4)!
CMD ["python", "-m", "yourapp"]
HOME=/tmp— theservicesaccount has no home dir; anything writing to$HOME(caches, configs) needs a writable target.- Include
gitonly if the container itself needs it — e.g. youpip installfrom git, or the app shells out to git at runtime. The build and host always have git; this line is about what's inside the image. - Install deps before copying the code (see layer caching).
chmod -R a+rwX /appmakes the app tree writable by any uid — that's what "uid-agnostic" means.
!!! tip "Faster builds with uv (optional)"
uv is a drop-in for pip that reads the same
pyproject.toml — no lockfile needed in the image. Swap the deps layer and add
UV_COMPILE_BYTECODE so containers don't pay the first-import .pyc compile
cost:
```dockerfile
FROM python:3.12-slim
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/ # (1)!
ENV HOME=/tmp
ENV UV_COMPILE_BYTECODE=1 # (2)!
WORKDIR /app
COPY pyproject.toml .
RUN uv pip install --system . # (3)!
COPY . .
RUN chmod -R a+rwX /app
CMD ["python", "-m", "yourapp"]
```
1. Pull the `uv` binary from its published image — no pip-installing uv itself.
2. Compile bytecode at build time so the container doesn't eat the first-import
`.pyc` compile cost on every cold start.
3. `--system` installs into the image's Python (no venv needed — the container
*is* the isolation); reads `pyproject.toml`, no `uv.lock` required.
Your compose.yaml
Copy this verbatim. It names nothing repo-specific — the deploy layer fills in identity and host paths.
services:
svc: # (1)!
build: .
user: "1337:1337" # (2)!
restart: unless-stopped # (3)!
environment:
HOME: /tmp
volumes:
- ${LOGS_DIR:-./logs}:/app/logs # (4)!
- ${CONFIG_DIR:-./config}:/app/config # (5)!
- ${MOUNTS_DIR:-./mounts}:/app/mounts
- cache:/app/cache # (6)!
volumes:
cache: # (7)!
- The service key is always
svc— never a repo-specific name, never acontainer_name. Our tooling keys offsvc(thesvccommand, thesvc-<workspace>-<name>unit). - Run as the shared
servicesaccount (uid/gid 1337, fixed fleet-wide). No in-containeruser/useradd— set it here. This is the one identity line that stays; it's not repo-specific. - Required. The host-side service is oneshot; Docker's own restart policy is what recovers a crashed container.
- Logs → host, path injected by the deploy layer. The
:-./logsdefault lets youdocker compose uplocally with nothing set and still work. - Config → host, likewise injected. Same pattern for
${MOUNTS_DIR}. - Named volume with a bare name — ephemeral, auto-namespaced per service at deploy time so it can't collide.
- Declare bare (
cache, notmyapp_cache). Deploy auto-prefixes it.
!!! tip "The ${VAR:-./default} pattern"
Every host path uses a variable with a local fallback. Locally, docker compose up needs nothing set — it uses ./logs, ./config, ./mounts. On the fleet,
the deploy layer sets the real values (below), so the same file works both
places.
How deploy fills it in
At deploy time we set the variables your compose reads, so identity and host paths are injected — not baked into your file. You can rely on these being present:
COMPOSE_PROJECT_NAME=<workspace>-<name>
LOGS_DIR=/srv/logs/<workspace>/<name>
CONFIG_DIR=/srv/config/<workspace>/<name>
MOUNTS_DIR=/srv/mounts/<workspace>/<name>
Which makes ownership obvious and collisions impossible:
| item | value |
|---|---|
| container | <workspace>-<name>-svc-1 |
| volume | <workspace>-<name>_cache |
| network | <workspace>-<name>_default |
| logs | /srv/logs/<workspace>/<name>/ |
!!! warning "What <workspace> and <name> are"
- <workspace> — who owns it: an individual dev (ricky, xattam, …) or a
category (tpv, web, bots, …).
- <name> — the git repo name, lowercased, by default. Overridable at
deploy time.
Host paths live under /srv/<kind>/<workspace>/<name>/ (config, logs, mounts), all
owned by the services user (uid/gid 1337). You never write these paths in your
compose — you read the injected ${...} variables.
Onboarding a service
One command registers and starts a service:
deploy <host> <workspace> <git-url> [name]
It registers the service, generates a per-repo read-only deploy key on the host (the private key never leaves the box), pushes the public key to the repo's Gitea deploy keys automatically, clones the code, and installs and starts the service's unit. No manual key paste, no host setup on your end.
!!! note "If your service won't start or its logs aren't persisting"
That's usually a host-side bind-mount ownership thing — the kind of detail
we sort out at deploy time, not something you need to chown or provision.
If a bot won't come up or logs/caches keep vanishing, flag it and we'll fix it.
Stick to the generic compose.yaml above and let us handle the host.
Layer caching
Copy the deps file and install before COPY . .. Docker caches layers in
order, so deps only reinstall when the deps file changes — not on every code edit.
Get this backwards and every one-line change triggers a full dependency reinstall.
Same principle whether you use pip or uv:
COPY requirements.txt . # pip baseline
RUN pip install --no-cache-dir -r requirements.txt # cached until deps change
COPY . . # changes every build
COPY pyproject.toml . # uv path
RUN uv pip install --system . # cached until deps change
COPY . . # changes every build
Subprocess and browser workloads
Bots that spawn Chrome, Xvfb, ffmpeg, or other child processes need three extra knobs in compose:
services:
svc:
build: .
user: "1337:1337"
restart: unless-stopped
init: true # (1)!
shm_size: "2gb" # (2)!
mem_limit: "4g" # (3)!
- Runs tini as PID 1 to reap zombie subprocesses and forward signals. Without it, spawned Chrome/Xvfb processes leak as zombies.
- Chrome and most headless browsers crash on Docker's default 64 MB
/dev/shm. Bump it for any browser workload. - Bound memory — especially when each worker spawns a browser. Raise it as worker count grows.
!!! warning "The PID-1 gotcha with shell-wrapper CMDs"
If your CMD is a shell-script wrapper (e.g. xvfb-run ...), it must not
be PID 1, or the real process dies on startup. init: true is exactly what
fixes this — tini takes PID 1, and your wrapper runs as a normal child.
What your compose / Dockerfile needs
Compose
- service key
svc— nocontainer_name, no repo-specific names user: "1337:1337"— the sharedservicesaccountrestart: unless-stopped- host paths via
${LOGS_DIR}/${CONFIG_DIR}/${MOUNTS_DIR}— never hardcoded - named volumes with bare names (
cache, notmyapp_cache) - for browser/subprocess workloads:
init: true,shm_size,mem_limit
Dockerfile
HOME=/tmpchmod -R a+rwX /app(uid-agnostic; runs as theservicesaccount, 1337)- deps installed before the code copy (layer caching) — pip or
uv pip install gitin the image if the container needs it- using uv? add
ENV UV_COMPILE_BYTECODE=1
Secrets
!!! warning "Secrets never go in the image"
We do not commit secrets (usually, lol). They stay gitignored and live
on the host under the config dir (/srv/config/<workspace>/<name>/), reaching
your container read-only via ${CONFIG_DIR}. Add them to .dockerignore so a
COPY . . can't sweep them into a layer.
Rotating a secret is a host-side edit — update the file and the service picks it up on restart. No rebuild, and nothing you run: flag it and we handle the restart.