# 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`.
!!! success "What can run here"
APIs, websites, applets, bots, monitors.
## Preparing for Deploy
You build and test locally — leaning on our libraries, AI, and this handbook — then
push to git. From there the ops tooling takes over: staging (if you run gitflow), then
it builds the Docker image and runs it on the fleet. **You don't run any deploy
commands** — your only job is the three pieces in the tabs below.
```mermaid
flowchart LR
subgraph build ["you build"]
direction TB
local["local testing"]
libs["libs
(rethink-public, pip)"]
ai["AI-assisted
(Claude Code)"]
docs["reading the
handbook"]
local --- libs
libs --- ai
ai --- docs
docs --- local
end
build -->|push| git["git
(Gitea)"]
git -.->|develop, if gitflow| stage["staging"]
git --> deploy["deployment
(ops tooling)"]
stage --> deploy
deploy --> docker["docker
(built + run
on the fleet)"]
classDef ship fill:#061541,stroke:#569bcc,color:#eef1f6;
classDef work fill:#0e1530,stroke:#294274,color:#eef1f6;
class git,stage,deploy,docker ship;
class local,libs,ai,docs work;
```
!!! danger "Your compose names nothing repo-specific"
**No `container_name`, no hardcoded names/paths.** The deploy layer injects
identity and host paths so your compose can't collide with any other service on
the fleet:
- the service key is **always `svc`** — it never changes, in any repo
- named volumes use **bare names** (`cache`, not `myapp_cache`)
- host paths come from **`${LOGS_DIR}` / `${CONFIG_DIR}`**
=== "Compose"
The `compose.yaml` your repo ships. Copy it verbatim — it names nothing
repo-specific, so the deploy layer can inject identity and host paths without
collisions.
```yaml
services:
svc: # generic service key — ALWAYS svc, no container_name
build: .
user: "1337:1337" # the shared services account (required)
restart: unless-stopped # required — host unit is oneshot; this recovers crashes
environment:
HOME: /tmp
volumes:
- ${LOGS_DIR:-./logs}:/app/logs # output — host log dir, injected at deploy
- ${CONFIG_DIR:-./config}:/app/config # input — host config dir, injected at deploy
# - ${MOUNTS_DIR:-./mounts}:/app/data # optional — arbitrary host data (opt-in)
- cache:/app/cache # your data — ephemeral named volume
volumes:
cache: # bare name — deploy auto-prefixes it per service
```
!!! example "The container path is where your code reads and writes"
`WORKDIR` is `/app`, so the **right-hand side** of each volume line is the path
your code targets. Write to `./cache` (i.e. `/app/cache`) → the `cache` volume;
logs go to `/app/logs`, config is read from `/app/config`.
**Storage, three tiers:**
- **`logs` + `config`** — we inject and manage these at deploy time.
- **`mounts`** (optional) — host dir for arbitrary data. We inject `MOUNTS_DIR` and
create the dir; you **uncomment** the line and pick the container path.
- **Named volumes** (`cache`, …) — yours; Docker owns them, no host paths to manage.
!!! warning "Two kinds of logs — and crashes go to the other one"
`${LOGS_DIR}` holds **only** what your app writes to disk through its logger
(e.g. `log_setup` writing a file) — your own structured logging. It does **not**
capture the process's stdout/stderr, and that's where **startup crashes and
uncaught exceptions land** — a traceback from a failed import or a missing file
never reaches your logger. So if a service dies on startup, or you don't see the
error in your log files, it's in the process output, not `${LOGS_DIR}`. Make
fatal errors visible — and to route uncaught exceptions into your log file too,
install a top-level hook:
```python
import sys, logging
sys.excepthook = lambda *exc: logging.getLogger().critical("uncaught", exc_info=exc)
```
!!! success "Same file, both places"
Locally, `docker compose up` needs nothing set — the `${VAR:-./default}`
fallbacks use `./logs` / `./config`. When deployed, the ops tooling sets the
real values. One `compose.yaml` works everywhere.
**Subprocess and browser workloads** (bots that spawn Chrome, Xvfb, ffmpeg) need
three extra knobs:
```yaml
services:
svc:
build: .
user: "1337:1337"
restart: unless-stopped
init: true # tini as PID 1 — reaps zombie subprocesses, forwards signals
shm_size: "2gb" # Chrome/headless browsers crash on Docker's default 64 MB /dev/shm
mem_limit: "4g" # bound memory — raise as worker/browser 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` fixes this — tini
takes PID 1, your wrapper runs as a normal child.
=== "Dockerfile"
Every service runs containerized as the shared **`services`** account: **uid/gid
1337**, fixed fleet-wide. Build the image to be **uid-agnostic** so it runs cleanly
as that account.
```dockerfile
FROM python:3.12-slim
ENV HOME=/tmp # services account has no home dir; $HOME must be writable
WORKDIR /app
RUN apt-get update \
&& apt-get install -y --no-install-recommends git \
&& rm -rf /var/lib/apt/lists/* # include git ONLY if the container itself needs it
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt # deps before code — layer caching
COPY . .
RUN chmod -R a+rwX /app # writable by any uid — this is "uid-agnostic"
CMD ["python", "-m", "yourapp"]
```
**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.
**Getting files in:** `COPY . .` grabs the whole repo; be explicit about anything
that needs its own place. In `COPY `, `` is relative to the build
context (your repo), `` is a path in the image.
```dockerfile
COPY ./config.toml /app/config.toml # a single file into a specific path
COPY ./assets /app/assets # a whole directory (tree mirrors ./assets/)
```
!!! tip "Faster builds with uv (optional)"
[uv](https://docs.astral.sh/uv/) is a drop-in for pip that reads the same
`pyproject.toml` — no lockfile needed in the image. 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/ # pull the uv binary from its image
ENV HOME=/tmp
ENV UV_COMPILE_BYTECODE=1 # compile bytecode at build, not cold start
WORKDIR /app
COPY pyproject.toml .
RUN uv pip install --system . # into the image's Python, no venv/lock
COPY . .
RUN chmod -R a+rwX /app
CMD ["python", "-m", "yourapp"]
```
=== "Secrets"
!!! warning "Secrets never go in the image"
We do **not** commit secrets (usually, lol). They stay **gitignored** and live
on the host in your config dir, reaching your container read-only via
`${CONFIG_DIR}`. Add them to `.dockerignore` so a `COPY . .` can't sweep them
into a layer.
- Secrets live on the **host**, in your config dir — never in git, never in the
image.
- They reach the container **read-only** via the injected `${CONFIG_DIR}` mount.
- Keep them out of the build context: list them in `.dockerignore` so a blanket
`COPY . .` can't pull 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.
## Supporting services live in your compose, not the fleet
Need Redis? **Declare it in your own `compose.yaml`, as a sidecar.** There is no shared
Redis — nothing fleet-wide, nothing per-workspace, nothing ops provisions for you. A repo
that needs Redis brings its own; a repo that doesn't adds nothing.
That's the whole point of the deploy model. Every service already runs in its own compose
project on its own network so that one service falling over can't touch another. A shared
Redis puts that coupling straight back: one process everything depends on, whose OOM, stray
`FLUSHALL`, single-threaded stall, or restart becomes *everyone's* outage. A sidecar shares
its owning repo's fate and nobody else's — and the isolation is free, because it rides the
per-project network you already get. No ACLs, no key-prefix discipline, no shared
credentials to manage.
Your app talks to it with the [`redis` lib](libraries.md) from the suite (async,
config-free, kv/hash/ttl/pubsub), pointed at **`redis://redis:6379`** — the compose
**service name**, not a host port. The sidecar comes up auto-namespaced on your project's
network like every other container, exactly as the naming convention above describes.
### Ephemeral or persistent — pick deliberately
A sidecar Redis is **ephemeral by default**: restart it and the data is gone. That's
correct for some workloads and quietly destructive for others, so make the call on purpose.
Ask one question — *if this data vanished on a restart, would anything be lost?*
- **No → ephemeral.** A scratch cache, a dedupe set, rate-limit counters, transient data
you can just re-fetch. Nothing to back up, nothing to grow.
- **Yes → persistent.** An outbound webhook or notification queue, a job queue, anything
that could be mid-flight when the process dies. Losing it drops real work.
=== "Ephemeral (cache / throwaway)"
Fine to lose on restart — no volume, no persistence, by design.
```yaml
redis:
image: redis:7-alpine
restart: unless-stopped
command: redis-server --save "" --maxmemory 256mb --maxmemory-policy allkeys-lru
# no volume: throwaway by design
```
=== "Persistent (durable queue / state)"
Survives restart, rebuild, and reboot — the append-only file lives on the host mounts
dir injected at deploy, the same `${MOUNTS_DIR}` mechanism described above. Redis just
uses it as its backing store.
```yaml
redis:
image: redis:7-alpine
restart: unless-stopped
command: redis-server --appendonly yes --appendfsync everysec
volumes:
- ${MOUNTS_DIR:-./mounts}/redis:/data # AOF persists on the host mounts dir
```
The app connects the same way in both modes — `REDIS_URL: redis://redis:6379`. Only the
durability changes.
!!! warning "Two things to know before you rely on a persistent sidecar"
**`--appendfsync everysec` can lose ~1 second of the newest entries** on a hard crash.
For a webhook queue that's an acceptable trade — just know it's there. Use
`--appendfsync always` if you genuinely cannot drop a single entry (safer, slower).
**Data under the mounts dir is not backed up.** Mounts are excluded from the backup
pipeline, and that's *right* for a queue: a lost queue means some notifications didn't
fire, not that business data is gone. **That's the dividing line.** If losing this data
would actually hurt, it isn't queue or cache state — it's a system of record, and it
belongs in Postgres (which *is* backed up), not a local mount.
!!! tip "Sometimes you don't need Redis at all"
For the dead-simple case — **one process draining its own queue** — a **SQLite file**
under the mounts dir does the job with no extra container: atomic commits, crash-safe,
zero infra. Reach for Redis when it's actually doing queue *things* (multiple workers,
blocking pops, streams / consumer groups, pub/sub) or when it's already in your compose
for something else.
### The rules
!!! danger "Don't do these"
- **Don't map Redis to a host port.** No `ports: - "6379:6379"`. Two repos both
grabbing host 6379 on the same box collide. Keep it internal to the compose network —
nothing exposed, nothing to collide.
- **Don't stand up a shared or fleet-wide Redis.** Per-repo means per-need. One Redis
per *project*, shared by that project's containers if a repo runs several — never one
per fleet.
- **Don't treat persistent Redis as a database.** Queues and caches, yes. A durable
system of record, no — that's Postgres, and unlike a mount it's backed up.
**You can't reach another repo's Redis** — different project, different network. That's not
a restriction you have to work around; it's the isolation working *for* you. Nobody else's
service can touch your cache or drain your queue either, and you never have to think about
whose keys are whose.
!!! quote "What about ACLs?"
A shared Redis *can* be secured — Redis 6+ ACLs scope users by command, key pattern, and
channel. But ACLs don't solve resource contention or the noisy-neighbour problem, and
they add real management burden, so the fleet uses per-repo sidecars instead. Reserve
ACLs for the rare case of a deliberately shared, durable, backed-up Redis run as actual
infrastructure.