5. The deploy – Plone deployment

5. The deploy#

Note

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

The job#

"deploy:production":
  image:
    name: ghcr.io/kitconcept/docker-stack-deploy:1.4.0
    entrypoint: [""]
  tags: [${RUNNER_TAG_DEPLOY}]
  resource_group: production
  environment:
    name: production
    url: ${DEPLOY_URL}
  script:
    - set -a && . ./deploy.env && set +a
    - export STACK_PARAM="${IMAGE_TAG}"
    - /docker-entrypoint.sh

docker-stack-deploy is a small image built on docker:cli. Its entrypoint configures SSH from the variables it is given, sets DOCKER_HOST=ssh://user@host, and runs docker stack deploy against the remote daemon.

Note

Because it works over SSH, this job needs neither Docker-in-Docker nor a mounted Docker socket. GitLab overrides a job image's entrypoint with its own shell, so entrypoint: [""] clears it and the script calls the entrypoint itself.

resource_group: production serialises deploys — two pipelines can never touch the stack at once.

The log is a line per phase, so the last successful line tells you where it stopped:

Container Registry: Logged in registry.playcluster.plone.org as deploy
SSH client: Configured
SSH remote: Keys added to /root/.ssh/known_hosts
SSH connect: Success
Deploy: Updated services
Deploy: Checking status
Deploy: Completed

No SSH_KNOWN_HOSTS variable is needed — the entrypoint runs ssh-keyscan itself.

From repository.toml to running services#

This is the chain worth understanding, because everything else follows from it.

repository.toml [deployment]
        |  read by the config job with tomllib
        v
   deploy.env  (ordinary artifact)
        |  sourced by deploy:production
        v
  environment variables
        |  interpolated by docker stack deploy
        v
 devops/stacks/stack.yml
        |
        v
   running services

Four hops, one source. Change the hostname in repository.toml and it reaches the router rule, the Volto API path and the VHM rewrites without touching the stack file.

The stack file#

Three services. No Traefik, no published ports.

frontend#

frontend:
  image: ${REGISTRY_IMAGE_PREFIX}/frontend:${STACK_PARAM:-latest}
  environment:
    RAZZLE_INTERNAL_API_PATH: http://backend:8080/Plone
    RAZZLE_API_PATH: https://${PUBLIC_HOSTNAME}
  networks: [nw-public, nw-internal]
  deploy:
    replicas: 2
    placement:
      constraints:
        - ${APP_PLACEMENT:-node.platform.os == linux}

Two API paths, and the difference matters. RAZZLE_INTERNAL_API_PATH is used by Volto's server-side rendering and points at the backend service directly over the internal network. RAZZLE_API_PATH is what the browser uses, so it must be the public URL.

backend#

Same shape, plus the VirtualHostMonster rewrites. Plone needs to know the public URL to generate correct links, and Traefik tells it by rewriting the path:

- "traefik.http.middlewares.${STACK_PREFIX}-vhm.replacepathregex.replacement=/VirtualHostBase/https/${PUBLIC_HOSTNAME}/Plone/++api++/VirtualHostRoot$$1"

The $$ is an escaped $ — docker compose would otherwise try to interpolate it.

db#

db:
  image: postgres:18
  deploy:
    replicas: 1
    placement:
      constraints:
        - ${DB_PLACEMENT:?set DB_PLACEMENT to deployment.db_placement from repository.toml}
  volumes:
    - vol-site-data:/var/lib/postgresql
volumes:
  vol-site-data:
    driver_opts:
      type: none
      device: "${DATA_PATH:?...}"
      o: bind

Warning

This is the most dangerous part of the file. The volume is a bind mount to a path on one specific node. Without a placement constraint, Swarm may reschedule the task elsewhere, where that path is a different — empty — directory, and Plone comes up against a blank database. Nothing errors.

Hence db_placement uses :? — the deploy refuses to run without it — while app_placement uses :- with an always-true default, because an unconstrained frontend is merely suboptimal. The severity of the failure decides which form to use.

db_placement resolves to node.labels.storage == persistent, which is play3.

Traefik labels#

The stack ships no Traefik, so routing is a set of labels the cluster's instance reads. Four things have to line up, all from [deployment.traefik]:

- traefik.enable=true
- traefik.constraint-label=${TRAEFIK_CONSTRAINT_LABEL:-public}
- traefik.swarm.network=${TRAEFIK_NETWORK:-nw-public}
- traefik.http.routers.${STACK_PREFIX}-frontend.rule=Host(`${PUBLIC_HOSTNAME}`)
- traefik.http.routers.${STACK_PREFIX}-frontend.entrypoints=${TRAEFIK_ENTRYPOINT:-https}
- traefik.http.routers.${STACK_PREFIX}-frontend.tls.certresolver=${TRAEFIK_CERTRESOLVER:-le}

Each has to match the cluster's Traefik, set up in the training-deployment-playcluster repository (its chapter 3): the constraint label it filters on, the network it reaches services over, and the names of its entrypoint and certificate resolver. Get one wrong and the service is simply not routed — usually with no error anywhere.

Warning

Set traefik.swarm.network, never traefik.docker.network as well. Defining both makes Traefik skip the service entirely:

ERR Skip container error="both Docker and Swarm labels are defined"

No router appears at all, which looks like a discovery problem rather than a label problem. This cost an evening.

Two more things learned by getting them wrong:

Namespace what you define. Router, service and middleware names are global to the Traefik instance. ${STACK_PREFIX} prefixes everything this stack owns.

Do not redeclare what the cluster owns. The routers reference gzip, which the cluster's Traefik defines. Declaring a second gzip in the same provider can conflict and affect other applications.

There is no HTTP router. The cluster Traefik redirects at the entrypoint:

--entrypoints.http.http.redirections.entrypoint.to=https

so no stack needs its own redirect.

The tag being deployed#

export STACK_PARAM="${IMAGE_TAG}"

STACK_PARAM is what the image lines interpolate:

image: ${REGISTRY_IMAGE_PREFIX}/backend:${STACK_PARAM:-latest}

So a push to main deploys sha-<short-sha> — the immutable tag, not latest. Which is what makes the rollback in chapter 6 work.

On a release tag, IMAGE_TAG is the tag itself, so the images are named after the release rather than after a sha. Only version-shaped tags do that:

.rules-release:
  rules:
    - if: $CI_COMMIT_TAG =~ /^v?\d+\.\d+/
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH

Without that pattern, any tag deploys — and a tag put on an older commit for some unrelated reason would quietly build that commit and ship it over what is running. A training that tags every step of an exercise, or a git bisect marker, is exactly such a case.