215 lines
11 KiB
Markdown
215 lines
11 KiB
Markdown
# 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 |
|
|
|
|
Its only runtime state is the SQLite database (`DB_FILE`) and the uploads directory (`UPLOAD_DIR`). Nothing else needs to be installed on the server.
|
|
|
|
## 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_<version>_<os>_<arch>[.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 -- <path>` 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`. Until you do, the file holds a placeholder (32 zero bytes) and `task release` fails with a message saying so.
|
|
3. Export `RELEASE_SIGNING_KEY=<path to the private 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.
|
|
|
|
### 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/<tag>/`, 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.
|
|
|
|
Enable it once, on the server, as root:
|
|
|
|
```sh
|
|
sudo renewcms enable-browser-updates
|
|
```
|
|
|
|
The command reads the service name, the binary path and the data directory from your `renewcms.service` unit. Pass `--service`, `--binary`, `--data-dir` and `--port` to override them or when there is no such unit. The port, used by the helper to poll `/healthz`, comes from `--port`, else from `PORT` in the unit's `EnvironmentFile`, else 8080. 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.
|
|
|
|
To undo it:
|
|
|
|
```sh
|
|
sudo renewcms disable-browser-updates
|
|
```
|
|
|
|
This stops and disables the path unit, removes both unit files and `update-helper.json`, and runs `systemctl daemon-reload`. The restart request, the result, the staged files and the backups stay where they are, and the Settings page goes back to "Not prepared".
|
|
|
|
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 `<name>.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 `<name>.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.
|
|
|
|
## 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=<openssl rand -hex 32>
|
|
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.
|
|
|
|
## Backups
|
|
|
|
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.
|