Running the container
The image serves the standalone editor, the bundled schemas and the SKOS vocabularies with nginx. It runs as uid 101 on port 8080 and needs only a writable /tmp. TLS is terminated in front of it, by a reverse proxy or an ingress.
Build the image
Build from the repository root. Schemas and vocabularies are committed copies, so no schemas checkout is needed.
git clone https://gitlab.com/sidedoc/sidedoc.git && cd sidedoc
docker buildx build -f docker/Dockerfile \
--build-arg VERSION=0.6.1 \
--build-arg REVISION="$(git rev-parse --short HEAD)" \
--build-arg CREATED="$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
-t sidedoc:0.6.1 .The build arguments only set labels and the version string; see Configuration. Use the version of the release you check out.
Quick start with Compose
docker/compose.yaml is a complete reference deployment, including all hardening options:
docker compose -f docker/compose.yaml up --build -d
curl -s http://127.0.0.1:8080/healthz # okOpen http://127.0.0.1:8080/. The port is bound to 127.0.0.1 only: put a TLS proxy in front before you expose it (see below).
Set the image name with SIDEDOC_IMAGE and the version with SIDEDOC_VERSION, in the shell or in a .env file next to compose.yaml. The environment: section lists every setting as a comment with its default.
Run with docker run
docker run -d --name sidedoc \
--read-only \
--tmpfs /tmp:rw,noexec,nosuid,nodev,size=16m \
--cap-drop ALL \
--security-opt no-new-privileges \
--pids-limit 100 --memory 128m --cpus 1 \
-p 127.0.0.1:8080:8080 \
-e SIDEDOC_HSTS_MAX_AGE=31536000 \
sidedoc:0.6.1For more than a few settings, use an env file. docker/sidedoc.env.example lists them all:
cp docker/sidedoc.env.example sidedoc.env # uncomment what you need
docker run ... --env-file sidedoc.env sidedoc:0.6.1Hardening options
The image works without any of these options, but you should use them all in production.
| Option | Effect |
|---|---|
--read-only | The root filesystem is immutable. The served files are owned by root and read-only for the nginx user anyway; this also stops anything else from being written. |
--tmpfs /tmp:... | The only writable path. The entrypoint renders its configuration into /tmp/sidedoc; nginx keeps its pid file and temp files in /tmp. Without it, a read-only container stops with /tmp must be writable. 16 MB is plenty. |
noexec,nosuid,nodev | Nothing on the tmpfs can be executed or act as a device. |
--cap-drop ALL | No Linux capabilities. nginx listens on an unprivileged port, so it needs none. |
--security-opt no-new-privileges | No process can gain privileges, for example through setuid binaries. The image also removes all setuid/setgid bits. |
--pids-limit, --memory, --cpus | Resource limits. nginx serving static files needs very little: 128 MB is generous. |
-p 127.0.0.1:8080:8080 | Publish only on loopback, for a proxy on the same host. |
The process runs as uid/gid 101 (nginx), set in the image. Do not override the user with --user root.
With --tmpfs options that set an owner (uid=101,gid=101,mode=0700, as in compose.yaml), make sure the uid matches; a plain --tmpfs /tmp is world-writable and always works.
Configuration
All settings are environment variables, read at start-up. The full list with defaults and allowed values is on the Configuration page. The ones you are most likely to set:
| Variable | When |
|---|---|
SIDEDOC_HSTS_MAX_AGE | The site is HTTPS-only and the proxy does not already send HSTS. |
SIDEDOC_REAL_IP_FROM | A proxy sits in front and you want client addresses in the access log. |
SIDEDOC_CSP_FRAME_ANCESTORS | Another site must embed SideDoc in a frame. |
SIDEDOC_CSP_CONNECT_SRC | The browser must connect to another origin, for example an S3 endpoint. |
SIDEDOC_SENTRY_ENABLED, SIDEDOC_SENTRY_DSN | You want browser errors in your own Sentry project. |
Invalid values stop the container with exit code 64 and a message on stderr; see Troubleshooting.
Secrets and _FILE variables
Any setting can be read from a file: set SIDEDOC_SENTRY_DSN_FILE=/run/secrets/sentry_dsn instead of SIDEDOC_SENTRY_DSN. This works with Docker/Compose secrets and Kubernetes secret volumes.
None of the settings is actually secret: they end up in response headers or in /config.js, which every visitor loads. Never configure credentials (S3 keys, tokens) for the container. Users enter their own credentials in the browser.
Health check
GET /healthz returns 200 ok and is not logged. The image defines a Docker HEALTHCHECK that calls it every 30 seconds:
docker inspect --format '{{.State.Health.Status}}' sidedoc # healthyUse the same path for Kubernetes probes and load-balancer checks.
Logs
- Access log on stdout, one JSON object per request by default (
SIDEDOC_ACCESS_LOG). It records no query strings, user agents or referrers. - nginx errors and the entrypoint's messages on stderr (
SIDEDOC_LOG_LEVEL).
A start looks like this:
sidedoc-entrypoint: listening on :8080 (ipv6=true, sentry=false, access_log=json)Reverse proxy and TLS
The container speaks plain HTTP. Terminate TLS in front of it and forward everything to port 8080. The container sends all security headers itself; the proxy only needs to add HSTS if you do not set SIDEDOC_HSTS_MAX_AGE.
To log real client addresses, let the proxy set X-Forwarded-For and set SIDEDOC_REAL_IP_FROM to the proxy's address or network. Without it the container logs the proxy's address.
nginx
server {
listen 443 ssl;
http2 on;
server_name sidedoc.example.org;
ssl_certificate /etc/letsencrypt/live/sidedoc.example.org/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/sidedoc.example.org/privkey.pem;
location / {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
server {
listen 80;
server_name sidedoc.example.org;
return 301 https://$host$request_uri;
}Set SIDEDOC_REAL_IP_FROM to the address the container sees for the proxy. With -p port publishing that is usually the Docker bridge gateway, not 127.0.0.1: check the remote_addr in the access log, typically 172.17.0.1.
Caddy
Caddy obtains certificates automatically and sets X-Forwarded-For by default:
sidedoc.example.org {
reverse_proxy 127.0.0.1:8080
}Traefik
With Traefik as a container on the same Docker network, add labels to the sidedoc service in compose.yaml and drop the ports: mapping:
labels:
traefik.enable: "true"
traefik.http.routers.sidedoc.rule: Host(`sidedoc.example.org`)
traefik.http.routers.sidedoc.entrypoints: websecure
traefik.http.routers.sidedoc.tls.certresolver: letsencrypt
traefik.http.services.sidedoc.loadbalancer.server.port: "8080"
environment:
SIDEDOC_REAL_IP_FROM: "172.16.0.0/12"Serving under a sub-path
The app uses relative URLs throughout (./config.js, ./assets/..., ./schemas/..., and the Sentry tunnel ./monitoring/envelope), so it works under a prefix such as https://example.org/sidedoc/ when the proxy strips the prefix:
location /sidedoc/ {
proxy_pass http://127.0.0.1:8080/; # trailing slash strips /sidedoc
}The container's own redirects are relative, so they keep the prefix.
Kubernetes
With the securityContext below, the pod meets the restricted Pod Security Standard. A minimal Deployment:
apiVersion: apps/v1
kind: Deployment
metadata:
name: sidedoc
spec:
replicas: 2
selector:
matchLabels: { app: sidedoc }
template:
metadata:
labels: { app: sidedoc }
spec:
automountServiceAccountToken: false
securityContext:
runAsNonRoot: true
runAsUser: 101
runAsGroup: 101
seccompProfile: { type: RuntimeDefault }
containers:
- name: sidedoc
image: registry.example.org/sidedoc:0.6.1
ports:
- { name: http, containerPort: 8080 }
env:
- { name: SIDEDOC_REAL_IP_FROM, value: "10.0.0.0/8" }
- { name: SIDEDOC_WORKER_PROCESSES, value: "1" }
securityContext:
readOnlyRootFilesystem: true
allowPrivilegeEscalation: false
capabilities: { drop: [ALL] }
volumeMounts:
- { name: tmp, mountPath: /tmp }
readinessProbe:
httpGet: { path: /healthz, port: http }
periodSeconds: 10
livenessProbe:
httpGet: { path: /healthz, port: http }
periodSeconds: 30
resources:
requests: { cpu: 10m, memory: 32Mi }
limits: { memory: 128Mi }
volumes:
- name: tmp
emptyDir: { medium: Memory, sizeLimit: 16Mi }
---
apiVersion: v1
kind: Service
metadata:
name: sidedoc
spec:
selector: { app: sidedoc }
ports:
- { name: http, port: 80, targetPort: http }Notes:
- Set
SIDEDOC_WORKER_PROCESSESto match the CPU limit.autocounts the node's CPUs, not the container's share. - Route an Ingress or Gateway to the
sidedocService and terminate TLS there. SetSIDEDOC_REAL_IP_FROMto the pod network or the ingress controller's addresses. - Pods are stateless and interchangeable: no volumes besides
/tmp, no session affinity. - To pass a setting from a Secret, mount the Secret as a volume and set
NAME_FILEto the file path, or usevalueFrom.secretKeyRefforNAMEdirectly.
Image contents
- Multi-stage build. The runtime stage is
nginx-unprivileged(Alpine, slim, about 35 MB) with no Node.js and no build tools. - Base images are pinned by tag through the
NODE_IMAGEandNGINX_IMAGEbuild arguments. For releases, pin them by digest too, and rebuild regularly to pick up security fixes. - Only
GETandHEADare allowed (405otherwise), exceptPOSTon the Sentry tunnel. Request bodies are limited to 1 KB, headers and timeouts are tight,server_tokensis off and dotfiles are never served.