From d275ba3c14c2c5903ea1cefe123fa2cfdd574773 Mon Sep 17 00:00:00 2001 From: Florian Sylvain Date: Mon, 5 Oct 2026 04:52:49 +0200 Subject: [PATCH] feat: complete dockerization (optional) --- .dockerignore | 17 ++ .env.example | 1 + .gitignore | 1 + Dockerfile | 30 +++ README.md | 11 + Taskfile.yml | 126 +++++++++++ api/dependecyInjection.go | 4 +- cmd/server/main.go | 1 + docker-compose.build.yml | 7 + docker-compose.yml | 24 +++ docker.env.example | 13 ++ docs/configuration.md | 1 + docs/deployment.md | 4 +- docs/docker.md | 202 ++++++++++++++++++ features/container_update_guidance.feature | 87 ++++++++ features/container_update_guidance.qa.md | 25 +++ features/docker-deployment.feature | 45 ++++ features/docker-deployment.qa.md | 98 +++++++++ features/docker_image_publishing.feature | 71 ++++++ features/docker_image_publishing.qa.md | 29 +++ internal/domain/update/updateDomain.go | 1 + internal/infrastructure/config/settings.go | 2 + .../update/containerHelperDetector.go | 16 ++ .../useCases/GetUpdateStatus.go | 16 +- test/container_update_guidance_http_test.go | 198 +++++++++++++++++ test/update_download_test.go | 2 +- test/update_http_test.go | 2 +- test/update_restart_fakes_test.go | 2 +- test/update_restart_http_test.go | 2 +- test/update_restart_usecase_test.go | 2 +- web/src/components/ServerCommand.vue | 12 +- web/src/components/UpdatesCard.vue | 45 +++- web/src/composables/useUpdate.ts | 1 + web/src/i18n/messages.ts | 2 + web/src/lib/updateFormat.ts | 12 +- web/src/views/SettingsView.vue | 1 + .../SettingsContainerUpdates.spec.ts | 150 +++++++++++++ web/src/views/__tests__/updateTestKit.ts | 1 + 38 files changed, 1244 insertions(+), 20 deletions(-) create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 docker-compose.build.yml create mode 100644 docker-compose.yml create mode 100644 docker.env.example create mode 100644 docs/docker.md create mode 100644 features/container_update_guidance.feature create mode 100644 features/container_update_guidance.qa.md create mode 100644 features/docker-deployment.feature create mode 100644 features/docker-deployment.qa.md create mode 100644 features/docker_image_publishing.feature create mode 100644 features/docker_image_publishing.qa.md create mode 100644 internal/infrastructure/update/containerHelperDetector.go create mode 100644 test/container_update_guidance_http_test.go create mode 100644 web/src/views/__tests__/SettingsContainerUpdates.spec.ts diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..5407dd3 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,17 @@ +.git +.gitignore +.dockerignore +.env +docker.env +tmp/ +bin/ +data/ +node_modules +web/node_modules +internal/infrastructure/assets/dist +.task/ +.claude/ +.idea +.vscode +*.db +*.exe diff --git a/.env.example b/.env.example index e97f4e4..5780c38 100644 --- a/.env.example +++ b/.env.example @@ -15,3 +15,4 @@ AUTH_RATE_LIMIT_MAX_ATTEMPTS=5 AUTH_RATE_LIMIT_WINDOW_SECONDS=60 TRUSTED_PROXIES= UPDATE_FEED_URL=https://git.floriansylvain.fr +RENEWCMS_CONTAINER=false diff --git a/.gitignore b/.gitignore index 67c4664..26f5157 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,7 @@ dist/ # Environment .env +docker.env # Generated files tmp/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..f2589c8 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,30 @@ +FROM oven/bun:1-alpine AS web +WORKDIR /src/web +COPY web/package.json web/bun.lock ./ +RUN bun install --frozen-lockfile +COPY web/ ./ +RUN bun run build-only + +FROM golang:1.27.1-alpine AS server +ARG VERSION=dev +ENV CGO_ENABLED=0 +WORKDIR /src +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +COPY --from=web /src/internal/infrastructure/assets/dist ./internal/infrastructure/assets/dist +RUN go build -trimpath -ldflags "-s -w -X main.version=${VERSION}" -o /out/renewcms ./cmd/server + +FROM alpine:3.22 +RUN addgroup -S -g 10001 renewcms \ + && adduser -S -u 10001 -G renewcms -H -s /sbin/nologin renewcms \ + && mkdir /data \ + && chown renewcms:renewcms /data +COPY --from=server /out/renewcms /usr/local/bin/renewcms +ENV RENEWCMS_CONTAINER=true +USER renewcms +WORKDIR /data +VOLUME /data +EXPOSE 8080 +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 CMD ["wget", "-qO-", "http://127.0.0.1:8080/healthz"] +ENTRYPOINT ["/usr/local/bin/renewcms"] diff --git a/README.md b/README.md index 6cda28d..51e64b7 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,16 @@ cp .env.example .env # fill in PUBLIC_URL, JWT_SECRET and SMTP_* Data is stored in `./data/` by default. For a real server (systemd, HTTPS, backups), see **[docs/deployment.md](docs/deployment.md)**. +### Run with Docker + +```sh +cp docker.env.example docker.env # fill in PUBLIC_URL, JWT_SECRET and SMTP_* +docker compose pull +docker compose up -d # http://localhost:8080 +``` + +Upgrade with `docker compose pull && docker compose up -d`. The image `git.floriansylvain.fr/fsylvain/renewcms` is published by the maintainer with `task docker:publish` (no CI). Tags: `vX.Y.Z`, `X.Y.Z`, `X.Y` and `latest` for a stable release; `vX.Y.Z-
`, `X.Y.Z-
` and the moving `beta` or `rc` for a pre-release (`latest` never moves for those, so the default is `beta` for now). To build from source use the override described in **[docs/docker.md](docs/docker.md)**.
+
 ### Updates from the browser
 
 On Linux with systemd, install v2.6.0-beta or newer manually, then follow [the browser-update setup guide](docs/deployment.md#browser-updates). It covers finding the actual database directory, installing the helper, testing a restart and troubleshooting. Other platforms use manual upgrades.
@@ -35,6 +45,7 @@ task dev                # API with hot reload + Vite HMR → http://localhost:51
 |---|---|
 | [docs/development.md](docs/development.md) | Dev setup, how Vite and the API fit together, tests, lint, `task` commands |
 | [docs/deployment.md](docs/deployment.md) | Building release binaries, running as a service, HTTPS, backups, upgrades |
+| [docs/docker.md](docs/docker.md) | Running RenewCMS in Docker: image, Compose, data volume, backups, reverse proxy, tags, upgrades, publishing |
 | [docs/configuration.md](docs/configuration.md) | Every environment variable |
 | [docs/architecture.md](docs/architecture.md) | Backend layering and how the SPA is embedded |
 
diff --git a/Taskfile.yml b/Taskfile.yml
index 554a56e..063a5bd 100644
--- a/Taskfile.yml
+++ b/Taskfile.yml
@@ -170,6 +170,132 @@ tasks:
     cmds:
       - ./bin/{{.BINARY}}{{exeExt}}
 
+  docker:build:
+    desc: Build the Docker image tagged with the git describe version and latest
+    cmds:
+      - docker build --build-arg VERSION={{.VERSION}} -t renewcms:{{.VERSION}} -t renewcms:latest .
+
+  docker:run:
+    desc: Run the image once on a named volume, reading docker.env, on http://localhost:8080
+    cmds:
+      - docker run --rm --name renewcms -p 8080:8080 --env-file docker.env -e DB_FILE=/data/renewcms.db -e UPLOAD_DIR=/data/uploads -v renewcms-data:/data --stop-timeout 15 renewcms:latest
+
+  docker:up:
+    desc: Build from the local sources and start the Compose service in the background
+    env:
+      VERSION: '{{.VERSION}}'
+    cmds:
+      - docker compose -f docker-compose.yml -f docker-compose.build.yml up -d --build
+
+  docker:pull:
+    desc: Pull the published image (RENEWCMS_IMAGE, default the beta tag)
+    cmds:
+      - docker compose pull
+
+  docker:upgrade:
+    desc: Pull the published image and recreate the Compose service on it
+    cmds:
+      - docker compose pull
+      - docker compose up -d
+
+  docker:down:
+    desc: Stop and remove the Compose service (the data volume is kept)
+    cmds:
+      - docker compose down
+
+  docker:logs:
+    desc: Follow the Compose service logs
+    cmds:
+      - docker compose logs -f renewcms
+
+  docker:publish:
+    desc: 'Build and push the multi-arch image to the registry. Usage, task docker:publish [-- --dry-run]'
+    vars:
+      REGISTRY_IMAGE:
+        sh: echo "${REGISTRY_IMAGE-git.floriansylvain.fr/fsylvain/renewcms}"
+    cmds:
+      - cmd: |
+          IMAGE='{{.REGISTRY_IMAGE}}'
+          V='{{.VERSION}}'
+          ARGS='{{.CLI_ARGS}}'
+          fail() { echo "docker:publish refused: $1" >&2; exit 1; }
+          DRY=no
+          case "$ARGS" in
+            "") ;;
+            "--dry-run") DRY=yes ;;
+            *) fail "unknown argument '$ARGS' (only --dry-run is accepted)" ;;
+          esac
+          [ -n "$IMAGE" ] || fail "REGISTRY_IMAGE is empty"
+          case "$V" in
+            v*) ;;
+            *) fail "VERSION '$V' must be an exact tag vX.Y.Z or vX.Y.Z-
" ;;
+          esac
+          R="${V#v}"
+          CORE="${R%%-*}"
+          PRE=""
+          [ "$CORE" = "$R" ] || PRE="${R#*-}"
+          MAJOR="${CORE%%.*}"
+          REST="${CORE#*.}"
+          MINOR="${REST%%.*}"
+          PATCH="${REST#*.}"
+          for N in "$MAJOR" "$MINOR" "$PATCH"; do
+            case "$N" in
+              ""|*[!0-9]*) fail "VERSION '$V' must be an exact tag vX.Y.Z or vX.Y.Z-
" ;;
+            esac
+          done
+          [ "$CORE" = "$MAJOR.$MINOR.$PATCH" ] || fail "VERSION '$V' must be an exact tag vX.Y.Z or vX.Y.Z-
"
+          if [ "$R" != "$CORE" ]; then
+            case "$PRE" in
+              ""|*[!0-9A-Za-z.-]*) fail "VERSION '$V' has an invalid pre-release part" ;;
+            esac
+            PRENAME="${PRE%%[!A-Za-z]*}"
+            [ -n "$PRENAME" ] || fail "VERSION '$V' pre-release must start with letters, for example -beta or -rc.1"
+          fi
+          if [ "$DRY" = no ]; then
+            [ -z "$(git status --porcelain)" ] || fail "the work tree has uncommitted changes"
+            [ "$(git describe --tags --exact-match 2>/dev/null)" = "$V" ] || fail "VERSION '$V' is not an exact git tag on HEAD (an override is accepted only with --dry-run)"
+          fi
+          TAGS="$V $R"
+          if [ -n "$PRE" ]; then
+            TAGS="$TAGS $PRENAME"
+          else
+            TAGS="$TAGS $MAJOR.$MINOR latest"
+          fi
+          ARGS_TAGS=""
+          LIST=""
+          for T in $TAGS; do
+            ARGS_TAGS="$ARGS_TAGS -t $IMAGE:$T"
+            LIST="$LIST, $T"
+          done
+          echo "image: $IMAGE"
+          echo "tags: ${LIST#, }"
+          if [ "$DRY" = yes ]; then
+            echo "dry run: building for the host platform only, loading locally, pushing nothing"
+            docker buildx build --load --build-arg VERSION="$V" $ARGS_TAGS .
+            exit 0
+          fi
+          BUILDER="$(docker buildx inspect 2>&1)" || fail "docker buildx is not available or no builder is selected (see docs/docker.md, Maintainer: publishing)"
+          STORE="$(docker info 2>&1)"
+          case "$BUILDER" in
+            *linux/amd64*) ;;
+            *) fail "the selected buildx builder does not support linux/amd64 (see docs/docker.md, Maintainer: publishing)" ;;
+          esac
+          case "$BUILDER" in
+            *linux/arm64*) ;;
+            *) fail "the selected buildx builder does not support linux/arm64: enable QEMU/binfmt (see docs/docker.md, Maintainer: publishing)" ;;
+          esac
+          case "$BUILDER" in
+            *"Driver:"*"docker-container"*|*"Driver:"*"kubernetes"*|*"Driver:"*"remote"*) ;;
+            *)
+              case "$STORE" in
+                *io.containerd.snapshotter*) ;;
+                *) fail "the selected builder uses the plain docker driver without the containerd image store, which cannot build multi-platform images: run 'docker buildx create --use' (see docs/docker.md, Maintainer: publishing)" ;;
+              esac
+              ;;
+          esac
+          docker buildx build --platform linux/amd64,linux/arm64 --build-arg VERSION="$V" $ARGS_TAGS --push .
+        silent: true
+
   clean:
     desc: Remove build output, the built SPA and tmp/
     cmds:
diff --git a/api/dependecyInjection.go b/api/dependecyInjection.go
index dc85da3..0033a09 100644
--- a/api/dependecyInjection.go
+++ b/api/dependecyInjection.go
@@ -151,7 +151,7 @@ func getRepositories(db *gorm.DB, settings config.Settings) *Repositories {
 		ReleaseFeed:       update.NewGiteaReleaseFeed(settings.UpdateFeedURL),
 		UpdateStore:       update.NewJSONStatusStore(filepath.Join(dataDir, updateStatusFileName)),
 		UpdateJobs:        update.NewJSONJobStore(filepath.Join(dataDir, updateJobFileName)),
-		UpdateHelper:      update.NewFileHelperDetector(filepath.Join(dataDir, updateHelperFileName)),
+		UpdateHelper:      update.NewContainerHelperDetector(update.NewFileHelperDetector(filepath.Join(dataDir, updateHelperFileName)), settings.Container),
 		AssetFetcher:      update.NewHTTPAssetFetcher(settings.UpdateFeedURL),
 		SignatureVerifier: update.NewEd25519SignatureVerifier(releasePublicKey()),
 		StagedRunner:      update.NewExecStagedBinaryRunner(),
@@ -197,7 +197,7 @@ func getUseCases(repos *Repositories, settings config.Settings) *UseCases {
 		MergeMediaUseCase:           useCases.NewMergeMediaUseCase(repos.ImageRepo, repos.ArticleRepo),
 		GetMediaUsageUseCase:        useCases.NewGetMediaUsageUseCase(repos.ImageRepo, repos.ArticleRepo),
 		GetSettingsUseCase:          useCases.NewGetSettingsUseCase(settings.Version),
-		GetUpdateStatusUseCase:      useCases.NewGetUpdateStatusUseCase(repos.UpdateStore, repos.UpdateJobs, repos.UpdateHelper, repos.UpdateResults, repos.RestartRequests, settings.Version, time.Now),
+		GetUpdateStatusUseCase:      useCases.NewGetUpdateStatusUseCase(repos.UpdateStore, repos.UpdateJobs, repos.UpdateHelper, repos.UpdateResults, repos.RestartRequests, settings.Version, settings.Container, time.Now),
 		CheckForUpdateUseCase:       useCases.NewCheckForUpdateUseCase(repos.ReleaseFeed, repos.UpdateStore, settings.Version, time.Now),
 		StartUpdateDownloadUseCase:  startUpdateDownloadUseCase(repos, settings),
 		RequestRestartUseCase:       useCases.NewRequestRestartUseCase(repos.UpdateStore, repos.UpdateJobs, repos.UpdateHelper, repos.UpdateResults, repos.RestartRequests, settings.Version, time.Now),
diff --git a/cmd/server/main.go b/cmd/server/main.go
index 918d67c..6f08ff8 100644
--- a/cmd/server/main.go
+++ b/cmd/server/main.go
@@ -49,6 +49,7 @@ var envDefaults = map[string]string{
 	"AUTH_RATE_LIMIT_WINDOW_SECONDS": "60",
 	"TRUSTED_PROXIES":                "",
 	"UPDATE_FEED_URL":                "https://git.floriansylvain.fr",
+	"RENEWCMS_CONTAINER":             "false",
 }
 
 const versionFlag = "--version"
diff --git a/docker-compose.build.yml b/docker-compose.build.yml
new file mode 100644
index 0000000..f5c47e6
--- /dev/null
+++ b/docker-compose.build.yml
@@ -0,0 +1,7 @@
+services:
+  renewcms:
+    build:
+      context: .
+      args:
+        VERSION: ${VERSION:-dev}
+    image: renewcms:${VERSION:-dev}
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..11a6497
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,24 @@
+services:
+  renewcms:
+    image: ${RENEWCMS_IMAGE:-git.floriansylvain.fr/fsylvain/renewcms:beta}
+    restart: unless-stopped
+    env_file:
+      - docker.env
+    environment:
+      DB_FILE: /data/renewcms.db
+      UPLOAD_DIR: /data/uploads
+      RENEWCMS_CONTAINER: "true"
+    ports:
+      - "8080:8080"
+    volumes:
+      - renewcms-data:/data
+    stop_grace_period: 15s
+    healthcheck:
+      test: ["CMD", "wget", "-qO-", "http://127.0.0.1:8080/healthz"]
+      interval: 30s
+      timeout: 5s
+      retries: 3
+      start_period: 10s
+
+volumes:
+  renewcms-data:
diff --git a/docker.env.example b/docker.env.example
new file mode 100644
index 0000000..8fffdb5
--- /dev/null
+++ b/docker.env.example
@@ -0,0 +1,13 @@
+PUBLIC_URL=https://cms.example.com
+JWT_SECRET=replace-with-output-of-openssl-rand-hex-32
+SMTP_HOST=smtp.example.com
+SMTP_PORT=465
+SMTP_LOGIN=noreply@example.com
+SMTP_PASSWORD=replace-me
+SMTP_SENDER=noreply@example.com
+
+ENVIRONMENT=production
+CORS_ALLOWED_ORIGINS=
+AUTH_RATE_LIMIT_MAX_ATTEMPTS=5
+AUTH_RATE_LIMIT_WINDOW_SECONDS=60
+TRUSTED_PROXIES=
diff --git a/docs/configuration.md b/docs/configuration.md
index b3ed5a7..48f0638 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -30,6 +30,7 @@ Copy [`.env.example`](../.env.example) to `.env` to get started. When a required
 | `AUTH_RATE_LIMIT_MAX_ATTEMPTS` | `5`           | Maximum number of requests a single client IP may send to `POST /v1/auth/login`, `POST /v1/auth/password-reset` or the invite-accept pair (`GET /v1/auth/invite` and `POST /v1/auth/invite/accept`, sharing one counter) within the window, tracked independently per route.                                                          |
 | `AUTH_RATE_LIMIT_WINDOW_SECONDS` | `60`        | Length, in seconds, of the fixed rate-limit window for the routes above.                                                                                                                                                    |
 | `TRUSTED_PROXIES`      | *(empty)*            | Semicolon-separated list of exact IP addresses (no CIDR) of reverse proxies allowed to supply the client IP via `X-Real-IP` for the rate limiting of the routes above. When a request's connection address is not in this list, any `X-Real-IP` or `X-Forwarded-For` header it sends is ignored and the connection address is used instead. See [deployment.md](deployment.md).      |
+| `RENEWCMS_CONTAINER` | `false` | Set to `true` (case-insensitive; any other value means `false`) when the app runs in a container. Settings then tells the owner to update by pulling a new image instead of offering browser updates, and `can_apply` is always `false`. The Dockerfile and `docker-compose.yml` set it to `true`. Update checks still work. |
 | `UPDATE_FEED_URL`      | `https://git.floriansylvain.fr` | Base URL of the Gitea server that publishes RenewCMS releases. The Settings page asks `{UPDATE_FEED_URL}/api/v1/repos/fsylvain/RenewCMS/releases` when the owner checks for updates. The last result is kept in `update-status.json`, next to `DB_FILE`. |
 
 Relative paths are resolved against the process's working directory. In production, use absolute paths (see [deployment.md](deployment.md)).
diff --git a/docs/deployment.md b/docs/deployment.md
index d19d134..248c3c8 100644
--- a/docs/deployment.md
+++ b/docs/deployment.md
@@ -10,6 +10,8 @@ RenewCMS ships as **one self-contained executable**. It serves:
 
 Runtime state includes the SQLite database (`DB_FILE`), uploads (`UPLOAD_DIR`), and update records, staged binaries and backups beside the database. Running a release binary needs no Go, Bun or Node installation. Browser updates additionally require Linux with systemd and the helper setup below.
 
+To run it in a container instead, see [docker.md](docker.md). The published image `git.floriansylvain.fr/fsylvain/renewcms` is upgraded with `docker compose pull && docker compose up -d`. Tags are `vX.Y.Z`, `X.Y.Z`, `X.Y` and `latest` for a stable release, and `vX.Y.Z-
`, `X.Y.Z-
` and the moving `` (`beta`, `rc`) for a pre-release, which never moves `latest`. Maintainers publish with `task docker:publish` after `docker login git.floriansylvain.fr`; there is no CI. See [Maintainer: publishing](docker.md#maintainer-publishing).
+
 ## 1. Build
 
 On any machine with the [dev prerequisites](development.md#prerequisites):
@@ -33,7 +35,7 @@ To check a download by hand: `go run ./cmd/releasesign verify checksums.txt`, wi
 
 ### Update checks
 
-The owner's Settings page asks the release feed for the newest release (see `UPDATE_FEED_URL` in [configuration.md](configuration.md)) and keeps the last result in `update-status.json`, next to `DB_FILE`. Back it up with the rest of the data directory if you like; it is rebuilt by the next check when missing. The Settings page reports a newer version. To install it from the browser, set up [Browser updates](#browser-updates) once on the server.
+The owner's Settings page asks the release feed for the newest release (see `UPDATE_FEED_URL` in [configuration.md](configuration.md)) and keeps the last result in `update-status.json`, next to `DB_FILE`. Back it up with the rest of the data directory if you like; it is rebuilt by the next check when missing. The Settings page reports a newer version. To install it from the browser, set up [Browser updates](#browser-updates) once on the server. In Docker (`RENEWCMS_CONTAINER=true`) browser updates are not offered; Settings tells the owner to pull a new image instead, see [docker.md](docker.md).
 
 ### Update downloads
 
diff --git a/docs/docker.md b/docs/docker.md
new file mode 100644
index 0000000..35f9f68
--- /dev/null
+++ b/docs/docker.md
@@ -0,0 +1,202 @@
+# Docker
+
+RenewCMS runs as a container. The image is published to the registry at `git.floriansylvain.fr/fsylvain/renewcms`, so site operators pull it and never build from source; it can also be built from this repository. The image holds only the single binary (SPA embedded) on Alpine, runs as a non-root user (uid 10001) and keeps all state on one volume mounted at `/data`. It reuses the configuration contract described in [configuration.md](configuration.md); there are no Docker-specific variables.
+
+Requirements: Docker with Compose v2. [task](https://taskfile.dev) is optional for operators and needed to build from source or to publish. Building needs no local Go or Bun. The image build bundles the SPA with `vite build` only; the `vue-tsc` type-check is not run inside the image (it does not work under the Bun-only build image), so keep running `bun run type-check` locally.
+
+## Quick start
+
+1. Copy the example env file and edit it:
+
+   ```sh
+   cp docker.env.example docker.env
+   ```
+
+   Set `PUBLIC_URL`, the `SMTP_*` values and a generated secret (`openssl rand -hex 32`) for `JWT_SECRET`. `docker.env` is git-ignored and excluded from the build context, so it never ends up in an image. Never commit it.
+
+2. Pull and start the published image:
+
+   ```sh
+   docker compose pull
+   docker compose up -d
+   ```
+
+   `docker-compose.yml` uses `image: ${RENEWCMS_IMAGE:-git.floriansylvain.fr/fsylvain/renewcms:beta}`. The default tag is `beta` because `latest` does not exist until the first stable release (the default changes to `latest` then). Set `RENEWCMS_IMAGE` in your shell or in a `.env` file next to the compose file to pin another tag, for example `RENEWCMS_IMAGE=git.floriansylvain.fr/fsylvain/renewcms:v2.9.0-beta`.
+
+   To build from the local sources instead, see [Building from source](#building-from-source).
+
+3. Check it: `curl http://localhost:8080/healthz` returns `ok`, and `http://localhost:8080/` serves the SPA.
+
+Other tasks: `task docker:pull`, `task docker:upgrade` (pull, then `docker compose up -d`), `task docker:up` (local build, see below), `task docker:build` (image only, tagged with the `git describe` version and `latest`), `task docker:run` (one-off foreground run on the volume `renewcms-data`, without Compose), `task docker:logs` and `task docker:down`.
+
+If a required variable is missing the container exits non-zero and the log names it, for example `Environment variable JWT_SECRET is not set`.
+
+### Local trial over plain HTTP
+
+`ENVIRONMENT` defaults to `production`, which makes the auth cookie `Secure`: browsers do not store it over `http://`, so login appears to fail. For a local trial on `http://localhost:8080`, set `ENVIRONMENT=development` and `PUBLIC_URL=http://localhost:8080` in `docker.env`. Use `production` only behind HTTPS.
+
+## Building from source
+
+`docker-compose.build.yml` adds the `build:` section and tags the result `renewcms:${VERSION:-dev}`. Use it as an override:
+
+```sh
+docker compose -f docker-compose.yml -f docker-compose.build.yml up -d --build
+```
+
+`task docker:up` does the same and passes the `git describe` version as `VERSION`. To check what Compose resolves, run `docker compose config`: without the override it shows the registry image and no `build` section, with it the `build` section is present.
+
+## Image tags
+
+| Release | Tags pushed |
+|---|---|
+| `vX.Y.Z` (stable) | `vX.Y.Z`, `X.Y.Z`, `X.Y`, `latest` |
+| `vX.Y.Z-
` (for example `v2.9.0-beta`, `v2.9.0-rc.1`) | `vX.Y.Z-
`, `X.Y.Z-
`, and the moving tag `` (`beta`, `rc`) |
+
+A pre-release never moves `latest`. Images are published for `linux/amd64` and `linux/arm64`. `renewcms --version` in the image prints the exact release tag. Images are not signed; the Ed25519 key signs the release binaries only.
+
+## Environment
+
+Compose reads `docker.env` through `env_file` and sets `DB_FILE=/data/renewcms.db` and `UPLOAD_DIR=/data/uploads` itself. Everything in [configuration.md](configuration.md) can go in `docker.env`. To publish another host port, change the left side of `ports` in `docker-compose.yml`; keep `PORT` at 8080 unless you also change the healthcheck.
+
+The container receives SIGTERM on stop, and RenewCMS shuts down gracefully within 10 seconds. Compose uses `stop_grace_period: 15s`. The healthcheck calls `/healthz` inside the container.
+
+## Data volume
+
+State lives on the named volume `renewcms-data` (Compose prefixes it with the project name, usually `renewcms_renewcms-data`; check with `docker volume ls`). It holds the SQLite database, uploads, and update records.
+
+- `docker compose down` removes the container and keeps the volume. The data survives and `docker compose up -d` brings it back.
+- **`docker compose down -v` deletes the volume and all data.** The next start is an empty instance.
+
+### Backup
+
+Replace `VOLUME` with the real volume name. The image has no `sqlite3`, so stop the service for a consistent copy:
+
+```sh
+docker compose stop
+docker run --rm -v VOLUME:/data -v "$PWD":/backup alpine tar czf /backup/renewcms-backup.tar.gz -C /data .
+docker compose start
+```
+
+### Restore into a fresh volume
+
+```sh
+docker compose down -v
+docker volume create VOLUME
+docker run --rm -v VOLUME:/data -v "$PWD":/backup alpine sh -c "tar xzf /backup/renewcms-backup.tar.gz -C /data && chown -R 10001:10001 /data"
+docker compose up -d
+```
+
+## Reverse proxy and TLS
+
+With `ENVIRONMENT=production` the site must be served over HTTPS, so put a reverse proxy in front and set `PUBLIC_URL` to the public `https://` address. Bind the published port to the host only when the proxy runs on the same host, by changing `ports` to `"127.0.0.1:8080:8080"`.
+
+Caddy on the host:
+
+```caddyfile
+cms.example.com {
+	reverse_proxy 127.0.0.1:8080 {
+		header_up X-Real-IP {remote_host}
+	}
+}
+```
+
+nginx on the host:
+
+```nginx
+server {
+    listen 443 ssl;
+    server_name cms.example.com;
+    client_max_body_size 20m;
+
+    location / {
+        proxy_pass http://127.0.0.1:8080;
+        proxy_set_header Host $host;
+        proxy_set_header X-Real-IP $remote_addr;
+        proxy_set_header X-Forwarded-Proto $scheme;
+    }
+}
+```
+
+Set `TRUSTED_PROXIES` to the proxy address as the container sees it, otherwise the login rate limiter ignores `X-Real-IP` and counts every client as the proxy. With a proxy on the host and the default bridge network, that is the bridge gateway (check with `docker network inspect`, often `172.17.0.1` or the Compose network gateway). With a proxy container on the same Compose network, it is that container's IP. The value is a semicolon-separated list of exact IPs, no CIDR. See [deployment.md](deployment.md#4-put-https-in-front).
+
+## SMTP
+
+RenewCMS sends registration and password-reset mail through the `SMTP_*` variables. Use your provider's host and port in `docker.env`.
+
+To use a mail server running on the Docker host, set `SMTP_HOST=host.docker.internal`. Docker Desktop resolves that name. On Linux add the mapping to the service in `docker-compose.yml`:
+
+```yaml
+    extra_hosts:
+      - "host.docker.internal:host-gateway"
+```
+
+The host MTA must listen on an address reachable from the container, not only on `127.0.0.1`.
+
+If SMTP is unreachable, the server still starts. Only the actions that send mail (registration, password reset, invitations) fail, and the error appears in `docker compose logs renewcms`.
+
+## Upgrading
+
+1. Back up the volume (see above).
+2. Pull the new image and recreate the container on the same volume:
+
+   ```sh
+   docker compose pull
+   docker compose up -d
+   ```
+
+   `task docker:upgrade` runs both. The schema is migrated at startup, and there is no down-migration.
+3. Check `docker compose logs renewcms` for the new version line, or `docker run --rm git.floriansylvain.fr/fsylvain/renewcms:beta --version`. The owner's Settings page also shows the running version.
+
+To roll back, set `RENEWCMS_IMAGE` to the previous tag, run `docker compose up -d`, and restore the backup if the schema changed.
+
+If you built from source, upgrade with `git pull` and `task docker:up` instead.
+
+## Maintainer: publishing
+
+Publishing is a manual, maintainer-run task, like `task release`. There is no CI pipeline.
+
+1. Tag the release and keep the work tree clean:
+
+   ```sh
+   git tag v2.9.0-beta
+   ```
+
+2. Log in once to the registry. The task never reads or stores credentials:
+
+   ```sh
+   docker login git.floriansylvain.fr
+   ```
+
+3. Make sure a buildx builder can build both platforms. Check with `docker buildx ls`. With the containerd image store (the Docker Desktop default) the default builder already works. Otherwise create one, and on a Linux host install the QEMU emulators:
+
+   ```sh
+   docker buildx create --use
+   docker run --privileged --rm tonistiigi/binfmt --install arm64
+   ```
+
+4. Publish:
+
+   ```sh
+   task docker:publish
+   ```
+
+   `REGISTRY_IMAGE` defaults to `git.floriansylvain.fr/fsylvain/renewcms` and `VERSION` to `git describe --tags`. The task refuses to run, before building anything, when `REGISTRY_IMAGE` is empty, when `VERSION` is not an exact tag `vX.Y.Z` or `vX.Y.Z-
` on HEAD, when the work tree is dirty, or when the builder cannot build `linux/amd64` and `linux/arm64`. It prints the tags it pushes.
+
+### Dry run
+
+```sh
+task docker:publish -- --dry-run
+task docker:publish VERSION=v2.9.0-rc.1 -- --dry-run
+```
+
+The dry run builds for the host platform only, loads the image into the local Docker, pushes nothing and needs no login. It prints the tags a real run would push. A `VERSION` override is accepted only with `--dry-run`, and a dirty tree is allowed there. Remove the loaded images afterwards with `docker rmi`.
+
+### After pushing
+
+- `docker buildx imagetools inspect git.floriansylvain.fr/fsylvain/renewcms:` lists `linux/amd64` and `linux/arm64`.
+- The moving tag (`beta`) points at the new digest, and `latest` did not move for a pre-release.
+- On a clean machine, `docker compose pull && docker compose up -d` starts the new image and Settings shows the new version.
+
+## Limitations
+
+Browser updates are unsupported in Docker (they need Linux with systemd, and a binary replaced inside a container is lost when it is recreated). The image and `docker-compose.yml` set `RENEWCMS_CONTAINER=true`, so the owner's Settings page shows when a newer version exists and tells them to pull a new image: `docker compose pull && docker compose up -d`. There is no Update or Restart button. Upgrade by pulling the image as above, or by rebuilding it if you build from source.
diff --git a/features/container_update_guidance.feature b/features/container_update_guidance.feature
new file mode 100644
index 0000000..d9263ed
--- /dev/null
+++ b/features/container_update_guidance.feature
@@ -0,0 +1,87 @@
+Feature: Container-aware update guidance
+
+  As the owner of a RenewCMS site running in Docker
+  I want Settings to say "update by pulling a new image" instead of "Not prepared"
+  So that I never see a browser-update flow that cannot work in a container
+
+  Background:
+    Given browser updates need Linux with systemd and the helper that "renewcms enable-browser-updates" configures
+    And a binary replaced inside a container is lost when the container is recreated,
+      so in a container the update is a new image: "docker compose pull && docker compose up -d"
+    And config.Settings gains Container bool, read from the env var RENEWCMS_CONTAINER (default "false", set to "true" by the Dockerfile and docker-compose.yml)
+    And RENEWCMS_CONTAINER is added to envDefaults in cmd/server/main.go, to .env.example and to docs/configuration.md; it is not in requiredEnvVars
+    And only "true" (case-insensitive) enables container mode; any other value means false
+    And the update status use case receives the bool through its constructor from api/dependecyInjection.go; internal/domain/update stays stdlib only
+    And the update check, update-status.json and the release feed work unchanged in container mode
+    And GET /v1/system/update gains a 13th key, container (bool), after started_at; the other 12 keys are unchanged
+    And in container mode can_apply is false for everyone, even if update-helper.json exists in the data directory
+    And POST /v1/system/update/download and /restart keep their guards and answer 409 because can_apply is false (existing behaviour, not re-specified)
+    And errors stay plain text from http.Error
+    And the user approved the copy below and the 13th key on 2026-10-05; docs/design is read-only for agents, so the design files are updated by the user, and the visual gate for container states stays PENDING until a board frame exists
+
+  Scenario Outline: The status carries the container flag
+    Given RENEWCMS_CONTAINER is ""
+    When a verified user sends GET /v1/system/update
+    Then the JSON key container is 
+
+    Examples:
+      | value | flag  |
+      | true  | true  |
+      | TRUE  | true  |
+      | false | false |
+      |       | false |
+      | yes   | false |
+
+  Scenario: Container mode disables apply even with a helper
+    Given RENEWCMS_CONTAINER is "true" and HelperDetector reports the helper installed
+    When the owner sends GET /v1/system/update
+    Then can_apply is false and container is true
+    And a newer stored result still reports available true, latest and notes
+
+  Scenario: The check still works in a container
+    Given container mode and a feed that reports "v2.9.0-beta" newer than the running "v2.8.0-beta"
+    When the owner sends POST /v1/system/update/check
+    Then the status is 200 and available is true and the result is stored in update-status.json
+
+  Scenario Outline: Download and restart are refused in a container
+    Given container mode and a stored newer release
+    When the owner sends POST 
+    Then the status is 409 and no job starts and the fetcher and the restart request writer are not called
+
+    Examples:
+      | path                        |
+      | /v1/system/update/download  |
+      | /v1/system/update/restart   |
+
+  Scenario: Host installs are unchanged
+    Given RENEWCMS_CONTAINER is unset
+    Then container is false and can_apply follows the helper as before
+    And "Not prepared" still shows its "renewcms enable-browser-updates" command
+
+  Scenario: Settings, owner, container, update available (route /settings)
+    Given GET /v1/system/update resolves available true, container true, can_apply false, latest "v2.9.0-beta"
+    Then the block uses the "Not prepared" frame's layout: icon info, tone info
+    And the title reads "Version 2.9.0-beta is available"
+    And the text reads "You have v2.8.0-beta. This site runs in Docker, so it updates by pulling a new image. Run the commands below on the server."
+    And a server-command block reads "docker compose pull && docker compose up -d" with the Copy button ("Copied" with a check for 2 s, announced)
+    And a link "How to update" with arrow-up-right points to docs/docker.md
+    And no Update, Restart or "Check for updates" button is rendered
+    And the "renewcms enable-browser-updates" command and its note are not rendered
+
+  Scenario: Settings, owner, container, up to date
+    Given container true and available false
+    Then "You're up to date" shows as in the existing Up to date state, the Restart button is not rendered and the container hint is not rendered
+    And "Check for updates" is still offered
+
+  Scenario: Settings, admin, container
+    Given the session role is "admin", container true and available true
+    Then the existing Admin view shows unchanged ("Only the owner can update RenewCMS.") with no command block
+
+  Scenario: Settings, non-container, no helper
+    Given container false and can_apply false
+    Then the existing "Not prepared" state shows unchanged
+
+  Scenario: The shipped Docker setup turns container mode on
+    Given the Dockerfile and docker-compose.yml
+    Then both set RENEWCMS_CONTAINER=true
+    And docs/docker.md and docs/configuration.md describe the variable
diff --git a/features/container_update_guidance.qa.md b/features/container_update_guidance.qa.md
new file mode 100644
index 0000000..4c84104
--- /dev/null
+++ b/features/container_update_guidance.qa.md
@@ -0,0 +1,25 @@
+# QA: Container update guidance
+
+## Automated (Go, test/, httptest)
+1. Fakes: in-memory update status store, fake FeedClient, fake HelperDetector, fake JobStore and restart writer, validator.InitValidator(). Mount NewSystemRouter on a fresh chi.NewRouter() with jwtauth.Verifier + Authenticator and a test jwtauth.New("HS256", ...).
+2. Parse RENEWCMS_CONTAINER through config.NewSettingsFromEnv with t.Setenv over the table in the feature (true, TRUE, false, empty, yes).
+3. GET status: container key present and equal to the table; the other 12 keys are unchanged.
+4. Container true with a helper present: can_apply false for the owner.
+5. POST check in container mode: 200, available true, stored.
+6. POST download and restart in container mode: 409, no fake called.
+7. Container false: the existing updates_* tests still pass.
+8. An admin receives can_apply false and container true.
+
+## SPA (Vitest, mocked /v1)
+- Container + available: the block, command, Copy, "How to update", and no buttons. Reuse the "Not prepared" sample data from the board.
+- Container + up to date: no Restart.
+- Admin: the existing admin view.
+- Non-container "Not prepared": unchanged.
+- Visual: container states are PENDING until a board frame exists; report as PENDING, never as passing. Existing states must still pass `task visual:check -- settings`.
+
+## Manual (real Docker, ./docker-compose.yml)
+1. `docker compose up -d --build`; confirm RENEWCMS_CONTAINER=true with `docker compose exec renewcms env`.
+2. Point UPDATE_FEED_URL at a feed with a newer release; log in as owner; /settings shows the container block, no Update or Restart.
+3. The Copy button puts the exact command on the clipboard.
+4. Run the shown command on a host that has a newer image; Settings shows the new version afterwards.
+5. Run the binary on the host without the variable: "Not prepared" shows the old command.
diff --git a/features/docker-deployment.feature b/features/docker-deployment.feature
new file mode 100644
index 0000000..360091c
--- /dev/null
+++ b/features/docker-deployment.feature
@@ -0,0 +1,45 @@
+Feature: Docker deployment
+  RenewCMS can run in a container built from the repository. The container
+  reuses the existing configuration contract in cmd/server/main.go
+  (requiredEnvVars, envDefaults); no new variables are introduced.
+
+  Background:
+    Given the image is built with the default build, without the nofrontend tag
+    And the container runs the binary directly as PID 1 under a non-root user
+    And the environment sets DB_FILE and UPLOAD_DIR to paths on a named volume
+    And the required variables PUBLIC_URL, JWT_SECRET, SMTP_LOGIN, SMTP_SENDER, SMTP_PASSWORD, SMTP_HOST and SMTP_PORT come from an env file at runtime
+
+  Scenario: Healthy start
+    When the container starts with every required variable set
+    Then it logs "RenewCMS  (production) listening on :8080"
+    And GET /healthz on the published port returns 200 with the body "ok"
+
+  Scenario: Frontend and API are both served
+    When the container is running
+    Then GET / returns 200 with the SPA index.html
+    And GET /v1/auth/registration returns 200 with a JSON body
+
+  Scenario Outline: A missing required variable stops the container
+    When the container starts without 
+    Then the process exits with a non-zero status
+    And the log contains "Environment variable  is not set"
+
+    Examples:
+      | variable      |
+      | PUBLIC_URL    |
+      | JWT_SECRET    |
+      | SMTP_LOGIN    |
+      | SMTP_SENDER   |
+      | SMTP_PASSWORD |
+      | SMTP_HOST     |
+      | SMTP_PORT     |
+
+  Scenario: Data survives container recreation
+    Given an account was registered while the container was running
+    When the container is removed and recreated on the same named volume
+    Then the same account can log in
+
+  Scenario: Graceful shutdown on SIGTERM
+    When the container receives SIGTERM
+    Then the log contains "shutting down..."
+    And the container exits with status 0 within 10 seconds
diff --git a/features/docker-deployment.qa.md b/features/docker-deployment.qa.md
new file mode 100644
index 0000000..d4f1372
--- /dev/null
+++ b/features/docker-deployment.qa.md
@@ -0,0 +1,98 @@
+# QA: Docker deployment
+
+Packaging and docs only. There is no Go test in test/ and no Vitest spec for this slice. All steps are manual command steps run from a clean checkout on a machine with Docker and Compose v2. Record results in the Notes table. Anything not run is reported NOT RUN, never PASS.
+
+## Preconditions
+- Clean checkout, no bin/, tmp/, node_modules or internal/infrastructure/assets/dist present.
+- docker.env created from docker.env.example. JWT_SECRET is generated (openssl rand -hex 32) and SMTP values are real or placeholders. Nothing is committed.
+- For local HTTP trial, ENVIRONMENT=development (otherwise the Secure cookie is not stored over HTTP).
+
+## Static checks (no Docker daemon needed)
+1. Dockerfile, .dockerignore and docker-compose.yml contain no comment lines (no line starting with `#`, no trailing ` #`).
+2. .dockerignore lists tmp/, bin/, node_modules (also web/node_modules), .git, internal/infrastructure/assets/dist, .env and docker.env.
+3. Dockerfile: three stages (bun, go, final). The Go build sets CGO_ENABLED=0, has no `-tags nofrontend`, and uses `-ldflags` containing `-X main.version=` from a build arg. The final stage has USER non-root, exec-form ENTRYPOINT (no `sh -c`), and no ENV, ARG or COPY holding JWT_SECRET or any SMTP_* value.
+4. docker-compose.yml: one service, a named volume mounted at the data directory, a `ports` entry, `env_file`, a healthcheck on /healthz, `stop_grace_period` >= 15s.
+5. git diff --stat shows no change under api/, internal/, web/src/, cmd/ and test/. Only Dockerfile, .dockerignore, docker-compose.yml, docker.env.example, Taskfile.yml, .gitignore, README.md and docs/ changed. No new env var appears in cmd/server/main.go.
+6. docs/docker.md exists and is linked from README.md and docs/deployment.md.
+7. `task --list` shows docker:build, docker:run, docker:up, docker:down and docker:logs.
+
+## Build
+8. `task docker:build` succeeds from the clean checkout, with no pre-built SPA.
+9. `docker run --rm  --version` prints the version, which is not `dev`.
+10. `docker image ls` size recorded. Flag it if it is above roughly 40 MB.
+11. `docker image inspect` shows a non-root User, and `docker run --rm --entrypoint id ` shows uid not 0.
+12. `docker history --no-trunc ` contains none of the values in docker.env.
+
+## Run
+13. `task docker:up` starts the service. `docker compose ps` shows it healthy within the healthcheck start period.
+14. `curl -i http://localhost:8080/healthz` returns 200 and body `ok`.
+15. `curl -i http://localhost:8080/` returns 200 text/html, and the SPA loads in a browser.
+16. `curl -i http://localhost:8080/v1/auth/registration` returns 200 with JSON.
+17. Logs show the listening line, and no permission error creating DB_FILE or UPLOAD_DIR on the volume.
+18. Register an account in the SPA, then upload an image, to confirm both the DB and UPLOAD_DIR are writable by the container user.
+
+## Persistence
+19. `docker compose down` (no -v), then `task docker:up`. The account from step 18 can log in and the image is still served.
+20. `docker compose down -v` removes the volume, and the next start gives an empty instance. Documented in docs/docker.md.
+21. The backup procedure in docs/docker.md is followed once and restores into a fresh volume.
+
+## Failure and shutdown
+22. For each required variable in turn, remove it from the env file and run `docker compose up`. The container exits non-zero and the log contains `Environment variable  is not set`.
+23. `docker compose stop` (timed) completes in under 10 s, the log contains `shutting down...`, and the exit code is 0. This proves PID 1 receives SIGTERM.
+24. Break the healthcheck (stop the app inside the container, or point it at the wrong port): the service becomes unhealthy.
+
+## SMTP (manual, needs real SMTP)
+25. With an external SMTP host, trigger a registration or password-reset email and confirm delivery.
+26. With a host MTA via host.docker.internal, repeat. On Linux this needs the extra_hosts mapping documented.
+27. Confirm the failure mode is documented when SMTP is unreachable.
+
+## Reverse proxy and TLS (manual)
+28. Put Caddy or nginx in front, per docs/docker.md, with ENVIRONMENT=production over HTTPS. Log in and confirm the Secure cookie is accepted.
+29. Set TRUSTED_PROXIES to the proxy address as seen by the container and confirm the login rate limiter uses X-Real-IP.
+
+## Upgrade
+30. Follow docs/docker.md: back up, rebuild with `task docker:build`, `task docker:up`. Data is intact and `--version` shows the new version.
+31. Confirm docs state that browser-update Restart is unsupported in Docker (Settings shows "Not prepared").
+
+## Notes to record
+| Item | Value |
+| --- | --- |
+| Image size | |
+| Base images and tags used | |
+| Container uid | |
+| Build time (cold) | |
+| Docker and Compose versions | |
+| Steps 25-29 run? | |
+
+Not run in this slice: CI, registry publishing, Kubernetes or Helm, multi-arch. SPA tests and visual checks are out of scope.
+
+## Results
+Run on Windows 11, Docker 29.8.1, Compose 5.5.1, with a throwaway docker.env (fake secrets, ENVIRONMENT=development) that was deleted afterwards.
+
+| Steps | Result |
+| --- | --- |
+| 1-7, 31 | PASS |
+| 8 | PASS after a fix: `bun run build` failed in the image because vue-tsc cannot resolve .vue modules under the Bun-only node fallback; the Dockerfile now runs `bun run build-only`. vue-tsc passes on the host and under real node 22 |
+| 9, 11, 12 | PASS (version v2.8.0-beta-dirty, uid 10001, entrypoint exec form, no secret names in history) |
+| 10 | 54.5 MB, above the 40 MB flag (binary 29.9 MB on alpine 3.22) |
+| 13-17 | PASS (healthy, /healthz 200 ok, / 200 text/html, /v1/auth/registration 200 JSON, no permission errors) |
+| 18-19 | PASS (register, verify, login, image upload to /data/uploads as uid 10001; all survive `down` and recreate; uploaded image still served) |
+| 20 | PASS (`down -v` removed the volume) |
+| 21 | PASS (tar backup, `down -v`, restore, login and media list intact) |
+| 22 | PASS for all 7 vars (exit 2, `Environment variable X is not set`) |
+| 23 | PASS (stop 0.4 s, exit 0, `shutting down...` logged) |
+| 24 | PASS (wrong-port healthcheck reports unhealthy) |
+| 25 | PARTIAL: mail delivered over STARTTLS to a Mailpit container on the compose network with the CA trusted via SSL_CERT_FILE; no real external host |
+| 26-29 | NOT RUN (host MTA, Caddy/nginx TLS, TRUSTED_PROXIES) |
+| 30 | NOT RUN as a before/after upgrade |
+| `task docker:run` | PASS (healthz 200, graceful stop, separate volume renewcms-data) |
+
+Notes
+| Item | Value |
+| --- | --- |
+| Image size | 54.5 MB |
+| Base images and tags used | oven/bun:1-alpine, golang:1.27.1-alpine, alpine:3.22 |
+| Container uid | 10001 |
+| Build time (cold) | not measured, layers were partly cached; about 18 s warm |
+| Docker and Compose versions | 29.8.1, 5.5.1 |
+| Steps 25-29 run? | 25 partial, 26-29 no |
diff --git a/features/docker_image_publishing.feature b/features/docker_image_publishing.feature
new file mode 100644
index 0000000..80df45b
--- /dev/null
+++ b/features/docker_image_publishing.feature
@@ -0,0 +1,71 @@
+Feature: Publish the RenewCMS image to a registry so an upgrade is "docker compose pull"
+
+  As a maintainer
+  I want one task that builds and pushes a versioned multi-arch image
+  So that site operators upgrade with "docker compose pull && docker compose up -d" and never build from source
+
+  Background:
+    Given there is no CI pipeline on purpose; publishing is a maintainer-run task, like "task release"
+    And the registry is the Gitea registry of git.floriansylvain.fr and REGISTRY_IMAGE defaults to "git.floriansylvain.fr/fsylvain/renewcms"
+    And the Dockerfile and docker-compose.yml from docker-deployment exist and are unchanged except where stated here
+    And Taskfile.yml gains "docker:publish", with variables REGISTRY_IMAGE and VERSION (default: git describe --tags)
+    And "docker:publish" refuses to run, before building anything, when REGISTRY_IMAGE is empty, when VERSION is not an exact tag "vX.Y.Z" or "vX.Y.Z-
", or when the work tree is dirty
+    And the build is "docker buildx build --platform linux/amd64,linux/arm64 --build-arg VERSION="; the tag is baked in through the existing version ldflags path, so the image's "renewcms --version" prints the tag
+    And tags pushed for vX.Y.Z-
: "vX.Y.Z-
" and "X.Y.Z-
" and the moving tag "" (for example "beta"); "latest" is NOT moved
+    And tags pushed for vX.Y.Z: "vX.Y.Z", "X.Y.Z", "X.Y", and "latest"
+    And the maintainer authenticates beforehand with "docker login git.floriansylvain.fr"; the task never reads, stores or echoes a password or token, and never passes credentials on a command line
+    And no secret, .env, signing key or JWT_SECRET is copied into the image (already enforced by .dockerignore; checked again here)
+    And no image signing or attestation is part of this slice; the Ed25519 key signs release binaries only
+    And "task docker:publish -- --dry-run" builds for the host platform only with "--load", pushes nothing, needs no login and prints the tags it would push
+    And docker-compose.yml uses "image: ${RENEWCMS_IMAGE:-git.floriansylvain.fr/fsylvain/renewcms:beta}" (the default tag is "beta" because "latest" does not exist until the first stable release; it is changed to "latest" then) and its "build:" block moves to docker-compose.build.yml, used as "docker compose -f docker-compose.yml -f docker-compose.build.yml up -d --build"
+    And docs/docker.md, docs/deployment.md and README.md document pull-based upgrade, the build override, the tag scheme and the maintainer publish steps; no comments in any file
+    And the real push is a manual item: it needs a registry account and network
+
+  Scenario Outline: Tags for a version
+    Given VERSION is ""
+    When "task docker:publish -- --dry-run" runs
+    Then it prints exactly these tags: 
+
+    Examples:
+      | version      | tags                                  |
+      | v2.9.0       | v2.9.0, 2.9.0, 2.9, latest            |
+      | v2.9.0-beta  | v2.9.0-beta, 2.9.0-beta, beta         |
+      | v2.9.0-rc.1  | v2.9.0-rc.1, 2.9.0-rc.1, rc           |
+
+  Scenario Outline: Refusals before any build
+    Given 
+    When "task docker:publish" runs
+    Then it exits non-zero with a message naming the problem and docker is not invoked
+
+    Examples:
+      | condition                             |
+      | REGISTRY_IMAGE is empty               |
+      | VERSION is "dev"                      |
+      | VERSION is "2.9.0" (no leading v)     |
+      | the work tree has uncommitted changes |
+
+  Scenario: Dry run builds and loads a working image
+    When "task docker:publish -- --dry-run" runs with VERSION "v2.9.0-beta"
+    Then an image is loaded locally for the host platform and no registry is contacted
+    And running it prints "v2.9.0-beta" for --version
+    And it serves GET /healthz as ok with the env from docker.env.example
+    And it runs as the non-root user with the data volume as in docker-deployment
+
+  Scenario: No secrets in the image
+    Given the dry-run image
+    Then "docker history" and a file listing show no .env, no release.key, no JWT_SECRET value and no SMTP password
+
+  Scenario: Compose pulls by default
+    Given docker-compose.yml with RENEWCMS_IMAGE unset
+    Then "docker compose config" shows the image "git.floriansylvain.fr/fsylvain/renewcms:beta" and no build section
+    And with RENEWCMS_IMAGE set to a tag, that tag is used
+    And with docker-compose.build.yml added, the build section is present and the image builds from the local Dockerfile
+
+  Scenario: Upgrade is a pull
+    Given a running stack on tag "v2.8.0-beta" with data in the volume
+    When the operator runs "docker compose pull && docker compose up -d" after a newer tag is pushed
+    Then the container is recreated on the new image and the articles, uploads and database in the volume are intact
+    And GET /v1/settings reports the new version
+
+  Scenario: Docs say the same thing
+    Then docs/docker.md, docs/deployment.md and README.md each show the pull upgrade, the tag scheme and the maintainer flow, and none claims CI exists
diff --git a/features/docker_image_publishing.qa.md b/features/docker_image_publishing.qa.md
new file mode 100644
index 0000000..cab0a5b
--- /dev/null
+++ b/features/docker_image_publishing.qa.md
@@ -0,0 +1,29 @@
+# QA: Docker image publishing
+
+Everything runs on the real local Docker daemon, with no registry push. On Windows use MSYS_NO_PATHCONV=1 under Git Bash and put the Docker bin on PATH (memory: docker-windows-gotchas).
+
+## Automated / scripted
+1. Refusals: run "task docker:publish" with REGISTRY_IMAGE empty, VERSION=dev, VERSION=2.9.0, and a dirty tree; assert non-zero exit and that no docker build started (compare "docker buildx ls" and the image list before and after).
+2. Tag table: run "task docker:publish -- --dry-run" with VERSION=v2.9.0, v2.9.0-beta and v2.9.0-rc.1 (the VERSION override is accepted only with --dry-run); compare the printed tags with the feature.
+3. Dry run: the image is loaded; "docker run --rm  --version" prints the tag; start the stack from docker.env.example and check GET /healthz is ok, and "docker exec  id -u" is not 0.
+4. Secrets: "docker history --no-trunc" and a file search for .env and release.key inside the image are clean; grep the layers for the sample JWT_SECRET value.
+5. Compose: "docker compose config" with and without RENEWCMS_IMAGE, and with the build override.
+6. Upgrade simulation: tag two local images v-old and v-new, run the stack on v-old, write an article, switch RENEWCMS_IMAGE to v-new, "docker compose up -d", and verify the article and the new version.
+7. Docs: grep the three docs for "docker compose pull", the tag scheme, and the absence of CI claims.
+8. No credentials: grep Taskfile.yml and docs for token or password literals; the task must not call "docker login".
+
+## Manual (user performs; needs the registry)
+- `docker login git.floriansylvain.fr`; "task docker:publish" with a real tag; confirm "docker buildx imagetools inspect :" lists linux/amd64 and linux/arm64.
+- On a clean machine, "docker compose pull && docker compose up -d" against the pushed image; confirm /settings shows the new version and the container guidance from container_update_guidance.
+- Confirm the moving "beta" tag points at the intended digest and that "latest" did not move for a prerelease.
+
+## Results
+- 1 Refusals: PASS (coder and hardener runs).
+- 2 Tag table: PASS (v2.9.0, v2.9.0-beta, v2.9.0-rc.1; re-seen with v2.9.0-beta and v2.9.1-beta, tags "vX, X, beta").
+- 3 Dry run: PASS (--version prints the tag, /healthz ok, uid 10001).
+- 4 Secrets: PASS.
+- 5 Compose: PASS.
+- 6 Upgrade simulation: PASS. Local images v2.9.0-beta and v2.9.1-beta, stack on the old tag, register + verify (mail captured over TLS by Mailpit) + login, article created (201), RENEWCMS_IMAGE switched, "docker compose up -d" recreated the container on the same volume. Old cookie still accepted, login 200, article 1 intact, GET /v1/settings reports v2.9.1-beta (was v2.9.0-beta).
+- 7 Docs: PASS.
+- 8 No credentials: PASS.
+- Manual items: NOT RUN (need the registry): docker login, real multi-arch push and imagetools inspect, "docker compose pull" against the pushed image, moving beta/latest tag checks, builder failure paths of a real run.
diff --git a/internal/domain/update/updateDomain.go b/internal/domain/update/updateDomain.go
index 5bdc521..62d3b62 100644
--- a/internal/domain/update/updateDomain.go
+++ b/internal/domain/update/updateDomain.go
@@ -60,6 +60,7 @@ type UpdateStatus struct {
 	Error      *string       `json:"error"`
 	StartedAt  *time.Time    `json:"started_at"`
 	FinishedAt *time.Time    `json:"finished_at"`
+	Container  bool          `json:"container"`
 }
 
 func NewResult(release Release, checkedAt time.Time) CheckResult {
diff --git a/internal/infrastructure/config/settings.go b/internal/infrastructure/config/settings.go
index 72f9978..94e4da1 100644
--- a/internal/infrastructure/config/settings.go
+++ b/internal/infrastructure/config/settings.go
@@ -33,6 +33,7 @@ type Settings struct {
 	TrustedProxies     []string
 	Version            string
 	UpdateFeedURL      string
+	Container          bool
 }
 
 func (s Settings) IsProduction() bool {
@@ -76,6 +77,7 @@ func NewSettingsFromEnv() (Settings, error) {
 		},
 		TrustedProxies: parseSemicolonSeparatedList(os.Getenv("TRUSTED_PROXIES")),
 		UpdateFeedURL:  os.Getenv("UPDATE_FEED_URL"),
+		Container:      strings.EqualFold(os.Getenv("RENEWCMS_CONTAINER"), "true"),
 	}, nil
 }
 
diff --git a/internal/infrastructure/update/containerHelperDetector.go b/internal/infrastructure/update/containerHelperDetector.go
new file mode 100644
index 0000000..498f777
--- /dev/null
+++ b/internal/infrastructure/update/containerHelperDetector.go
@@ -0,0 +1,16 @@
+package update
+
+import domain "RenewCMS/internal/domain/update"
+
+type containerHelperDetector struct {
+	inner     domain.HelperDetector
+	container bool
+}
+
+func NewContainerHelperDetector(inner domain.HelperDetector, container bool) domain.HelperDetector {
+	return containerHelperDetector{inner: inner, container: container}
+}
+
+func (d containerHelperDetector) Installed() bool {
+	return !d.container && d.inner.Installed()
+}
diff --git a/internal/infrastructure/useCases/GetUpdateStatus.go b/internal/infrastructure/useCases/GetUpdateStatus.go
index 8073ee9..4ac2ae0 100644
--- a/internal/infrastructure/useCases/GetUpdateStatus.go
+++ b/internal/infrastructure/useCases/GetUpdateStatus.go
@@ -6,17 +6,21 @@ import (
 )
 
 type GetUpdateStatusUseCase struct {
-	status updateStatusBuilder
-	helper update.HelperDetector
+	status    updateStatusBuilder
+	helper    update.HelperDetector
+	container bool
 }
 
-func NewGetUpdateStatusUseCase(store update.StatusStore, jobs update.JobStore, helper update.HelperDetector, results update.ResultStore, requests update.RestartRequestStore, currentVersion string, now func() time.Time) *GetUpdateStatusUseCase {
+func NewGetUpdateStatusUseCase(store update.StatusStore, jobs update.JobStore, helper update.HelperDetector, results update.ResultStore, requests update.RestartRequestStore, currentVersion string, container bool, now func() time.Time) *GetUpdateStatusUseCase {
 	return &GetUpdateStatusUseCase{
-		status: updateStatusBuilder{store: store, jobs: jobs, results: results, requests: requests, currentVersion: currentVersion, now: now},
-		helper: helper,
+		status:    updateStatusBuilder{store: store, jobs: jobs, results: results, requests: requests, currentVersion: currentVersion, now: now},
+		helper:    helper,
+		container: container,
 	}
 }
 
 func (g *GetUpdateStatusUseCase) GetStatus(isOwner bool) update.UpdateStatus {
-	return g.status.build(isOwner && g.helper.Installed())
+	status := g.status.build(isOwner && g.helper.Installed())
+	status.Container = g.container
+	return status
 }
diff --git a/test/container_update_guidance_http_test.go b/test/container_update_guidance_http_test.go
new file mode 100644
index 0000000..2e438d7
--- /dev/null
+++ b/test/container_update_guidance_http_test.go
@@ -0,0 +1,198 @@
+package test
+
+import (
+	"RenewCMS/api"
+	validator "RenewCMS/api/pkg/validator"
+	"RenewCMS/internal/domain/update"
+	"RenewCMS/internal/infrastructure/config"
+	infraupdate "RenewCMS/internal/infrastructure/update"
+	"RenewCMS/internal/infrastructure/useCases"
+	"net/http"
+	"testing"
+	"time"
+
+	"github.com/go-chi/jwtauth/v5"
+)
+
+type containerHarness struct {
+	*updateHarness
+	fetcher  *fakeAssetFetcher
+	requests *fakeRestartRequestStore
+	jobs     *fakeJobStore
+}
+
+func newContainerHarness(t *testing.T, container bool, helperInstalled bool) *containerHarness {
+	t.Helper()
+	validator.InitValidator()
+	rig := newDownloadRig(t)
+	rig.helper.installed = helperInstalled
+	helper := infraupdate.NewContainerHelperDetector(rig.helper, container)
+	now := func() time.Time { return updateTestCheckedAt }
+	token := jwtauth.New("HS256", []byte("test-secret"), nil)
+	feed := &fakeReleaseFeed{release: update.Release{
+		Tag:         "v2.3.0-beta",
+		URL:         "https://git.example/fsylvain/RenewCMS/releases/tag/v2.3.0-beta",
+		PublishedAt: time.Date(2026, 9, 24, 8, 0, 0, 0, time.UTC),
+		Notes:       update.ReleaseNotes{Version: "v2.3.0-beta", Items: []string{"Media library"}},
+	}}
+	rig.store.found = true
+	start := useCases.NewStartUpdateDownloadUseCase(useCases.StartUpdateDownloadDependencies{
+		Store:          rig.store,
+		Jobs:           rig.jobs,
+		Helper:         helper,
+		Results:        rig.results,
+		Requests:       rig.requests,
+		Fetcher:        rig.fetcher,
+		Verifier:       nil,
+		Runner:         rig.runner,
+		Backup:         rig.backup,
+		Staging:        rig.staging,
+		Logger:         rig.logger,
+		CurrentVersion: updateRunning,
+		Platform:       rig.platform,
+		FeedURL:        downloadFeedURL,
+		Now:            now,
+	})
+	routes := &api.UseCases{
+		Token:                      token,
+		GetUserUseCase:             useCases.NewGetUserUseCase(newUpdateUserRepo()),
+		GetUpdateStatusUseCase:     useCases.NewGetUpdateStatusUseCase(rig.store, rig.jobs, helper, rig.results, rig.requests, updateRunning, container, now),
+		CheckForUpdateUseCase:      useCases.NewCheckForUpdateUseCase(feed, rig.store, updateRunning, now),
+		StartUpdateDownloadUseCase: start,
+		RequestRestartUseCase:      useCases.NewRequestRestartUseCase(rig.store, rig.jobs, helper, rig.results, rig.requests, updateRunning, now),
+	}
+	return &containerHarness{
+		updateHarness: &updateHarness{router: api.NewSystemRouter(routes), token: token, feed: feed, store: rig.store},
+		fetcher:       rig.fetcher,
+		requests:      rig.requests,
+		jobs:          rig.jobs,
+	}
+}
+
+func TestContainerUpdateGuidanceHTTP(t *testing.T) {
+	t.Run("The status carries the container flag", func(t *testing.T) {
+		cases := []struct {
+			value string
+			flag  bool
+		}{{"true", true}, {"TRUE", true}, {"false", false}, {"", false}, {"yes", false}}
+		for _, tc := range cases {
+			t.Run("value "+tc.value, func(t *testing.T) {
+				t.Setenv("SMTP_PORT", "1")
+				t.Setenv("AUTH_RATE_LIMIT_MAX_ATTEMPTS", "1")
+				t.Setenv("AUTH_RATE_LIMIT_WINDOW_SECONDS", "1")
+				t.Setenv("RENEWCMS_CONTAINER", tc.value)
+				settings, err := config.NewSettingsFromEnv()
+				if err != nil {
+					t.Fatal(err)
+				}
+				if settings.Container != tc.flag {
+					t.Fatalf("Container = %v, want %v", settings.Container, tc.flag)
+				}
+				h := newContainerHarness(t, settings.Container, false)
+
+				body := decodeUpdateBody(t, h.sendAs(t, http.MethodGet, "/update", updateOwnerId))
+
+				assertUpdateKeys(t, body)
+				if body["container"] != tc.flag {
+					t.Errorf("container = %v, want %v", body["container"], tc.flag)
+				}
+			})
+		}
+	})
+
+	t.Run("Container mode disables apply even with a helper", func(t *testing.T) {
+		h := newContainerHarness(t, true, true)
+		h.store.result = newStoredUpdateResult()
+
+		for name, userId := range map[string]uint32{"owner": updateOwnerId, "admin": updateAdminId} {
+			body := decodeUpdateBody(t, h.sendAs(t, http.MethodGet, "/update", userId))
+
+			if body["can_apply"] != false || body["container"] != true {
+				t.Errorf("%s: body = %v", name, body)
+			}
+			if body["available"] != true || body["latest"] != "v2.3.0-beta" || body["notes"] == nil {
+				t.Errorf("%s: body = %v", name, body)
+			}
+		}
+	})
+
+	t.Run("Host installs are unchanged", func(t *testing.T) {
+		h := newContainerHarness(t, false, true)
+		h.store.result = newStoredUpdateResult()
+
+		body := decodeUpdateBody(t, h.sendAs(t, http.MethodGet, "/update", updateOwnerId))
+
+		if body["container"] != false || body["can_apply"] != true {
+			t.Errorf("body = %v", body)
+		}
+	})
+
+	t.Run("The check still works in a container", func(t *testing.T) {
+		h := newContainerHarness(t, true, true)
+		h.store.found = false
+
+		rec := h.sendAs(t, http.MethodPost, "/update/check", updateOwnerId)
+
+		body := decodeUpdateBody(t, rec)
+		if rec.Code != http.StatusOK || body["available"] != true || body["container"] != true {
+			t.Fatalf("status = %d body = %v", rec.Code, body)
+		}
+		if h.store.saves != 1 || h.store.result.Latest != "v2.3.0-beta" {
+			t.Errorf("saves = %d result = %+v", h.store.saves, h.store.result)
+		}
+	})
+
+	t.Run("Download and restart are refused in a container", func(t *testing.T) {
+		for _, path := range []string{"/update/download", "/update/restart"} {
+			t.Run(path, func(t *testing.T) {
+				h := newContainerHarness(t, true, true)
+				h.store.result = newStoredUpdateResult()
+
+				rec := h.sendAs(t, http.MethodPost, path, updateOwnerId)
+
+				if rec.Code != http.StatusConflict {
+					t.Fatalf("status = %d: %s", rec.Code, rec.Body.String())
+				}
+				if _, found := h.jobs.Load(); found {
+					t.Error("no job may start")
+				}
+				if len(h.fetcher.calls) != 0 {
+					t.Errorf("fetcher calls = %v", h.fetcher.calls)
+				}
+				if h.requests.writes != 0 {
+					t.Errorf("restart request writes = %d", h.requests.writes)
+				}
+			})
+		}
+	})
+}
+
+func TestContainerHelperDetectorTruthTable(t *testing.T) {
+	for _, tc := range []struct {
+		container, inner, want bool
+	}{{false, false, false}, {false, true, true}, {true, false, false}, {true, true, false}} {
+		got := infraupdate.NewContainerHelperDetector(&fakeHelperDetector{installed: tc.inner}, tc.container).Installed()
+		if got != tc.want {
+			t.Errorf("container=%v inner=%v: Installed() = %v, want %v", tc.container, tc.inner, got, tc.want)
+		}
+	}
+}
+
+func TestContainerStatusUseCaseCanApplyTruthTable(t *testing.T) {
+	for _, tc := range []struct {
+		isOwner, container, helper, want bool
+	}{
+		{true, false, true, true},
+		{false, false, true, false},
+		{true, true, true, false},
+		{true, false, false, false},
+		{false, true, true, false},
+	} {
+		rig := newDownloadRig(t)
+		rig.helper.installed = tc.helper
+		status := useCases.NewGetUpdateStatusUseCase(rig.store, rig.jobs, infraupdate.NewContainerHelperDetector(rig.helper, tc.container), rig.results, rig.requests, updateRunning, tc.container, func() time.Time { return updateTestCheckedAt }).GetStatus(tc.isOwner)
+		if status.CanApply != tc.want || status.Container != tc.container {
+			t.Errorf("%+v: CanApply=%v Container=%v", tc, status.CanApply, status.Container)
+		}
+	}
+}
diff --git a/test/update_download_test.go b/test/update_download_test.go
index 1e0b9fd..bed1046 100644
--- a/test/update_download_test.go
+++ b/test/update_download_test.go
@@ -386,7 +386,7 @@ func (r *downloadRig) rebuild() {
 		FeedURL:        downloadFeedURL,
 		Now:            func() time.Time { return updateTestCheckedAt },
 	})
-	r.status = useCases.NewGetUpdateStatusUseCase(r.store, r.jobs, r.helper, r.results, r.requests, updateRunning, time.Now)
+	r.status = useCases.NewGetUpdateStatusUseCase(r.store, r.jobs, r.helper, r.results, r.requests, updateRunning, false, time.Now)
 }
 
 func (r *downloadRig) checksumsFor(name string, content []byte) string {
diff --git a/test/update_http_test.go b/test/update_http_test.go
index a8b01e3..45951b2 100644
--- a/test/update_http_test.go
+++ b/test/update_http_test.go
@@ -28,7 +28,7 @@ const (
 	updateRunning         = "v2.2.0-beta"
 )
 
-var updateStatusKeys = []string{"available", "can_apply", "checked_at", "current", "error", "finished_at", "latest", "notes", "progress", "release_url", "started_at", "state"}
+var updateStatusKeys = []string{"available", "can_apply", "checked_at", "container", "current", "error", "finished_at", "latest", "notes", "progress", "release_url", "started_at", "state"}
 
 type updateHarness struct {
 	router http.Handler
diff --git a/test/update_restart_fakes_test.go b/test/update_restart_fakes_test.go
index 39c4952..2157aac 100644
--- a/test/update_restart_fakes_test.go
+++ b/test/update_restart_fakes_test.go
@@ -13,7 +13,7 @@ import (
 )
 
 func newUpdateStatusUseCase(store update.StatusStore, jobs update.JobStore, helper update.HelperDetector, running string) *useCases.GetUpdateStatusUseCase {
-	return useCases.NewGetUpdateStatusUseCase(store, jobs, helper, &fakeResultStore{}, &fakeRestartRequestStore{}, running, time.Now)
+	return useCases.NewGetUpdateStatusUseCase(store, jobs, helper, &fakeResultStore{}, &fakeRestartRequestStore{}, running, false, time.Now)
 }
 
 type fakeRestartRequestStore struct {
diff --git a/test/update_restart_http_test.go b/test/update_restart_http_test.go
index e30c8b2..f3910cb 100644
--- a/test/update_restart_http_test.go
+++ b/test/update_restart_http_test.go
@@ -58,7 +58,7 @@ func newRestartHTTP(t *testing.T) *restartHTTP {
 		Token:          h.token,
 		GetUserUseCase: useCases.NewGetUserUseCase(newUpdateUserRepo()),
 		GetUpdateStatusUseCase: useCases.NewGetUpdateStatusUseCase(
-			h.store, h.jobs, h.helper, h.resultFiles, h.markers, updateRunning, h.clock.Now),
+			h.store, h.jobs, h.helper, h.resultFiles, h.markers, updateRunning, false, h.clock.Now),
 		RequestRestartUseCase: useCases.NewRequestRestartUseCase(
 			h.store, h.jobs, h.helper, h.resultFiles, h.markers, updateRunning, h.clock.Now),
 	}
diff --git a/test/update_restart_usecase_test.go b/test/update_restart_usecase_test.go
index 206b028..1faea29 100644
--- a/test/update_restart_usecase_test.go
+++ b/test/update_restart_usecase_test.go
@@ -32,7 +32,7 @@ func newRestartRig(running string) *restartRig {
 		clock:    &fakeClock{now: restartBaseTime},
 	}
 	rig.request = useCases.NewRequestRestartUseCase(rig.store, rig.jobs, rig.helper, rig.results, rig.requests, running, rig.clock.Now)
-	rig.status = useCases.NewGetUpdateStatusUseCase(rig.store, rig.jobs, rig.helper, rig.results, rig.requests, running, rig.clock.Now)
+	rig.status = useCases.NewGetUpdateStatusUseCase(rig.store, rig.jobs, rig.helper, rig.results, rig.requests, running, false, rig.clock.Now)
 	return rig
 }
 
diff --git a/web/src/components/ServerCommand.vue b/web/src/components/ServerCommand.vue
index a003413..be35452 100644
--- a/web/src/components/ServerCommand.vue
+++ b/web/src/components/ServerCommand.vue
@@ -4,9 +4,13 @@ import { t } from '@/i18n'
 import { computed } from 'vue'
 import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
 
-const props = defineProps<{
-	line: string
-}>()
+const props = withDefaults(
+	defineProps<{
+		line: string
+		showNote?: boolean
+	}>(),
+	{ showNote: true },
+)
 
 const emit = defineEmits<{
 	copy: []
@@ -50,7 +54,7 @@ async function copyLine() {
 				{{ buttonLabel }}
 			
 		
-		{{
+		{{
 			t('They run this once on the server. After that, Update and Restart appear here.')
 		}}
 	
diff --git a/web/src/components/UpdatesCard.vue b/web/src/components/UpdatesCard.vue
index 4335e32..29cbe75 100644
--- a/web/src/components/UpdatesCard.vue
+++ b/web/src/components/UpdatesCard.vue
@@ -10,9 +10,11 @@ import type { RestartKind, UpdateStatus } from '@/composables/useUpdate'
 import type { UserRole } from '@/composables/useApi'
 import type { UpdateApplyPhase, UpdateCheckPhase } from '@/stores/update'
 import {
+	CONTAINER_UPDATE_COMMAND,
 	ENABLE_BROWSER_UPDATES_COMMAND,
 	VERIFYING_ANNOUNCEMENT,
 	browserUpdatesDocsUrl,
+	containerUpdatesDocsUrl,
 	displayVersion,
 	downloadAnnouncement,
 	formatDownloadProgress,
@@ -383,6 +385,21 @@ const block = computed(() => {
 			meta: props.status.finished_at ? formatUpdatedAt(props.status.finished_at) : undefined,
 		}
 	}
+	if (showContainerGuidance.value) {
+		return {
+			tone: 'info',
+			icon: 'i-lucide-info',
+			get title() {
+				return t('Version {0} is available', [newVersion.value])
+			},
+			get text() {
+				return t(
+					'You have {0}. This site runs in Docker, so it updates by pulling a new image. Run the commands below on the server.',
+					[props.status.current],
+				)
+			},
+		}
+	}
 	if (props.status.available && isOwner.value) {
 		return {
 			tone: 'info',
@@ -435,9 +452,15 @@ const restartAvailable = computed(
 	() => canApplyView.value && (props.status.state === 'idle' || isDone.value),
 )
 
+const showContainerGuidance = computed(
+	() =>
+		isOwner.value && props.status.container && props.status.available && props.phase === 'idle',
+)
+
 const showServerGuidance = computed(
 	() =>
 		isOwner.value &&
+		!props.status.container &&
 		!props.status.can_apply &&
 		props.status.available &&
 		props.phase === 'idle',
@@ -550,7 +573,10 @@ function onAction(button: ActionButton) {
 
 const showNotes = computed(
 	() =>
-		showServerGuidance.value || applyView.value === 'available' || applyView.value === 'ready',
+		showServerGuidance.value ||
+		showContainerGuidance.value ||
+		applyView.value === 'available' ||
+		applyView.value === 'ready',
 )
 
 const notes = computed(() => {
@@ -625,6 +651,12 @@ const notes = computed(() => {
 
 		
 
+		
+
 		
{ + + {{ t('How to update') }} + + + { + const actual = await importOriginal() + return { + ...actual, + useApi: vi.fn(), + } +}) + +const CONTAINER_COMMAND = 'docker compose pull && docker compose up -d' + +let wrapper: VueWrapper | null = null + +async function mountSettings(role: UserRole, status: UpdateStatus) { + setActivePinia(createPinia()) + const session = useSessionStore() + session.session = { verified: true, username: 'marta', id: 1, role } + session.status = 'ready' + const request = vi.fn((path: string) => { + if (path === '/v1/settings/') { + return Promise.resolve({ version: RUNNING_VERSION }) + } + if (path === STATUS_PATH) { + return Promise.resolve(status) + } + throw new Error(`Unexpected request: ${path}`) + }) + vi.mocked(useApi).mockReturnValue({ + getMe: vi.fn().mockResolvedValue({ + verified: true, + username: 'marta', + id: 1, + displayName: '', + role, + }), + request, + }) + await router.push('/settings') + wrapper = mount(SettingsView, { + attachTo: document.body, + global: { plugins: [router, uiPlugin] }, + }) + await flushPromises() +} + +function updatesCard() { + return document.body.querySelector('[data-testid="updates-card"]')! +} + +function cardText() { + return updatesCard().textContent ?? '' +} + +afterEach(async () => { + wrapper?.unmount() + wrapper = null + vi.useRealTimers() + await router.push('/login') + document.body.innerHTML = '' +}) + +describe('Settings, owner, container, update available', () => { + it('shows the pull-image guidance, the command, Copy and the docs link, with no other button', async () => { + await mountSettings('owner', newerAvailable({ container: true })) + + expect(cardText()).toContain('Version 2.3.0-beta is available') + expect(cardText()).toContain( + 'You have v2.2.0-beta. This site runs in Docker, so it updates by pulling a new image. Run the commands below on the server.', + ) + expect(updatesCard().querySelector('code')?.textContent).toBe(CONTAINER_COMMAND) + const link = Array.from(updatesCard().querySelectorAll('a')).find( + (anchor) => anchor.textContent?.trim() === 'How to update', + )! + expect(link.getAttribute('href')).toBe( + 'https://git.example/fsylvain/RenewCMS/src/branch/main/docs/docker.md', + ) + expect(visibleButtonLabels(updatesCard())).toEqual(["What's new in 2.3.0-beta", 'Copy']) + expect(cardText()).not.toContain('renewcms enable-browser-updates') + expect(cardText()).not.toContain('They run this once on the server') + expect(cardText()).not.toContain("can't update itself from the browser") + }) + + it('copies the container command and reads Copied for 2 s', async () => { + const writeText = stubClipboard() + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) + await mountSettings('owner', newerAvailable({ container: true })) + + findButton('Copy')!.click() + await flushPromises() + + expect(writeText).toHaveBeenCalledWith(CONTAINER_COMMAND) + expect(findButton('Copied')!.getAttribute('aria-live')).toBe('polite') + + await vi.advanceTimersByTimeAsync(2000) + expect(findButton('Copy')?.textContent?.trim()).toBe('Copy') + expect(findButton('Copied')).toBeUndefined() + }) +}) + +describe('Settings, owner, container, up to date', () => { + it("shows You're up to date with only Check for updates and no container hint", async () => { + await mountSettings('owner', upToDate({ container: true })) + + expect(cardText()).toContain("You're up to date") + expect(visibleButtonLabels(updatesCard())).toEqual(['Check for updates']) + expect(cardText()).not.toContain('Docker') + expect(updatesCard().querySelector('code')).toBeNull() + }) +}) + +describe('Settings, admin, container', () => { + it('keeps the admin view with no command block', async () => { + await mountSettings('admin', newerAvailable({ container: true })) + + expect(cardText()).toContain('Only the owner can update RenewCMS.') + expect(cardText()).not.toContain('Docker') + expect(updatesCard().querySelector('code')).toBeNull() + expect(visibleButtonLabels(updatesCard())).toEqual([]) + }) +}) + +describe('Settings, owner, not container, not prepared', () => { + it('still shows the enable-browser-updates command', async () => { + await mountSettings('owner', newerAvailable({ container: false })) + + expect(updatesCard().querySelector('code')?.textContent).toBe( + 'renewcms enable-browser-updates', + ) + expect(cardText()).toContain("This site can't update itself from the browser yet.") + expect(cardText()).not.toContain('Docker') + }) +}) diff --git a/web/src/views/__tests__/updateTestKit.ts b/web/src/views/__tests__/updateTestKit.ts index 35e229f..a8d0e55 100644 --- a/web/src/views/__tests__/updateTestKit.ts +++ b/web/src/views/__tests__/updateTestKit.ts @@ -37,6 +37,7 @@ export function upToDate(overrides: Partial = {}): UpdateStatus { error: null, started_at: null, finished_at: null, + container: false, ...overrides, } }