Files
RenewCMS/docs/docker.md
T

14 KiB

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; there are no Docker-specific variables.

Requirements: Docker with Compose v2. task 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:

    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:

    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.

  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:

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-<pre> (for example v2.9.0-beta, v2.9.0-rc.1) vX.Y.Z-<pre>, X.Y.Z-<pre>, and the moving tag <pre-name> (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 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 (unless you use MySQL), 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:

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

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

MySQL or MariaDB

SQLite on the /data volume is the default. docker-compose.mysql.yml runs RenewCMS next to a MySQL 8.4 container instead:

export MYSQL_ROOT_PASSWORD=<generated>
export DB_PASSWORD=<generated>
docker compose -f docker-compose.mysql.yml up -d

It starts the mysql service with a named volume mysql-data and a healthcheck, and starts renewcms only once the database is healthy (depends_on with service_healthy). RenewCMS gets DB_DRIVER=mysql, DB_HOST=mysql, DB_NAME, DB_USER and DB_PASSWORD from the compose file and the rest from docker.env. DATA_DIR=/data keeps update state and the .sql pre-update backups on the renewcms-data volume, which still holds the uploads. Replace the placeholders; never commit real passwords.

To use MariaDB, change the mysql service image to mariadb:11, its MYSQL_* variables to MARIADB_* and its healthcheck to healthcheck.sh --connect --innodb_initialized. To use a server outside Compose, drop the mysql service and point DB_HOST at it. For a server on another machine set DB_TLS=true.

Back the database up with docker compose -f docker-compose.mysql.yml exec mysql mysqldump ... (a tar copy of the volume is not consistent while it runs), and the renewcms-data volume as described above. Moving an existing SQLite volume is a manual procedure, see deployment.md.

PostgreSQL

docker-compose.postgres.yml runs RenewCMS next to a PostgreSQL 17 container:

export DB_PASSWORD=<generated>
docker compose -f docker-compose.postgres.yml up -d

It starts the postgres service with a named volume postgres-data and a pg_isready healthcheck, and starts renewcms only once the database is healthy (depends_on with service_healthy). RenewCMS gets DB_DRIVER=postgres, DB_HOST=postgres, DB_PORT, DB_NAME, DB_USER, DB_PASSWORD and DB_SSLMODE=disable (the traffic stays on the Compose network) from the compose file and the rest from docker.env. DATA_DIR=/data keeps update state and the .sql pre-update backups on the renewcms-data volume. For a server on another machine drop the postgres service, point DB_HOST at it and set DB_SSLMODE=require. Back the database up with docker compose -f docker-compose.postgres.yml exec postgres pg_dump -U renewcms renewcms. Replace the placeholders; never commit real passwords.

S3-compatible storage (SeaweedFS)

docker-compose.s3.yml runs RenewCMS with STORAGE_DRIVER=s3 next to a SeaweedFS server (Apache 2.0, image chrislusf/seaweedfs, override with SEAWEEDFS_IMAGE) with its S3 gateway enabled, and a one-shot seaweedfs-init step that creates the bucket with weed shell:

export S3_SECRET_ACCESS_KEY=<generated>
docker compose -f docker-compose.s3.yml up -d

RenewCMS gets S3_ENDPOINT=http://seaweedfs:8333, S3_PATH_STYLE=true, the bucket name and the access key (S3_ACCESS_KEY_ID, default renewcms, and S3_SECRET_ACCESS_KEY) from the compose file, and the rest from docker.env. SeaweedFS reads the same key pair as its admin identity. The renewcms-data volume then holds the SQLite database and update state but no uploads; the images live in the seaweedfs-data volume. The bucket is not published and the app serves the images. SeaweedFS is used because MinIO stopped publishing community images. To use AWS S3 or another provider, drop the seaweedfs services and set the S3_* variables in docker.env (see configuration.md). The volume backup above covers the database only, so back the bucket up separately. To move existing uploads see configuration.md.

task test:s3 starts a throwaway SeaweedFS and runs the storage integration test against it; it skips with a message when Docker is not available.

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:

cms.example.com {
	reverse_proxy 127.0.0.1:8080 {
		header_up X-Real-IP {remote_host}
	}
}

nginx on the host:

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.

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:

    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:

    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:

    git tag v2.9.0-beta
    
  2. Log in once to the registry. The task never reads or stores credentials:

    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:

    docker buildx create --use
    docker run --privileged --rm tonistiigi/binfmt --install arm64
    
  4. Publish:

    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-<pre> 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

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:<tag> 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.