Skip to content

Deployment

Docker

The fastest way to get started is the pre-built image. Get the example config, fill in the required fields, then start the container:

cp config.example.toml config.toml
$EDITOR config.toml
docker compose up -d

docker-compose.yml:

services:
  screenshotter:
    image: ghcr.io/mkende/screenshotter:latest
    restart: unless-stopped
    environment:
      SCREENSHOTTER_CONFIG: /etc/screenshotter/config.toml
    volumes:
      - ./config.toml:/etc/screenshotter/config.toml:ro
      - screenshotter-data:/var/lib/screenshotter
    ports:
      - "127.0.0.1:8080:8080"

volumes:
  screenshotter-data:

Point server.storage_path and db.dsn under /var/lib/screenshotter in the config so that the data lands on the volume.

Build from source

Requirements:

  • Go 1.25 or later, with CGo enabled (the SQLite driver needs it);
  • a C compiler (gcc or clang).
git clone https://github.com/mkende/screenshotter_server
cd screenshotter_server
go build -o screenshotter ./cmd/screenshotter

This produces a single self-contained binary. Database migrations run automatically on startup.

Running

./screenshotter -config /etc/screenshotter/config.toml

The server listens on listen_addr (0.0.0.0:8080 by default) and leaves TLS to a reverse proxy. HTTPS is required in production: the session cookie is marked Secure, so logins over plain HTTP do not stick.

Reverse proxy

Forward to 127.0.0.1:8080 (or whatever listen_addr says), and add the proxy’s address range to trusted_proxy in config.toml so that the forwarded headers are honoured.

Caddy (/etc/caddy/Caddyfile):

screenshots.example.com {
    reverse_proxy 127.0.0.1:8080
}

nginx (/etc/nginx/sites-available/screenshotter):

server {
    listen 443 ssl;
    server_name screenshots.example.com;

    ssl_certificate     /etc/ssl/certs/screenshots.example.com.pem;
    ssl_certificate_key /etc/ssl/private/screenshots.example.com.key;

    location / {
        proxy_pass         http://127.0.0.1:8080;
        proxy_set_header   Host              $host;
        proxy_set_header   X-Real-IP         $remote_addr;
        proxy_set_header   X-Forwarded-For   $proxy_add_x_forwarded_for;
        proxy_set_header   X-Forwarded-Proto $scheme;
        client_max_body_size 10m;
    }
}

Keep the proxy’s body size limit above server.max_upload_mb.

systemd

# /etc/systemd/system/screenshotter.service
[Unit]
Description=Screenshotter server
After=network.target

[Service]
ExecStart=/usr/local/bin/screenshotter -config /etc/screenshotter/config.toml
Restart=on-failure
User=screenshotter
Group=screenshotter
# Storage and DB directories must be owned by this user.

[Install]
WantedBy=multi-user.target
systemctl daemon-reload
systemctl enable --now screenshotter

Kubernetes

The server keeps its images on a volume and, with SQLite, its database too, so run it as a single-replica Deployment with a Recreate strategy over one ReadWriteOnce claim. The image looks for its config where the SCREENSHOTTER_CONFIG variable points, and secrets are read from the environment through the *_env_var settings.

apiVersion: v1
kind: ConfigMap
metadata:
  name: screenshotter-config
data:
  config.toml: |
    canonical_address = "https://screenshots.example.com"
    jwt_secret_env_var = "SCREENSHOTTER_JWT_SECRET"
    # Pod CIDR of the cluster, so the ingress controller's forwarded headers
    # (scheme and client address) are honoured.
    trusted_proxy = ["10.0.0.0/8"]

    [server]
    storage_path = "/var/lib/screenshotter/images"

    [db]
    driver = "sqlite"
    dsn = "/var/lib/screenshotter/db.sqlite"

    [oidc]
    enabled = true
    issuer = "https://accounts.google.com"
    client_id = "<your-client-id>"
    client_secret_env_var = "SCREENSHOTTER_OIDC_CLIENT_SECRET"
---
apiVersion: v1
kind: Secret
metadata:
  name: screenshotter-secrets
stringData:
  SCREENSHOTTER_JWT_SECRET: "<openssl rand -hex 32>"
  SCREENSHOTTER_OIDC_CLIENT_SECRET: "<your-client-secret>"
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: screenshotter-data
spec:
  accessModes: [ReadWriteOnce]
  resources:
    requests:
      storage: 20Gi
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: screenshotter
spec:
  replicas: 1
  strategy:
    type: Recreate
  selector:
    matchLabels:
      app: screenshotter
  template:
    metadata:
      labels:
        app: screenshotter
    spec:
      containers:
        - name: screenshotter
          image: ghcr.io/mkende/screenshotter:latest
          env:
            - name: SCREENSHOTTER_CONFIG
              value: /etc/screenshotter/config.toml
          envFrom:
            - secretRef:
                name: screenshotter-secrets
          ports:
            - name: http
              containerPort: 8080
          volumeMounts:
            - name: config
              mountPath: /etc/screenshotter
              readOnly: true
            - name: data
              mountPath: /var/lib/screenshotter
          readinessProbe:
            tcpSocket:
              port: http
          livenessProbe:
            tcpSocket:
              port: http
      volumes:
        - name: config
          configMap:
            name: screenshotter-config
        - name: data
          persistentVolumeClaim:
            claimName: screenshotter-data
---
apiVersion: v1
kind: Service
metadata:
  name: screenshotter
spec:
  selector:
    app: screenshotter
  ports:
    - name: http
      port: 80
      targetPort: http
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: screenshotter
  annotations:
    # ingress-nginx; keep the limit above server.max_upload_mb.
    nginx.ingress.kubernetes.io/proxy-body-size: 10m
spec:
  ingressClassName: nginx
  tls:
    - hosts: [screenshots.example.com]
      secretName: screenshotter-tls
  rules:
    - host: screenshots.example.com
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: screenshotter
                port:
                  name: http

trusted_proxy matters here: the ingress controller reaches the pod over plain HTTP, and without it the server would ignore the X-Forwarded-Proto header, see every request as http and redirect it to the canonical https address in a loop. Use the ingress controller’s pod address range (or the whole pod CIDR, as above).

Pin the image to a release tag rather than latest for anything you want to upgrade deliberately. Several replicas are not supported yet: they would need PostgreSQL and a shared ReadWriteMany volume, and the server does not coordinate its writes across instances.

Storage and backups

Images live under server.storage_path:

<storage_path>/
  <id>.png          # full-size PNG
  thumbs/<id>.png   # 320 px thumbnail, generated at upload time

Both directories are created on startup. Titles, source URLs and ownership are in the database, so a backup needs both the storage directory and the SQLite file (or a PostgreSQL dump).

Upgrading

Replace the binary or pull the new image and restart: schema migrations run on startup.