1. The project and its images – Plone deployment

1. The project and its images#

Note

This chapter is part of the reference documentation of the training-deployment-gitlabdeploy repository, and is maintained there.

A cookieplone project is a monorepo: one repository holding a Plone backend, a Volto frontend, and the deployment description for both. They are versioned and released together, which is what makes a single pipeline sensible.

backend/     Plone, installed with uv. Python package: playclusterdemo
frontend/    Volto, installed with pnpm. Add-on: volto-playclusterdemo
devops/      The Docker Swarm stack file
.gitlab/     The pipeline
repository.toml   Project and deployment settings

repoplone, the thing that knows the answers#

Almost nothing in this repository hardcodes a version. repository.toml declares them, and repoplone reads it:

uvx repoplone settings dump
{
  "name": "playcluster-demo",
  "container_images_prefix": "registry.playcluster.plone.org/plone-training1/training-deployment-gitlabdeploy",
  "backend":  { "python_version": "3.13", "base_package_version": "6.2.1" },
  "frontend": { "volto_version": "19.3.0" }
}

Both Makefiles call this while parsing, not while running:

REPOSITORY_SETTINGS := $(shell uvx repoplone settings dump)
PLONE_VERSION := $(shell echo '$(REPOSITORY_SETTINGS)' | jq -r '.backend.base_package_version')

Important

That has a consequence for CI: every job that runs make needs uv and jq, even frontend jobs that never touch Python. Miss it and the Makefile fails before the first target runs.

The backend#

Plone installed with uv, not buildout, and not pip. backend/Makefile's install target does three things:

update-constraints:  uvx repoplone deps constraints
sync:                uv sync
config:              uvx cookiecutter ... gh:plone/cookiecutter-zope-instance

uv.lock pins the entire dependency tree, and uv sync --locked in the image build refuses to proceed if the lockfile and pyproject.toml disagree. That is the property we want in CI: builds fail loudly rather than resolving something new.

The [tool.uv] constraint-dependencies block in backend/pyproject.toml holds the Plone version pins that repoplone deps constraints generates. It is several hundred lines and it is generated — do not hand-edit it.

The backend image#

backend/Dockerfile, two stages:

ARG PYTHON_VERSION=3.13
FROM plone/server-builder:uv-${PYTHON_VERSION} AS builder

RUN --mount=type=cache,target=/root/.cache \
    --mount=type=bind,source=uv.lock,target=uv.lock \
    --mount=type=bind,source=pyproject.toml,target=pyproject.toml \
    uv sync --locked --no-dev --no-group test --group container --no-install-project

COPY . /src
RUN uv sync --locked --no-dev --no-group test --group container --no-editable

FROM plone/server-prod-config:uv-${PYTHON_VERSION}
COPY --from=builder --chown=500:500 /app /app

Two details worth pausing on.

Dependencies are installed before the source is copied. Only uv.lock and pyproject.toml are bind-mounted for that first uv sync. So editing Python code does not invalidate the dependency layer, and rebuilds after a code change are fast.

The runtime stage carries no build tooling. It starts from plone/server-prod-config and copies the finished /app across.

The frontend image#

frontend/Dockerfile has a wrinkle that surprises people:

FROM plone/frontend-builder:${VOLTO_VERSION} AS builder

COPY --chown=node packages/volto-playclusterdemo /app/packages/volto-playclusterdemo
COPY --chown=node package.json /app/package.json.temp
COPY --chown=node mrs.developer.json /app/
COPY --chown=node pnpm-lock.yaml /app/pnpm-lock.yaml

RUN <<EOT
    python3 -c "...merge package.json.temp dependencies into package.json..."
    pnpm dlx mrs-developer missdev --no-config --fetch-https
    pnpm install && pnpm build:deps
    pnpm build
EOT

Volto core is not in this repository. mrs.developer.json pins a version:

{ "core": { "package": "@plone/volto", "tag": "19.3.0", "filterBlobs": true } }

and mrs-developer clones it into frontend/core during the build. So the image build needs network access to GitHub, and frontend/core is deliberately gitignored.

The base image already carries a package.json; the project's own dependencies are merged into it rather than replacing it. That is what the package.json.temp dance is doing.

Both Dockerfiles take a MAINTAINER build argument#

ARG MAINTAINER="Fred van Dijk <fred@plone.org>"
LABEL maintainer="${MAINTAINER}" \
      org.label-schema.name="playcluster-demo-backend"

The default keeps a bare docker build sensible. CI passes the real value, read from backend/pyproject.toml's [project].authors — the single place it is declared.

Building them by hand#

make build-images

which is just the two Makefile targets:

build-image:
	docker build . -t $(IMAGE_NAME) --build-arg PYTHON_VERSION=$(PYTHON_VERSION) -f Dockerfile

IMAGE_NAME comes from container_images_prefix in repository.toml, so a local build produces exactly the names CI produces. That is deliberate: the pipeline is not doing anything you cannot reproduce on your laptop.

Try it#

Nothing to deploy, but worth running to see where the values come from:

make debug-settings
PROJECT_NAME: playcluster-demo
VOLTO_VERSION: 19.3.0
PLONE_VERSION: 6.2.1
PUBLIC_HOSTNAME: playcluster.plone.org
STACK_NAME: playcluster-plone-org
STACK_FILE: devops/stacks/stack.yml

Every one of those comes out of repository.toml. Chapter 5 shows where they end up.