Fixing `:null` Port URLs and ClassicUI Access in Plone 6 + Volto + Traefik Deployments

Fixing :null Port URLs and ClassicUI Access in Plone 6 + Volto + Traefik Deployments

A guide for DevOps engineers deploying Plone 6 with Volto frontend behind Traefik reverse proxy in Docker Swarm.

Problem Description

After deploying Plone 6 with Volto behind Traefik, you may encounter:

  1. Malformed URLs with :null port - Content URLs and image paths generate with :null as the port number:

    https://www.mysite.com:null/resources/my-article
    https://mysite.com:null/folder/@@images/preview_image-168-abc123.png
    
  2. Inaccessible ClassicUI/ZMI - The Classic Plone interface and Zope Management Interface return 404 errors or route incorrectly to the Volto frontend.

Root Cause

Why :null Appears in URLs

Plone needs to know the public URL (scheme, hostname, port) to generate correct absolute URLs in API responses. When running behind a reverse proxy like Traefik, Plone only sees internal container addresses (e.g., http://backend:8080).

VirtualHostMonster (VHM) is the Zope/Plone mechanism that solves this. Traefik must rewrite incoming requests to include VHM path information that tells Plone the public URL.

Without VHM middleware:

  • Plone doesn't know the public hostname
  • Plone doesn't know the public port
  • URLs are generated with null values

Why ClassicUI Doesn't Work

The Volto frontend typically has a catch-all route for the domain. Without explicit higher-priority routing for /ClassicUI, requests are sent to Volto instead of the Plone backend.

Solution

1. Add VHM Middleware for API Routes

Add these Traefik labels to your backend service:

backend:
  image: your-plone-backend:latest
  deploy:
    labels:
      - "traefik.enable=true"
      - "traefik.docker.network=traefik-public"
      
      # VHM middleware - tells Plone the public URL
      - "traefik.http.middlewares.backend-vhm.replacepathregex.regex=^/\\+\\+api($$|/.*)"
      - "traefik.http.middlewares.backend-vhm.replacepathregex.replacement=/VirtualHostBase/https/mysite.com:443/Plone/++api++/VirtualHostRoot/$${1}"
      
      # Backend router for HTTPS - apply VHM middleware
      - "traefik.http.routers.backend.rule=(Host(`mysite.com`) || Host(`www.mysite.com`)) && PathPrefix(`/++api`)"
      - "traefik.http.routers.backend.entrypoints=websecure"
      - "traefik.http.routers.backend.tls.certresolver=letsencrypt"
      - "traefik.http.routers.backend.middlewares=backend-vhm"
      - "traefik.http.routers.backend.service=backend"
      - "traefik.http.services.backend.loadbalancer.server.port=8080"
      
      # HTTP to HTTPS redirect
      - "traefik.http.routers.backend-http.rule=(Host(`mysite.com`) || Host(`www.mysite.com`)) && PathPrefix(`/++api`)"
      - "traefik.http.routers.backend-http.entrypoints=web"
      - "traefik.http.routers.backend-http.middlewares=redirect-to-https"

2. Add ClassicUI Routing for ZMI Access

Add these additional labels to the backend service:

      # ClassicUI VHM middleware
      - "traefik.http.middlewares.mw-backend-vhm-classic-https.replacepathregex.regex=^/ClassicUI($$|/.*)"
      - "traefik.http.middlewares.mw-backend-vhm-classic-https.replacepathregex.replacement=/VirtualHostBase/https/mysite.com:443/Plone/VirtualHostRoot/_vh_ClassicUI$${1}"
      
      # ClassicUI Router - priority 2000 to beat frontend catch-all
      - "traefik.http.routers.rt-backend-classic-https.rule=Host(`mysite.com`) && PathPrefix(`/ClassicUI`)"
      - "traefik.http.routers.rt-backend-classic-https.service=backend"
      - "traefik.http.routers.rt-backend-classic-https.middlewares=mw-backend-vhm-classic-https"
      - "traefik.http.routers.rt-backend-classic-https.entrypoints=websecure"
      - "traefik.http.routers.rt-backend-classic-https.tls.certresolver=letsencrypt"
      - "traefik.http.routers.rt-backend-classic-https.priority=2000"

Understanding VHM Path Format

The VirtualHostMonster path format is:

/VirtualHostBase/<protocol>/<hostname>:<port>/<plone-site-id>/<api-path>/VirtualHostRoot/<remaining-path>

Breaking down our API middleware replacement:

/VirtualHostBase/https/mysite.com:443/Plone/++api++/VirtualHostRoot/$${1}
│                │     │              │     │                       │
│                │     │              │     │                       └─ Captured path after /++api
│                │     │              │     └─ API traversal path
│                │     │              └─ Plone site ID in Zope
│                │     └─ Public hostname and port
│                └─ Public protocol
└─ VHM marker

For ClassicUI, we use _vh_ClassicUI which creates a virtual path segment:

/VirtualHostBase/https/mysite.com:443/Plone/VirtualHostRoot/_vh_ClassicUI$${1}

This makes Plone generate URLs like https://mysite.com/ClassicUI/... instead of https://mysite.com/Plone/....

Complete Backend Service Example

version: "3.8"

services:
  backend:
    image: ghcr.io/your-org/your-plone-backend:latest
    environment:
      RELSTORAGE_DSN: "${RELSTORAGE_DSN}"
    networks:
      - traefik-public
    deploy:
      replicas: 1
      update_config:
        parallelism: 1
        delay: 30s
        order: start-first
        failure_action: rollback
      labels:
        - "traefik.enable=true"
        - "traefik.docker.network=traefik-public"
        
        # ===========================================
        # API Routes with VHM (fixes :null URLs)
        # ===========================================
        - "traefik.http.middlewares.backend-vhm.replacepathregex.regex=^/\\+\\+api($$|/.*)"
        - "traefik.http.middlewares.backend-vhm.replacepathregex.replacement=/VirtualHostBase/https/mysite.com:443/Plone/++api++/VirtualHostRoot/$${1}"
        
        - "traefik.http.routers.backend.rule=(Host(`mysite.com`) || Host(`www.mysite.com`)) && PathPrefix(`/++api`)"
        - "traefik.http.routers.backend.entrypoints=websecure"
        - "traefik.http.routers.backend.tls.certresolver=letsencrypt"
        - "traefik.http.routers.backend.middlewares=backend-vhm"
        - "traefik.http.routers.backend.service=backend"
        - "traefik.http.services.backend.loadbalancer.server.port=8080"
        
        - "traefik.http.routers.backend-http.rule=(Host(`mysite.com`) || Host(`www.mysite.com`)) && PathPrefix(`/++api`)"
        - "traefik.http.routers.backend-http.entrypoints=web"
        - "traefik.http.routers.backend-http.middlewares=redirect-to-https"
        
        # ===========================================
        # ClassicUI Routes (ZMI Access)
        # ===========================================
        - "traefik.http.middlewares.mw-backend-vhm-classic-https.replacepathregex.regex=^/ClassicUI($$|/.*)"
        - "traefik.http.middlewares.mw-backend-vhm-classic-https.replacepathregex.replacement=/VirtualHostBase/https/mysite.com:443/Plone/VirtualHostRoot/_vh_ClassicUI$${1}"
        
        - "traefik.http.routers.rt-backend-classic-https.rule=Host(`mysite.com`) && PathPrefix(`/ClassicUI`)"
        - "traefik.http.routers.rt-backend-classic-https.service=backend"
        - "traefik.http.routers.rt-backend-classic-https.middlewares=mw-backend-vhm-classic-https"
        - "traefik.http.routers.rt-backend-classic-https.entrypoints=websecure"
        - "traefik.http.routers.rt-backend-classic-https.tls.certresolver=letsencrypt"
        - "traefik.http.routers.rt-backend-classic-https.priority=2000"

networks:
  traefik-public:
    external: true

Accessible URLs After Fix

URL Purpose
https://mysite.com/++api++/... REST API (used by Volto)
https://mysite.com/ClassicUI Classic Plone interface
https://mysite.com/ClassicUI/manage ZMI at Plone site level
https://mysite.com/ClassicUI/aq_parent/manage_main ZMI at Zope root

Verification Steps

1. Check API Response URLs

curl -s "https://mysite.com/++api++/@search?portal_type=Document" | jq '.items[0]["@id"]'

Expected: "https://mysite.com/path/to/document"
Bad: "https://www.mysite.com:null/path/to/document"

2. Check Image URLs

curl -s "https://mysite.com/++api++/my-page" | jq '.image_scales'

Verify base_path and URLs don't contain :null.

3. Test ClassicUI Access

curl -I "https://mysite.com/ClassicUI"

Expected: HTTP/2 200 or HTTP/2 302 (redirect to login)
Bad: HTTP/2 404 or HTML from Volto frontend

Common Pitfalls

1. Escaping in Docker Compose vs Shell Scripts vs --label-add

In docker-compose.yml (YAML escaping):

- "traefik.http.middlewares.backend-vhm.replacepathregex.regex=^/\\+\\+api($$|/.*)"
- "traefik.http.middlewares.backend-vhm.replacepathregex.replacement=/VirtualHostBase/https/mysite.com:443/Plone/++api++/VirtualHostRoot/$${1}"
  • $$ in YAML becomes literal $
  • \\+ becomes \+ (regex escape for literal +)

In shell scripts with heredoc (double escaping):

cat > docker-compose.yml << 'EOF'
- "traefik.http.middlewares.backend-vhm.replacepathregex.regex=^/\\+\\+api($$|/.*)"
EOF
  • Use 'EOF' (quoted) to prevent shell expansion inside heredoc

In docker service update --label-add (use single quotes!):

--label-add 'traefik.http.middlewares.backend-vhm.replacepathregex.regex=^/\+\+api($|/.*)'
--label-add 'traefik.http.middlewares.backend-vhm.replacepathregex.replacement=/VirtualHostBase/https/mysite.com:443/Plone/++api++/VirtualHostRoot/${1}'
  • Single quotes prevent ALL shell expansion
  • Pass literal $, backticks, etc. directly to Docker

2. Missing Port in VHM Path

Always include the port, even for standard ports:

  • :white_check_mark: /VirtualHostBase/https/mysite.com:443/Plone/...
  • :cross_mark: /VirtualHostBase/https/mysite.com/Plone/...

3. Wrong Plone Site ID

The site ID in the VHM path must match your actual Plone site ID (commonly Plone):

/VirtualHostBase/https/mysite.com:443/Plone/...
                                      └─ Must match your site ID

Check your site ID at: https://mysite.com/ClassicUI/aq_parent/manage_main

4. Priority for ClassicUI

Without priority=2000, the frontend catch-all may intercept ClassicUI requests:

- "traefik.http.routers.rt-backend-classic-https.priority=2000"

5. Rolling Updates Don't Apply New Labels

This is a critical gotcha! When using rolling updates with Docker Swarm:

# This only updates the IMAGE, not the labels!
docker service update --image myimage:latest myservice

If you add VHM middleware labels to your docker-compose.yml but only do rolling image updates, the labels won't be applied. Traefik will never see the new middleware configuration.

Solutions:

Option A: Full stack redeploy (applies all labels from docker-compose.yml):

docker stack deploy -c docker-compose.yml mystack

Option B: Add labels during rolling update (for CI/CD pipelines):

docker service update \
    --image myimage:latest \
    --label-add 'traefik.http.middlewares.backend-vhm.replacepathregex.regex=^/\+\+api\+\+($|/.*)' \
    --label-add 'traefik.http.middlewares.backend-vhm.replacepathregex.replacement=/VirtualHostBase/https/mysite.com:443/Plone/++api++/VirtualHostRoot/${1}' \
    --label-add 'traefik.http.routers.backend.middlewares=backend-vhm' \
    --label-add 'traefik.http.routers.backend.rule=(Host(`mysite.com`) || Host(`www.mysite.com`)) && PathPrefix(`/++api++`)' \
    myservice

:warning: CRITICAL: Use single quotes! Double quotes cause shell expansion issues:

  • $$ in double quotes becomes the shell process ID (e.g., 12345)
  • Backticks in double quotes trigger command substitution
  • Single quotes pass the literal string to Docker, which is what Traefik needs

Note: --label-add is idempotent - running it multiple times with the same values is safe and won't interfere with Traefik.

6. Best Practice: Dedicated CI/CD Step for VHM Labels

Rather than relying on deploy scripts to apply labels correctly, add a dedicated step in your CI/CD workflow that explicitly applies VHM labels after every deploy:

# GitHub Actions example
- name: Apply VHM middleware labels to backend
  run: |
    ssh ${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }} '
      docker service update \
        --label-add "traefik.http.middlewares.backend-vhm.replacepathregex.regex=^/\+\+api\+\+(\$|/.*)" \
        --label-add "traefik.http.middlewares.backend-vhm.replacepathregex.replacement=/VirtualHostBase/https/mysite.com:443/Plone/++api++/VirtualHostRoot/\${1}" \
        --label-add "traefik.http.routers.backend.middlewares=backend-vhm" \
        --label-add "traefik.http.routers.backend.rule=(Host(\`mysite.com\`) || Host(\`www.mysite.com\`)) && PathPrefix(\`/++api++\`)" \
        mystack_backend
    '

Why this is better:

  • Labels are defined in one place (the workflow YAML)
  • No escaping issues with shell scripts or heredocs
  • Guaranteed to run after every deploy
  • Easy to audit and update
  • Works regardless of deploy script implementation

7. Regex Must Match the Full ++api++ Path

A subtle but critical mistake is using a regex that only matches /++api instead of /++api++:

:cross_mark: WRONG:

regex=^/\+\+api($|/.*)
PathPrefix(`/++api`)

This only matches /++api or /++api/foo - NOT /++api++ or /++api++/foo.

:white_check_mark: CORRECT:

regex=^/\+\+api\+\+($|/.*)
PathPrefix(`/++api++`)

This correctly matches /++api++ or /++api++/foo which is what Plone REST API actually uses.

Symptoms of this bug:

  • API requests return the Zope root "Plone is up and running" page instead of JSON
  • VHM appears to be "working" (no connection errors) but returns wrong content
  • The regex applies but never matches the actual request paths

References


Scope Assessment: This Guide vs CookiePlone

What This Document Covers

This is a troubleshooting/fix guide for specific problems, not a complete setup guide. It assumes you already have:

  • :cross_mark: Docker Swarm initialized and running
  • :cross_mark: Traefik service configured as reverse proxy
  • :cross_mark: SSL/TLS certificates setup (Let's Encrypt)
  • :cross_mark: Frontend (Volto) service configuration
  • :cross_mark: Docker networks created
  • :cross_mark: Secrets management configured
  • :cross_mark: CI/CD pipeline for builds and deployments

What CookiePlone Provides

CookiePlone (cookiecutter-plone) is the official Plone project generator. It creates complete project scaffolding including:

  • :white_check_mark: Complete devops/ folder with Docker Swarm configurations
  • :white_check_mark: Traefik configuration with VHM already set up correctly
  • :white_check_mark: CI/CD workflows with GitHub Actions
  • :white_check_mark: Ansible playbooks for server provisioning
  • :white_check_mark: Makefile commands for common operations
  • :white_check_mark: Environment and secrets management
  • :white_check_mark: SSL/TLS with Let's Encrypt auto-renewal
  • :white_check_mark: ClassicUI routing (see community discussion)

When to Use What

Scenario Recommendation
Starting a new Plone 6 project Use CookiePlone - it's the official, community-maintained approach
Existing project with :null URL issues This guide helps fix that specific problem
Existing project missing ClassicUI access This guide shows how to add the routing
Learning how VHM works This guide explains the "why" behind the configuration
Major infrastructure overhaul Consider migrating to CookiePlone templates

Migrating to CookiePlone Templates

If you're considering adopting CookiePlone's DevOps approach for an existing project:

  1. Generate a fresh CookiePlone project with similar settings
  2. Compare its devops/ folder to your current setup
  3. Adopt configurations that improve your infrastructure
  4. Test thoroughly in staging before production

CookiePlone templates are actively maintained by the Plone community and incorporate lessons learned from many production deployments. For new projects, starting with CookiePlone is strongly recommended over building deployment infrastructure from scratch.


Document version: 1.5 | Last updated: January 2026

1 Like