# Deployment RenewCMS ships as **one self-contained executable**. It serves: | Path | What | |------------|-----------------------------------------| | `/v1/*` | JSON API | | `/healthz` | Liveness check, returns `200 ok` | | `/*` | The Vue SPA (embedded), with fallback to `index.html` for client-side routes | Runtime state includes the SQLite database (`DB_FILE`, or a MySQL/MariaDB server, see [Using MySQL or MariaDB](#using-mysql-or-mariadb)), uploads (`UPLOAD_DIR`, or an S3-compatible bucket with `STORAGE_DRIVER=s3`, see [configuration.md](configuration.md#image-storage)), 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): ```sh task build # this OS/arch → bin/renewcms[.exe] task release # all targets → bin/renewcms_ _ _ [.exe] ``` `task release` also signs the release: it writes `bin/checksums.txt` (SHA-256 of the six binaries) and signs it into `bin/checksums.txt.sig` with an Ed25519 key. It refuses to start, before building anything, unless `RELEASE_SIGNING_KEY` points to the private key file and that key matches the public key committed as `internal/infrastructure/update/release.pub`. The one-time setup is below. The frontend is built once and embedded into every binary. The binaries are static (`CGO_ENABLED=0`), so a Linux build runs on any distribution. The version comes from `git describe`: tag a release (`git tag v1.2.0`) before building, and the binary logs its version at startup. ### Release signing key (maintainers, once) 1. Run `task release:keygen -- ` with a path outside the repository (for example `~/.renewcms/release.key`). The private key is written there and never leaves your machine; keygen refuses a path inside the work tree. 2. Commit the printed public key as `internal/infrastructure/update/release.pub`. A placeholder key (32 zero bytes) prevents signing. Official releases already embed the project's public key; this setup is for release maintainers, not site operators. Never copy the private signing key to a CMS server. 3. Export `RELEASE_SIGNING_KEY= ` before every `task release`. To check a download by hand: `go run ./cmd/releasesign verify checksums.txt`, with `checksums.txt.sig` next to it, then compare the binary's hash with the matching line of `checksums.txt`. ### 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. 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 Once the restart helper is installed (it writes `update-helper.json` next to `DB_FILE`), the owner's Update button downloads the new binary, `checksums.txt` and `checksums.txt.sig` from the release feed host over https only, checks the ed25519 signature and the SHA-256, runs the staged binary with `--version`, copies the database into `backups/` (the last 3 copies are kept) and stages everything under `updates/ /`, all next to `DB_FILE`. The job record is `update-job.json`. Nothing running is replaced and the site is not restarted by the download: the owner's Restart button does that, as described in [Browser updates](#browser-updates). `renewcms --version` prints the build version and exits. ### Browser updates The owner's Restart button installs a downloaded update, or just restarts the site, without touching the server by hand. The app itself stays unprivileged: the systemd unit below keeps `ProtectSystem=strict` and only the data directory (the directory that holds `DB_FILE`) is writable. The only privileged code is a small helper, the same binary started as `renewcms apply-update` by a systemd service that runs as root. First install v2.6.0-beta or newer manually, back up the database, uploads and current binary, and confirm the app runs as an unprivileged service user. The binary and its parent directory should be controlled by root. Owners can start updates and restarts; admins have read-only update status. #### Find the actual database directory The helper's `--data-dir` must be the directory containing `DB_FILE`. Inspect the relevant settings without printing the configuration's secrets: ```sh sudo systemctl show renewcms -p User -p WorkingDirectory sudo grep '^DB_FILE=' /etc/renewcms/renewcms.env ``` | WorkingDirectory | DB_FILE | Correct --data-dir | | --- | --- | --- | | `/var/lib/renewcms` | `/var/lib/renewcms/renewcms.db` | `/var/lib/renewcms` | | `/var/lib/renewcms` | `./data/renewcms.db` | `/var/lib/renewcms/data` | Leave an existing `DB_FILE` unchanged: changing it can open a different, empty database. Configure the helper to match the existing database instead. #### Enable the helper For this guide's absolute database path and port 8080, run: ```sh sudo /opt/renewcms/renewcms enable-browser-updates \ --service renewcms \ --binary /opt/renewcms/renewcms \ --data-dir /var/lib/renewcms \ --port 8080 ``` Replace these flags with your actual paths and port. For the relative database example above and port 8788, use `--data-dir /var/lib/renewcms/data --port 8788`. The absolute executable path works even when `/opt/renewcms` is absent from sudo's PATH. Without explicit flags, discovery reads `/etc/systemd/system/renewcms.service` and takes its first `ReadWritePaths` entry as the data directory. It does not resolve `DB_FILE`, so explicit flags are necessary when these directories differ or the unit lives elsewhere. The port comes from `--port`, else `PORT` in the unit's `EnvironmentFile`, else 8080. The helper polls `/healthz` on `127.0.0.1` at that port. It then: - writes `renewcms-update.path` and `renewcms-update.service` next to your other units in `/etc/systemd/system` (no secret and no environment value goes into them); - runs `systemctl daemon-reload` and enables and starts the path unit; - writes `update-helper.json` in the data directory last, so the Settings page only offers Update and Restart once everything else worked. Running it again rewrites the units and the status file to the current values. If a step fails, the units it wrote are removed again and `update-helper.json` is not written. #### Verify setup and test a plain restart For the layout above, run these checks, substituting your service user and database directory if different: ```sh systemctl is-active renewcms-update.path systemctl is-enabled renewcms-update.path sudo systemctl cat renewcms-update.service sudo -u renewcms cat /var/lib/renewcms/update-helper.json ``` Expect `active`, `enabled`, an `ExecStart` with the correct paths and port, and readable JSON. Reload Settings as owner. With no update waiting, Restart should appear. Press it, confirm the plain restart dialog, and expect the overlay followed by the site returning and a “Site restarted” message. Then run: ```sh systemctl is-active renewcms sudo cat /var/lib/renewcms/update-result.json sudo cat /var/lib/renewcms/update-running.json sudo journalctl -u renewcms-update.service -n 30 --no-pager curl --retry 5 --retry-connrefused --retry-delay 2 -fsS http://127.0.0.1:8080/healthz ``` Expect an active app service, result `kind: restart` with `outcome: succeeded`, the installed version and a new process in `update-running.json`, and health response `ok`. Check `/healthz` through your public HTTPS hostname too. A brief connection refusal during startup can be transient. The oneshot helper normally becomes inactive after success; the path unit stays active. #### Install a future update When a newer signed release exists, check for updates in Settings, read its notes and click Update. The site stays online while the files are downloaded, verified and backed up. Wait for Ready to install, then click Restart and confirm installation. Afterwards, check the version in Settings, public health, the helper journal and `update-result.json`: expect `kind: update` and `outcome: succeeded`. On the latest release you can test only a plain restart. Test installation, forced rollback and hostile-file cases on an isolated Linux/systemd machine with its own data and service. The helper unit names are fixed, so setting up another instance on the same machine replaces that machine's existing helper configuration. #### Troubleshooting - **Restart is missing:** reload Settings, confirm you are owner, and inspect `GET /v1/system/update` in the browser's Network panel. For an idle owner, `can_apply: false` means the app did not detect the helper. Check that `update-helper.json` is beside the actual database and readable by the service user. - **Wrong helper directory:** disable the helper with the old `--data-dir`, then enable it with the correct directory and port. Do not change `DB_FILE` to compensate. - **Overlay does not clear:** inspect the helper journal, `update-result.json` and `update-running.json` in the actual database directory. Check localhost health on the configured port, then public health through the proxy. Do not cache `/healthz` at the proxy. - **Old binary panics on `--version`:** older releases may interpret it as normal startup and require server environment variables. v2.6.0-beta and newer print their version without loading configuration. A separately launched process failing this way does not stop the systemd service. - **Download or verification fails:** inspect the app journal. All assets and redirects must use HTTPS on the configured feed host. The release must include the six binaries, `checksums.txt` and `checksums.txt.sig`. - **Update rolls back:** check the previous version and helper journal. Automatic rollback restores the binary, not the database; schema changes still require a recovery plan. #### Disable the helper Use the same database directory you supplied when enabling it: ```sh sudo /opt/renewcms/renewcms disable-browser-updates \ --service renewcms \ --data-dir /var/lib/renewcms ``` This stops and disables the path unit, removes both unit files and `update-helper.json`, and runs `systemctl daemon-reload`. The restart request, result, staged files and backups stay. `can_apply` becomes false: Settings shows “Not prepared” when an update is available, or “You're up to date” without Restart otherwise. How it works: pressing Restart makes the app write `update-restart-request.json` in the data directory. The path unit notices it and starts the helper, which deletes the request, checks the staged files again, installs the update, restarts the service and waits up to 60 seconds for the site to answer `/healthz` and for the new process to write `update-running.json`. The helper writes its outcome to `update-result.json`; the Settings page reads it. The technical reason of a failure goes to the journal only (`journalctl -u renewcms-update`). Files in the data directory: | File | Written by | What | |------|------------|------| | `update-restart-request.json` | the app | The request: `kind` (`update` or `restart`), `tag` (updates only), `requested_at`, `requested_by`. The helper deletes it first. | | `update-result.json` | the helper | The outcome: `running`, `succeeded`, `rolled_back` or `failed`, with `kind`, `tag`, `from_version`, `started_at`, `finished_at`. It stays until the next download or restart. | | `update-helper.json` | `enable-browser-updates` | Marks browser updates as set up (`version`, `service`, `binary`, `data_dir`, `installed_at`). | | `update-running.json` | the server, at every start | `version`, `pid` and `started_at` of the running process. The helper uses it to tell a new process from an old one. | What is supported and what is not: - Linux with systemd only. Not Windows, macOS, Docker or other init systems: there the command refuses with a clear message and the Settings page keeps showing "Not prepared". - Before the swap the helper copies the staged files into a private temp directory, checks the signature and the SHA-256 on that copy and installs the copy. It refuses symbolic links under `updates/` and on the request file. - The previous binary is kept as ` .previous` next to the binary, and the update download keeps a database backup in `backups/`. - If the new version does not come up within 60 seconds, the helper puts ` .previous` back, restarts the service and records `rolled_back`. If even that fails the result is `failed` and the journal says so loudly. - A plain restart (no update waiting) restarts the service the same way and records `succeeded` or `failed`; no binary is replaced. - Only the owner can press Restart. The site is unavailable for about 10 seconds. #### Verification status A real v2.6.0-beta plain browser restart was confirmed on Linux/systemd on 2026-10-02: the path unit triggered the helper, a new server process started, the result was `succeeded`, and public health returned `ok`. Real signed installation, forced rollback and adversarial filesystem checks remain unverified. The manual cases are listed in [the restart QA procedure](../features/updates_restart_helper.qa.md). ## 2. Lay out the server The following is a suggested layout for Linux: ``` /opt/renewcms/renewcms the binary /etc/renewcms/renewcms.env configuration (chmod 600, owned by root) /var/lib/renewcms/ data: renewcms.db, uploads/ (owned by the service user) ``` ```sh sudo useradd --system --home /var/lib/renewcms --shell /usr/sbin/nologin renewcms sudo install -d -o renewcms -g renewcms /var/lib/renewcms sudo install -D -m 755 bin/renewcms_v1.2.0_linux_amd64 /opt/renewcms/renewcms sudo install -D -m 600 /dev/null /etc/renewcms/renewcms.env # then edit it ``` `/etc/renewcms/renewcms.env` looks like this (see [configuration.md](configuration.md) for every variable): ```sh ENVIRONMENT=production PUBLIC_URL=https://cms.example.com PORT=8080 DB_FILE=/var/lib/renewcms/renewcms.db UPLOAD_DIR=/var/lib/renewcms/uploads JWT_SECRET= SMTP_LOGIN=noreply@example.com SMTP_SENDER=noreply@example.com SMTP_PASSWORD=... SMTP_HOST=smtp.example.com SMTP_PORT=465 ``` ## 3. Run it as a service (systemd) `/etc/systemd/system/renewcms.service`: ```ini [Unit] Description=RenewCMS After=network-online.target Wants=network-online.target [Service] User=renewcms Group=renewcms WorkingDirectory=/var/lib/renewcms EnvironmentFile=/etc/renewcms/renewcms.env ExecStart=/opt/renewcms/renewcms Restart=on-failure # RenewCMS shuts down gracefully on SIGTERM (up to 10 s to finish requests and close the DB) TimeoutStopSec=15 NoNewPrivileges=true ProtectSystem=strict ProtectHome=true PrivateTmp=true ReadWritePaths=/var/lib/renewcms [Install] WantedBy=multi-user.target ``` ```sh sudo systemctl daemon-reload sudo systemctl enable --now renewcms journalctl -u renewcms -f curl -fsS http://localhost:8080/healthz ``` On Windows, register the binary as a service with a wrapper such as [NSSM](https://nssm.cc/) or [WinSW](https://github.com/winsw/winsw), and set the same environment variables. ## 4. Put HTTPS in front With `ENVIRONMENT=production` the auth cookie is `Secure`, so **the site must be served over HTTPS**. Terminate TLS in a reverse proxy and keep RenewCMS on localhost. [Caddy](https://caddyserver.com) handles certificates automatically: ```caddyfile cms.example.com { reverse_proxy localhost:8080 { header_up X-Real-IP {remote_host} } } ``` nginx: ```nginx server { listen 443 ssl http2; server_name cms.example.com; # ssl_certificate ... (e.g. certbot) client_max_body_size 20m; # image uploads 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; } } ``` If RenewCMS runs on the same host as the proxy, consider firewalling port 8080. Either way, set `TRUSTED_PROXIES` (see [configuration.md](configuration.md)) to the proxy's own address as seen by RenewCMS, typically `127.0.0.1` when the proxy and RenewCMS share a host, so the login/password-reset rate limiter honors the forwarded client IP instead of the proxy's own address. ### Security headers RenewCMS sends `Content-Security-Policy` (including `frame-ancestors 'none'`), `X-Content-Type-Options`, `X-Frame-Options`, `Referrer-Policy` and `Cross-Origin-Opener-Policy` itself, so the proxy doesn't need to add them. With `ENVIRONMENT=production` it also sends `Strict-Transport-Security: max-age=31536000` (browsers ignore it over plain HTTP, so serve the site over HTTPS only). Don't add the same headers in the proxy, or they will be duplicated. The CSP allows Google Fonts (`fonts.googleapis.com`, `fonts.gstatic.com`) and `https:` images, because the SPA loads its fonts from Google and articles may embed remote images. `style-src` keeps `'unsafe-inline'` for the article editor; `script-src` is `'self'` only. ## Using MySQL or MariaDB SQLite is the default. To use an existing MySQL 8 or MariaDB 11 server instead, create an empty database and a user with full rights on it, then set in `/etc/renewcms/renewcms.env`: ```sh DB_DRIVER=mysql DB_HOST=db.example.com DB_USER=renewcms DB_PASSWORD= DB_NAME=renewcms DB_TLS=true DATA_DIR=/var/lib/renewcms ``` `DB_FILE` is then ignored. `DATA_DIR` is where update state, staged updates and backups live, so give it the same value as the helper's `--data-dir` (see [browser updates](#browser-updates)). Use `DB_TLS=true` (or `preferred`) whenever the server is not on the same host. Every variable is described in [configuration.md](configuration.md#database). The schema is created and migrated at startup, so the first start on an empty database needs nothing else. ### Moving an existing SQLite site to MySQL There is no migration tool. The manual procedure, with the site stopped: 1. Take a backup of the data directory (database file and uploads). 2. Start RenewCMS once on the empty MySQL database so it creates the schema, then stop it. 3. Export the rows from the SQLite file with `sqlite3 renewcms.db .dump`, keep only the `INSERT INTO` statements for `articles`, `images`, `users`, `invites`, `api_keys` and `purge_settings`, and import them into MySQL. Quoting and date formats differ between the two engines, so check a sample first: SQLite writes times such as `2026-01-02 03:04:05.123456789+00:00`, and the `+00:00` suffix must be removed for MariaDB and older MySQL versions. An unset time (`0001-01-01 00:00:00`) is valid as it is in MySQL and is read back as unset. 4. Copy `uploads/` to the new `UPLOAD_DIR`, set `DB_DRIVER=mysql` and the connection variables, and start the service. Keep the SQLite file until you have checked the site. ## Backups With MySQL or MariaDB, back the database up with your server's own tools (for example `mysqldump --single-transaction`), plus the uploads directory. RenewCMS also writes a logical dump to `DATA_DIR/backups` before each update download. The rest of this section is about SQLite. Everything worth backing up is under the data directory. Take a consistent SQLite copy while the service is running: ```sh sqlite3 /var/lib/renewcms/renewcms.db ".backup '/backups/renewcms-$(date +%F).db'" rsync -a /var/lib/renewcms/uploads/ /backups/uploads/ ``` Alternatively, run `systemctl stop renewcms`, copy the directory, and run `systemctl start renewcms`. ## Upgrading 1. Back up (see above). The schema is migrated automatically at startup (GORM `AutoMigrate`), and there is no down-migration. 2. Replace `/opt/renewcms/renewcms` with the new binary. 3. Run `sudo systemctl restart renewcms`, then check `journalctl -u renewcms` for the new version line. To roll back, put the previous binary back and restore the database backup if the schema changed.