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,
}
}