Skip to content

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.

sh
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:

sh
docker compose -f docker/compose.yaml up --build -d
curl -s http://127.0.0.1:8080/healthz      # ok

Open 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 ​

sh
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.1

For more than a few settings, use an env file. docker/sidedoc.env.example lists them all:

sh
cp docker/sidedoc.env.example sidedoc.env    # uncomment what you need
docker run ... --env-file sidedoc.env sidedoc:0.6.1

Hardening options ​

The image works without any of these options, but you should use them all in production.

OptionEffect
--read-onlyThe 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,nodevNothing on the tmpfs can be executed or act as a device.
--cap-drop ALLNo Linux capabilities. nginx listens on an unprivileged port, so it needs none.
--security-opt no-new-privilegesNo process can gain privileges, for example through setuid binaries. The image also removes all setuid/setgid bits.
--pids-limit, --memory, --cpusResource limits. nginx serving static files needs very little: 128 MB is generous.
-p 127.0.0.1:8080:8080Publish 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:

VariableWhen
SIDEDOC_HSTS_MAX_AGEThe site is HTTPS-only and the proxy does not already send HSTS.
SIDEDOC_REAL_IP_FROMA proxy sits in front and you want client addresses in the access log.
SIDEDOC_CSP_FRAME_ANCESTORSAnother site must embed SideDoc in a frame.
SIDEDOC_CSP_CONNECT_SRCThe browser must connect to another origin, for example an S3 endpoint.
SIDEDOC_SENTRY_ENABLED, SIDEDOC_SENTRY_DSNYou 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:

sh
docker inspect --format '{{.State.Health.Status}}' sidedoc    # healthy

Use 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:

text
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 ​

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:

text
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:

yaml
    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:

nginx
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:

yaml
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_PROCESSES to match the CPU limit. auto counts the node's CPUs, not the container's share.
  • Route an Ingress or Gateway to the sidedoc Service and terminate TLS there. Set SIDEDOC_REAL_IP_FROM to 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_FILE to the file path, or use valueFrom.secretKeyRef for NAME directly.

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_IMAGE and NGINX_IMAGE build arguments. For releases, pin them by digest too, and rebuild regularly to pick up security fixes.
  • Only GET and HEAD are allowed (405 otherwise), except POST on the Sentry tunnel. Request bodies are limited to 1 KB, headers and timeouts are tight, server_tokens is off and dotfiles are never served.