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:
-
Malformed URLs with
:nullport - Content URLs and image paths generate with:nullas the port number:https://www.mysite.com:null/resources/my-article https://mysite.com:null/folder/@@images/preview_image-168-abc123.png -
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
nullvalues
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:
/VirtualHostBase/https/mysite.com:443/Plone/...
/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
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++:
WRONG:
regex=^/\+\+api($|/.*)
PathPrefix(`/++api`)
This only matches /++api or /++api/foo - NOT /++api++ or /++api++/foo.
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
- Plone Deployment Training
- Plone VirtualHostMonster Documentation
- Traefik Docker Provider
- Plone Community Forum: ClassicUI Access
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:
Docker Swarm initialized and running
Traefik service configured as reverse proxy
SSL/TLS certificates setup (Let's Encrypt)
Frontend (Volto) service configuration
Docker networks created
Secrets management configured
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:
Complete devops/folder with Docker Swarm configurations
Traefik configuration with VHM already set up correctly
CI/CD workflows with GitHub Actions
Ansible playbooks for server provisioning
Makefile commands for common operations
Environment and secrets management
SSL/TLS with Let's Encrypt auto-renewal
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:
- Generate a fresh CookiePlone project with similar settings
- Compare its
devops/folder to your current setup - Adopt configurations that improve your infrastructure
- 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