Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/admin-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -857,7 +857,7 @@ Stash the tarball on a USB stick or in 1Password.

## Updating

Container images update themselves nightly at 3am via Watchtower. You do nothing for that.
Container images update themselves nightly at 3am via Watchtower. You do nothing for that. Each famstack release pins every service to a version line, so Watchtower brings in bug-fix releases, and a new minor or major version of Immich, Paperless or Synapse arrives with a famstack update, not overnight.

The famstack code is a git checkout, and a release is a tag. Updating means moving your checkout to a newer tag.

Expand Down
4 changes: 3 additions & 1 deletion docs/adr/rfc-002-installation-instances-and-updates.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,9 @@ reported, so on a checkout between tags it named a hundred different trees.
Every surface now derives it from `git describe --tags --dirty`, which
answers by comparing the tree against the commit a tag points at.

**P3. A release does not describe what runs.** Across the stacklets there are
**P3. A release does not describe what runs.** *(narrowed: every image now
names a minor line or an exact version, so installs of one tag differ by patch
releases only)* Across the stacklets there are
19 image references. Exactly one is pinned to an exact upstream version
(`paperless-ngx:3.0.4`). Five are `:latest` (element-web, synapse, tika,
adguard, watchtower), one is `:main` (open-webui), one carries no tag at all
Expand Down
4 changes: 2 additions & 2 deletions docs/creating-stacklets.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,7 @@ name: stack-myapp # project name: stack-{id}
services:
stack-myapp: # container name: stack-{id}
container_name: stack-myapp
image: someimage:latest
image: someimage:1.4 # minor line, else the exact version
labels:
- "com.centurylinklabs.watchtower.enable=${WATCHTOWER_ENABLE:-true}"
networks:
Expand Down Expand Up @@ -284,7 +284,7 @@ name: stack-code
services:
stack-code:
container_name: stack-code
image: codeberg.org/forgejo/forgejo:14
image: codeberg.org/forgejo/forgejo:14.0
networks:
- stack
ports:
Expand Down
12 changes: 11 additions & 1 deletion docs/stack-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -857,6 +857,16 @@ Every container gets this label. Watchtower (in the `core` stacklet)
monitors labeled containers and pulls new images on the nightly schedule.
Set `channel = "none"` in `stacklet.toml` to disable.

Watchtower pulls the tag the compose file names, so the tag decides how far
an image can move. Pin the minor line where upstream publishes one
(`gotenberg:8.37`), otherwise the exact version (`synapse:v1.161.0`), or a
digest when upstream tags no release at all. Watchtower then delivers patch
releases, and a minor or major version changes only with a commit. Immich
keeps to semantic versioning and tags only its major line, so it is pinned
to that (`immich-server:v3`). For PostgreSQL the major tag (`postgres:16-alpine`) is already the patch line:
its minor releases are bug and security fixes. Named tags such as `latest`,
`main` or `release` fail `tests/unit/framework/test_compose_pins.py`.

### Health Checks

```yaml
Expand Down Expand Up @@ -903,7 +913,7 @@ name: stack-docs
services:
stack-docs-paperless:
container_name: stack-docs-paperless
image: ghcr.io/paperless-ngx/paperless-ngx:latest
image: ghcr.io/paperless-ngx/paperless-ngx:3.0
labels:
- "com.centurylinklabs.watchtower.enable=${WATCHTOWER_ENABLE:-true}"
networks:
Expand Down
4 changes: 3 additions & 1 deletion stacklets/ai/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@ name: stack-ai
services:
stack-ai-speech:
container_name: stack-ai-speech
image: ghcr.io/matatonic/openedai-speech-min
# No version tag after 0.18.2 (2024). `latest` is a later build, so the
# digest is the only name for the image that runs.
image: ghcr.io/matatonic/openedai-speech-min@sha256:e82d719252fb1b861967f4f9abb8513f028ab9c1139451245688dbf34992b060
# Gated by the "voice" compose profile. The stacklet defaults
# COMPOSE_PROFILES=voice so this runs in normal use; `STACK_AI_NO_VOICE=1`
# clears it so local-dev `stack up ai` skips the speech container.
Expand Down
2 changes: 1 addition & 1 deletion stacklets/chatai/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ name: stack-chatai
services:
stack-chatai:
container_name: stack-chatai
image: ghcr.io/open-webui/open-webui:main
image: ghcr.io/open-webui/open-webui:0.11
labels:
- "com.centurylinklabs.watchtower.enable=${WATCHTOWER_ENABLE:-true}"
- "stack.commit=${STACK_COMMIT:-unknown}"
Expand Down
2 changes: 1 addition & 1 deletion stacklets/code/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ name: stack-code
services:
stack-code:
container_name: stack-code
image: codeberg.org/forgejo/forgejo:14
image: codeberg.org/forgejo/forgejo:14.0
labels:
- "com.centurylinklabs.watchtower.enable=${WATCHTOWER_ENABLE:-true}"
- "stack.commit=${STACK_COMMIT:-unknown}"
Expand Down
2 changes: 1 addition & 1 deletion stacklets/core/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ services:
# containrrr/watchtower was archived in 2024 and ships an old Docker
# SDK pinned to API v1.25, which modern daemons (incl. OrbStack)
# reject — the container crashlooped and poisoned core's health.
image: nickfedor/watchtower:latest
image: nickfedor/watchtower:1.16
container_name: stack-core-watchtower
restart: unless-stopped
environment:
Expand Down
12 changes: 6 additions & 6 deletions stacklets/docs/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,12 @@ name: stack-docs
services:
stack-docs-paperless:
container_name: stack-docs-paperless
# Pinned to an exact release, never `:latest`. A watchtower pull of
# Pinned to the 3.0 line, never `:latest`. A watchtower pull of
# `:latest` once rolled 2.x straight to 3.0 and broke document filing.
# Paperless migrates its database on the first 3.x start and 2.x will
# not boot afterwards, so a major bump is one-way: do it deliberately,
# with a backup taken first. Bump within 3.0.x for patches.
image: ghcr.io/paperless-ngx/paperless-ngx:3.0.4
# with a backup taken first. Watchtower delivers the 3.0.x patches.
image: ghcr.io/paperless-ngx/paperless-ngx:3.0
labels:
- "com.centurylinklabs.watchtower.enable=${WATCHTOWER_ENABLE:-true}"
- "stack.commit=${STACK_COMMIT:-unknown}"
Expand Down Expand Up @@ -91,7 +91,7 @@ services:

stack-docs-redis:
container_name: stack-docs-redis
image: docker.io/valkey/valkey:8-alpine
image: docker.io/valkey/valkey:8.1-alpine
labels:
- "com.centurylinklabs.watchtower.enable=${WATCHTOWER_ENABLE:-true}"
- "stack.commit=${STACK_COMMIT:-unknown}"
Expand All @@ -106,7 +106,7 @@ services:

stack-docs-gotenberg:
container_name: stack-docs-gotenberg
image: gotenberg/gotenberg:8
image: gotenberg/gotenberg:8.37
labels:
- "com.centurylinklabs.watchtower.enable=${WATCHTOWER_ENABLE:-true}"
- "stack.commit=${STACK_COMMIT:-unknown}"
Expand All @@ -120,7 +120,7 @@ services:

stack-docs-tika:
container_name: stack-docs-tika
image: apache/tika:latest
image: apache/tika:4.0.0
labels:
- "com.centurylinklabs.watchtower.enable=${WATCHTOWER_ENABLE:-true}"
- "stack.commit=${STACK_COMMIT:-unknown}"
Expand Down
6 changes: 3 additions & 3 deletions stacklets/infra/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,13 @@
# The hetzner module is v2, which talks to the Hetzner Cloud API. v1 used
# the DNS Console API, which Hetzner has deprecated.
#
# Pinned to the Caddy 2 line. Every subdomain reusing the wildcard
# Pinned to the Caddy 2.10 line. Every subdomain reusing the wildcard
# certificate needs 2.10 or later.

FROM caddy:2-builder AS builder
FROM caddy:2.10-builder AS builder
RUN xcaddy build \
--with github.com/caddy-dns/hetzner/v2 \
--with github.com/caddy-dns/cloudflare

FROM caddy:2
FROM caddy:2.10
COPY --from=builder /usr/bin/caddy /usr/bin/caddy
4 changes: 2 additions & 2 deletions stacklets/messages/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ name: stack-messages
services:
stack-messages-synapse:
container_name: stack-messages-synapse
image: matrixdotorg/synapse:latest
image: matrixdotorg/synapse:v1.161.0
labels:
- "com.centurylinklabs.watchtower.enable=${WATCHTOWER_ENABLE:-true}"
- "stack.commit=${STACK_COMMIT:-unknown}"
Expand Down Expand Up @@ -55,7 +55,7 @@ services:

stack-messages-element:
container_name: stack-messages-element
image: vectorim/element-web:latest
image: vectorim/element-web:v1.12.28
labels:
- "com.centurylinklabs.watchtower.enable=${WATCHTOWER_ENABLE:-true}"
- "stack.commit=${STACK_COMMIT:-unknown}"
Expand Down
4 changes: 3 additions & 1 deletion stacklets/messages/hooks/on_install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -39,11 +39,13 @@ chmod 700 "$DB_DIR"

if [ ! -f "$SYNAPSE_DIR/homeserver.yaml" ]; then
echo "messages: generating Synapse configuration for '$SERVER_NAME'..."
# Same tag as docker-compose.yml, so the config comes from the Synapse
# version that will run it.
docker run --rm \
-v "$SYNAPSE_DIR:/data" \
-e SYNAPSE_SERVER_NAME="$SERVER_NAME" \
-e SYNAPSE_REPORT_STATS=no \
matrixdotorg/synapse:latest generate
matrixdotorg/synapse:v1.161.0 generate

# ── Patch homeserver.yaml ────────────────────────────────────────────
#
Expand Down
6 changes: 3 additions & 3 deletions stacklets/photos/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ name: stack-photos
services:
stack-photos-server:
container_name: stack-photos-server
image: ghcr.io/immich-app/immich-server:${IMMICH_VERSION:-release}
image: ghcr.io/immich-app/immich-server:${IMMICH_VERSION:-v3}
labels:
- "com.centurylinklabs.watchtower.enable=${WATCHTOWER_ENABLE:-true}"
- "stack.commit=${STACK_COMMIT:-unknown}"
Expand All @@ -36,7 +36,7 @@ services:

stack-photos-ml:
container_name: stack-photos-ml
image: ghcr.io/immich-app/immich-machine-learning:${IMMICH_VERSION:-release}
image: ghcr.io/immich-app/immich-machine-learning:${IMMICH_VERSION:-v3}
labels:
- "com.centurylinklabs.watchtower.enable=${WATCHTOWER_ENABLE:-true}"
- "stack.commit=${STACK_COMMIT:-unknown}"
Expand All @@ -57,7 +57,7 @@ services:

stack-photos-redis:
container_name: stack-photos-redis
image: docker.io/valkey/valkey:8-alpine
image: docker.io/valkey/valkey:8.1-alpine
labels:
- "com.centurylinklabs.watchtower.enable=${WATCHTOWER_ENABLE:-true}"
- "stack.commit=${STACK_COMMIT:-unknown}"
Expand Down
2 changes: 1 addition & 1 deletion stacklets/photos/stacklet.toml
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ DB_USERNAME = "postgres"
DB_DATABASE_NAME = "immich"
DB_HOSTNAME = "stack-photos-db"
REDIS_HOSTNAME = "stack-photos-redis"
IMMICH_VERSION = "release"
IMMICH_VERSION = "v3"

# stack status uses this to confirm the service is actually responding,
# not just that the container is running.
Expand Down
2 changes: 1 addition & 1 deletion tests/integration/test_caddy.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@

pytestmark = pytest.mark.skipif(not HAS_DOCKER, reason="Docker not available")

STOCK_CADDY = "caddy:2"
STOCK_CADDY = "caddy:2.10"
DOMAIN = "home.example.family"


Expand Down
90 changes: 48 additions & 42 deletions tests/unit/framework/test_compose_pins.py
Original file line number Diff line number Diff line change
@@ -1,61 +1,83 @@
"""Every container image must name an explicit version.
"""Every container image must name a version.

`:latest` plus watchtower is a scheduled outage. Paperless-ngx rolled from
A floating tag plus watchtower is a scheduled outage. Paperless-ngx rolled from
the 2.x line to 3.0.2 unattended and broke document filing across the whole
e2e suite; we found out from a red test run, not from a decision.

Images are pinned to the minor line where upstream publishes one
(`gotenberg:8.37`), otherwise to the exact version (`synapse:v1.161.0`), so
watchtower delivers patch releases and nothing larger. Whether a minor tag
exists is a registry question, so this test checks what it can offline: every
tag is a version, never a name such as `latest`, `main` or `release` that
upstream moves to its next build.

This is the audit that would have caught it, as a test instead of a one-time
grep — it costs milliseconds and cannot silently stop being true.
"""

from __future__ import annotations

import re
import tomllib
from pathlib import Path

REPO_ROOT = Path(__file__).resolve().parent.parent.parent.parent

# `image: repo/name:tag` in compose, ignoring commented-out lines. The tag is
# optional in the grammar precisely because omitting it means `:latest`, which
# is the case this test exists to reject.
# is one of the cases this test exists to reject.
_IMAGE_RE = re.compile(r"^\s*image:\s*[\"']?([^\s\"'#]+)", re.MULTILINE)

# Known-unpinned images, as of 2026-07-31. This is a ratchet, not an allowlist:
# the test blocks any NEW floating tag immediately, while these existing ones
# get pinned deliberately, one at a time, each verified against a running rig.
# Picking a Synapse or Element version is a real decision with a real blast
# radius; making that call to turn a test green would be the wrong order.
#
# Delete entries as they are pinned. An empty set is the goal state.
KNOWN_UNPINNED: set[str] = {
"ghcr.io/matatonic/openedai-speech-min", # no tag at all
"nickfedor/watchtower:latest",
"apache/tika:latest",
"matrixdotorg/synapse:latest",
"vectorim/element-web:latest",
}
# A version starts with a digit, optionally after a `v`: `14.0`, `v1.161.0`,
# `16-alpine`, `14-vectorchord0.4.3-pgvectors0.2.0`.
_VERSION_RE = re.compile(r"^v?\d")

# A tag taken from the environment, `${IMMICH_VERSION:-v3.2.2}`.
_VAR_RE = re.compile(r"^\$\{(\w+)(?::-([^}]*))?\}$")


def _compose_files() -> list[Path]:
return sorted(REPO_ROOT.glob("stacklets/*/docker-compose*.yml"))


def _unpinned(text: str) -> list[str]:
"""Return image refs carrying no tag, or an explicitly floating one."""
def _env_defaults(compose: Path) -> dict:
manifest = compose.parent / "stacklet.toml"
if not manifest.exists():
return {}
data = tomllib.loads(manifest.read_text(encoding="utf-8"))
return data.get("env", {}).get("defaults", {})


def _tag(ref: str, env: dict) -> str:
"""The tag `ref` resolves to once the stacklet's `.env` is rendered."""
# Strip the registry host before looking for the tag separator, so
# `registry:5000/img` is not mistaken for an `img:5000` tag.
last_segment = ref.rsplit("/", 1)[-1]
tag = last_segment.split(":", 1)[1] if ":" in last_segment else "latest"
# `.env` always carries the stacklet's `[env.defaults]` value, so that is
# the pin; the compose fallback only applies to a variable it lacks.
if var := _VAR_RE.match(tag):
tag = str(env.get(var.group(1), var.group(2) or ""))
return tag


def _unpinned(compose: Path) -> list[str]:
"""Return image refs whose tag is not a version."""
env = _env_defaults(compose)
found = []
for ref in _IMAGE_RE.findall(text):
for ref in _IMAGE_RE.findall(compose.read_text(encoding="utf-8")):
# Environment-substituted refs (${FOO}) are resolved at render time;
# the pin lives wherever that variable is defined, not here.
if ref.startswith("${"):
continue
# A digest pin (repo@sha256:...) is stricter than a tag. Accept it.
if "@sha256:" in ref:
continue
# Strip the registry host before looking for the tag separator, so
# `registry:5000/img` is not mistaken for an `img:5000` tag.
last_segment = ref.rsplit("/", 1)[-1]
tag = last_segment.split(":", 1)[1] if ":" in last_segment else "latest"
if tag == "latest" and ref not in KNOWN_UNPINNED:
tag = _tag(ref, env)
# Built from the stacklet's Dockerfile, whose FROM line is the pin.
if tag == "local":
continue
if not _VERSION_RE.match(tag):
found.append(ref)
return found

Expand All @@ -66,26 +88,10 @@ def test_compose_files_exist():
assert _compose_files(), "no stacklet compose files found - glob is wrong"


def test_ratchet_has_no_stale_entries():
# If an image gets pinned but its entry stays behind, the exemption sits
# there covering a name nothing uses - and quietly re-exempts that image
# the day someone reintroduces it. Shrinking the list must be mandatory.
all_refs = {
ref
for path in _compose_files()
for ref in _IMAGE_RE.findall(path.read_text(encoding="utf-8"))
}
stale = KNOWN_UNPINNED - all_refs
assert not stale, (
"KNOWN_UNPINNED lists images no compose file uses - pinned already? "
f"remove them: {', '.join(sorted(stale))}"
)


def test_no_floating_image_tags():
offenders: dict[str, list[str]] = {}
for path in _compose_files():
unpinned = _unpinned(path.read_text(encoding="utf-8"))
unpinned = _unpinned(path)
if unpinned:
offenders[str(path.relative_to(REPO_ROOT))] = unpinned

Expand Down
Loading