Skip to content

Deployment

iiiris is a single Go binary linked against libvips (CGO). Container is the primary distribution form; static binaries are also fine if libvips is on the host.

Requirements

  • Go 1.25+ (build only — coreos/go-oidc and other auth deps require 1.25)
  • libvips with OpenJPEG (runtime + build):
  • macOS: brew install vips
  • Debian/Ubuntu: apt-get install libvips-dev libopenjp2-7-dev pkg-config
  • Windows: install via MSYS2 UCRT64 — see Windows (local build) below

Filesystem source I/O is memory-mapped on Unix and Windows (peak memory is independent of source size); other platforms (plan9, wasm) fall back to buffering. See docs/sources.md.

Operator quick path

Running a pre-built iiiris? The default release is a distroless container, and a release binary is attached to each GitLab Release — so you can skip the build sections below:

  1. Run itRunning from a binary, or Local development with docker compose for the bundled stack.
  2. Operate itHealth and operations (with a Kubernetes probe example) and Rate limiting and abuse protection.
  3. Configure itconfiguration.md.

The Build variants, GitLab CI, and Releases sections below are for building and publishing iiiris from source.

Build variants

iiiris has four ways to produce a runnable artifact. Pick the one that matches your deployment — they differ in how libvips is linked, which governs the LGPL compliance posture if you redistribute the result.

Variant Command Linkage Size / footprint Use when
Local binary make build Dynamic against host libvips (CGO) binary only Dev iteration, run-on-host
Release binary make release-binary Dynamic against host libvips (CGO) binary only, stripped Distributing a single binary to Linux hosts that have libvips
Debian-slim image make docker Dynamic against apt-installed libvips larger; includes shell + apt Convenience, debug-friendly Docker deployment
Distroless image docker build -f deploy/distroless/Dockerfile -t iiiris:distroless . Static libvips + OpenJPEG + transitive libs ~46 MB, no shell, no package manager Production hardening; smallest attack surface

All four produce the same iiirisd binary semantically; they differ only in what's bundled with it. The license posture follows the linkage column: dynamic = trivial; static = the LGPL §6(d) relink pointer (LIBVIPS-SOURCE.md, bundled into the distroless image) becomes the binding piece of compliance paperwork.

Local binary (make build)

For dev iteration and run-on-host deployments. Requires Go 1.25+ and libvips/OpenJPEG installed on the host (see Requirements).

go mod tidy
make build      # → ./bin/iiirisd
make run        # go run ./cmd/iiirisd
make test       # go test -race ./...
make bench      # go test -bench=. ./...
make lint       # golangci-lint run

To stamp the version into the binary explicitly:

go build -ldflags="-X main.Version=$(git describe --tags --always)" \
  -o bin/iiirisd ./cmd/iiirisd

Linkage: dynamic CGO. The binary depends on the host's libvips at runtime; move it to a different machine and the target needs the same libvips present. See Running from a binary.

Windows (local build)

Work in progress. Windows is supported as a local build target via MSYS2 UCRT64 — the binary, zip bundle, and MSI installer documented below all work end-to-end. Pre-built Windows artefacts are not currently attached to GitLab Releases: the CI automation that produces them (an EC2-orchestrator path that launches Windows builders on demand) is wired up but not yet running on tag-cuts. Run make build from an MSYS2 UCRT64 shell to produce an iiirisd.exe locally; instructions below.

For developers and self-builders running iiiris on Windows. The build flow is the same make build as Linux/macOS, executed from an MSYS2 UCRT64 shell so libvips, gcc, and pkg-config are on PATH.

One-time install (from an elevated PowerShell):

choco install msys2     # MSYS2 base
choco install golang    # Go 1.25 or later

Then open an MSYS2 UCRT64 shell (Start Menu → "MSYS2 UCRT64", not "MSYS2 MSYS" or "MSYS2 MINGW64") and install the libvips toolchain:

pacman -S --noconfirm --needed \
  mingw-w64-ucrt-x86_64-gcc \
  mingw-w64-ucrt-x86_64-pkg-config \
  mingw-w64-ucrt-x86_64-libvips \
  mingw-w64-ucrt-x86_64-openjpeg2

Versions validated by the Phase 1 spike: Go 1.25.0, gcc 16.1.0 (MSYS2 Rev5), libvips 8.18.2.

Build — from the same UCRT64 shell at the repo root:

make build              # → ./bin/iiirisd.exe
./bin/iiirisd.exe       # zero-config: serves ./testdata on :8080

cmd.exe and PowerShell do not work for the build itself: the Makefile uses GNU Make syntax that needs MSYS2's bash, and go build needs pkg-config + gcc.exe on PATH (the UCRT64 shell sets both).

Linkage: dynamic CGO. iiirisd.exe depends on ~90 MSYS2 UCRT64 DLLs at runtime (libvips, libjpeg, libtiff, libwebp, libpng, libopenjp2, the gcc runtime, plus transitive glib / harfbuzz / freetype / fontconfig deps — all under C:\msys64\ucrt64\bin\). Moving the binary to a host without MSYS2 installed requires copying those DLLs alongside the .exe.

This section covers the build-from-source path for contributors and self-builders. If you just want to run iiiris on Windows, the Releases page also publishes a prebuilt iiirisd-vX.Y.Z-windows-amd64.zip (binary + bundled DLLs + compliance bundle) per tagged release — extract and run, no MSYS2 install required.

Running as a Windows service (manual)

iiirisd.exe detects Windows Service Control Manager context automatically and behaves as an SCM-managed service when launched by it (otherwise it runs as a console process). A WiX MSI installer with optional service-install mode lands in a later release; until then, register the service manually with sc.exe:

# Adjust paths to your install location and config.
sc.exe create iiiris binPath= "`"C:\path\to\iiirisd.exe`" -config `"C:\path\to\iiiris.yaml`"" start= auto
sc.exe description iiiris "iiiris IIIF Image API server"
net start iiiris

The service writes structured logs to cfg.log.file (IIIRIS_LOG_FILE) when set — recommended for service mode, since SCM redirects the binary's stdio. A Windows Event Log handler is also wired in, but its source is registered only when the MSI installer (Phase 3c) lays down the matching HKLM\...\EventLog keys; until then the Event Log handler returns nil at startup and file + stderr cover the logging gap.

Stop and remove:

net stop iiiris
sc.exe delete iiiris

Windows installer (MSI)

Tagged releases publish iiirisd-vX.Y.Z-windows-amd64.msi alongside the zip and container images — download from the Releases page, double-click to install. The installer runs a standard Windows wizard (Welcome → License → install scope → install folder → Ready → Install → Finished); the install scope dialog offers two layouts:

  • All-users (default, recommended for any real deployment) — binary at C:\Program Files\iiiris\, data at C:\ProgramData\iiiris\{config,cache,logs}\. Per-machine, needs Administrator at install time. Service-install mode (see below) requires this layout.
  • Per-user — binary at %LocalAppData%\iiiris\, data still under %ProgramData%\iiiris\. No admin needed. Service-install mode is unavailable (no HKLM access).

A commented config.yaml.example is dropped at %ProgramData%\iiiris\config\config.yaml on first install only (NeverOverwrite="yes"); operator edits survive upgrades. Edit it to taste before running iiirisd.

Upgrade: download the new release MSI, double-click, the installer's MajorUpgrade action stops the existing iiirisd (if any), replaces the binary, and restarts. Data dirs and config.yaml are preserved.

Uninstall: Add/Remove Programs → iiiris → Uninstall, or msiexec /x iiirisd-vX.Y.Z-windows-amd64.msi /qn. The binary and its bundled DLLs are removed; data dirs at %ProgramData%\iiiris are preserved (Permanent="yes" on the data components per the brief's decisions). To wipe data completely, remove %ProgramData%\iiiris manually.

SmartScreen: the MSI is self-signed. Windows SmartScreen shows "Unknown publisher" on first run — click "More info → Run anyway" or right-click the .msi → Properties → Unblock. The self-signature proves the artifact wasn't tampered with after CI built it; it does not dodge the warning. An EV/OV certificate procurement would clear SmartScreen entirely without code change (CI just swaps the PFX).

Service mode

The MSI optionally registers iiirisd as a Windows service that starts on boot. Requires the all-users install location — the service registration lands under HKLM, which per-user installs cannot write.

Enable service mode by passing INSTALLSERVICE=1 on the command line:

msiexec /i iiirisd-vX.Y.Z-windows-amd64.msi /qn ALLUSERS=2 INSTALLSERVICE=1

(The attended GUI install will gain a checkbox in a future release; until then service mode is command-line-driven.)

When INSTALLSERVICE=1, the installer:

  • Registers a Windows service named iiiris (display name "iiiris IIIF Image Server") with Start=auto, ErrorControl=normal, running as NT AUTHORITY\LocalSystem.
  • Configures the service to invoke iiirisd.exe -config "%ProgramData%\iiiris\config\config.yaml".
  • Registers the Windows Event Log source iiiris under HKLM\...\EventLog\Application\ so structured logs appear under Event Viewer → Applications and Services Logs → iiiris.
  • Starts the service immediately on install completion.

Operate the service with the standard Windows tools:

net start iiiris
net stop iiiris
services.msc                 # GUI: list, restart, change account
sc.exe query iiiris          # status + binary path

Logs land in two places when running under SCM:

Sink Where When to read
Windows Event Log Event Viewer → Applications and Services Logs → iiiris Operational overview, errors, warnings
Rolling log file %ProgramData%\iiiris\logs\iiirisd.log (rotates at 100 MB, 5 backups) Forensic detail, full request log

Change the service account (default LocalSystem is the "just works" choice for any source backend; some sites prefer least-privilege):

  1. Open services.msc, right-click iiiris, Properties.
  2. Log On tab → "This account" → enter NT AUTHORITY\NetworkService (network-aware least-priv) or a dedicated domain account.
  3. Restart the service.

Grant the new account write access on %ProgramData%\iiiris\ (cache + logs) if you swap away from LocalSystem.

Upgrade in service mode runs the same MajorUpgrade flow as binary-mode but adds a brief downtime window: the installer stops the old service, replaces the binary, starts the new one. Single- host installs accept the seconds-long gap.

Uninstall in service mode removes the service, deregisters the Event Log source, removes the binary; data dirs and config.yaml are preserved (same Permanent="yes" contract as binary-mode).

For developers who want the source-build path, see Windows (local build) above. The MSI is the recommended path for operators.

Release binary (make release-binary)

The stripped, version-stamped flavour of the local binary — what the GitLab CI release-binary-linux-amd64 job publishes for each tagged release.

make release-binary    # → ./bin/iiirisd (stripped, version-stamped)

Linkage is still dynamic CGO — not statically linked. If you need a binary that runs without a system libvips, use the distroless image below; that's the only variant that statically links.

Architecture-wise the official CI release ships linux/amd64 and windows/amd64: the Linux binary requires libvips on the host (see above), the Windows zip bundles its own (see Releases). For macOS, build locally via brew install vips then make build; cross-compiling CGO + libvips from Linux is impractical, so no prebuilt macOS artifact is published.

Debian-slim image (make docker)

The convenient Docker variant. Bundles libvips42 + libopenjp2-7 + ca-certificates via apt, runs as non-root user iiiris (UID 10001), includes a shell and apt so you can docker exec in to debug.

make docker                                  # build, tag iiiris:dev
docker run --rm -p 8080:8080 iiiris:dev      # zero-config run
docker run --rm -p 8080:8080 \
  -v /path/to/images:/srv/images \
  -e IIIRIS_FS_ROOT=/srv/images \
  iiiris:dev                                  # mount real images

Linkage: dynamic; libvips is replaceable via apt inside the image. The LGPL relink path is trivial (apt-get install your own libvips); no extra paperwork beyond the LICENSE + NOTICE + THIRD_PARTY_LICENSES.md bundle the image already carries at /usr/share/doc/iiiris/.

The Dockerfile copies testdata/ into the image so the zero-config default works (the binary serves the bundled sample).

Distroless image (docker build -f deploy/distroless/Dockerfile .)

The production-hardening variant. Static-links libvips + OpenJPEG + libtiff + every transitive image-format library into the iiirisd binary, then ships on gcr.io/distroless/static-debian12:nonroot. ~46 MB compressed, no shell, no package manager, no apt. Runs as the distroless nonroot user (UID 65532).

docker build -f deploy/distroless/Dockerfile -t iiiris:distroless .
docker run --rm -p 8080:8080 iiiris:distroless

Build args expose every pinned upstream library version, so an operator can relink against a different libvips without source edits:

docker build -f deploy/distroless/Dockerfile \
  --build-arg VIPS_VERSION=8.17.0 \
  -t iiiris:distroless-relink .

Linkage: static. Because the libvips static archive is embedded in the binary, LGPL-2.1 §6(d) applies if you redistribute the resulting image. The image already includes the compliance bundle at /usr/share/doc/iiiris/LICENSE, NOTICE, THIRD_PARTY_LICENSES.md, and LIBVIPS-SOURCE.md (pinned versions, source URLs, build flags, one-command rebuild recipe). If you republish the image under your own tag, keep these files unchanged.

Redistribution compliance

If you redistribute iiiris in any form, the files you must ship alongside depend on the variant:

Variant Ship alongside the artifact Where they live in the artifact
Local / release binary LICENSE, NOTICE, THIRD_PARTY_LICENSES.md (manual — files travel separately)
Debian-slim image Nothing extra /usr/share/doc/iiiris/ (already inside the image)
Distroless image Nothing extra /usr/share/doc/iiiris/ (already inside the image, including LIBVIPS-SOURCE.md)

For container variants the bundle is already baked in by deploy/Dockerfile and deploy/distroless/Dockerfile; you don't need to do anything special. For bare binaries, copy the three files from the repo root next to the binary in your tarball / package / installer.

Running from a binary

Docker is convenient but not required — iiiris is one process and one binary. To run without Docker:

  1. Install runtime dependencies (libvips + OpenJPEG):
# macOS
brew install vips

# Debian / Ubuntu
sudo apt-get install libvips42 libopenjp2-7

# RHEL / Fedora
sudo dnf install vips openjpeg2

The binary needs libvips ≥ 8.x with OpenJPEG support. Any reasonably recent distro package qualifies.

  1. Get the binary — pick one of:

  2. Download a release — every tagged release publishes a Linux/amd64 iiirisd binary on the GitLab Releases page (built by release-binary-linux-amd64 in CI).

  3. Build locallymake build produces ./bin/iiirisd for your host platform. This is the only path for macOS, Windows, or architectures other than linux/amd64.

  4. Run it. Zero-config startup is supported (no flags, no file):

./iiirisd                                       # serves ./testdata on :8080
./iiirisd -config /etc/iiiris/iiiris.yaml       # production
IIIRIS_ADMIN_USER=admin IIIRIS_ADMIN_PASS=  ./iiirisd  # admin UI enabled
  1. Process supervision — iiiris handles SIGINT/SIGTERM gracefully (drains in-flight requests up to server.shutdown_timeout), so any standard supervisor works: systemd, runit, supervisord, or the container orchestrator's own lifecycle. A minimal systemd unit:
[Unit]
Description=iiiris IIIF Image API server
After=network-online.target
Wants=network-online.target

[Service]
ExecStart=/usr/local/bin/iiirisd -config /etc/iiiris/iiiris.yaml
Restart=on-failure
EnvironmentFile=-/etc/iiiris/env
User=iiiris
Group=iiiris

[Install]
WantedBy=multi-user.target

Local development with docker compose

docker-compose.yml at the repo root provides a one-command local stack:

cp .env.example .env
# edit .env: set IIIRIS_ADMIN_PASS to a real value
docker compose up --build

Then — Image API:

  • http://localhost:8080/iiif/3/thumbnail.jp2/info.json
  • http://localhost:8080/iiif/3/library/manuscript/page-001.jp2/full/400,/0/default.jpg

Presentation API (manifests + collections auto-derived from the mounted directory):

  • http://localhost:8080/iiif/presentation/3/library — Collection
  • http://localhost:8080/iiif/presentation/3/library/manuscript — Manifest (the manifest.yaml sidecar supplies the title + per-folio labels)
  • http://localhost:8080/iiif/presentation/2/library/manuscript — the 2.1 shape

Bundled viewer (open in a browser):

  • http://localhost:8080/view/library/manuscript — Mirador (default, self-hosted)
  • http://localhost:8080/view/library/manuscript?viewer=uv — Universal Viewer (from CDN)

Plus http://localhost:8080/admin/ (Basic auth from .env) and http://localhost:8080/health / /ready.

.env is gitignored — credentials never get committed.

By default the compose file bind-mounts the repo's ./examples/images directory into the container as the IIIF source root. That corpus is laid out to exercise both APIs: a root thumbnail.jp2 for the Image API, and a library/ of sub-directories (manuscript/ with a sidecar, plates/) that the Presentation API auto-derives into a collection + manifests (see examples/images/README.md). The layout matches the structure these URLs assume, so pointing IIIRIS_LOCAL_IMAGES=/path/to/your/images at any directory of the same shape (a library/ of object folders) makes the same URLs work — any folder of images is servable, and a folder-of-folders becomes a collection.

The compose stack runs iiirisd zero-config — built-in defaults plus the .env vars, no YAML file. Two consequences worth knowing:

  • Logging defaults to debug (set IIIRIS_LOG_LEVEL in .env to quiet it). The binary's own default stays info; this is a compose-only convenience for local development.
  • The /admin/config editor is read-only — with no -config file there is nowhere to persist edits. It still shows the effective configuration. To make it editable, pass a -config file via a command: override and bind-mount it read-write. See admin.md.

Optional: Redis caching

docker-compose.redis.yml is an opt-in overlay that adds a redis service and routes the render / info / manifest caches through it (via examples/config/redis-cache.yaml, passed with -config). The base stack stays heap-cached when used alone.

docker compose -f docker-compose.yml -f docker-compose.redis.yml up --build

Then exercise an endpoint and watch entries land in Redis:

curl -s localhost:8080/iiif/presentation/3/library/manuscript > /dev/null
docker exec -it iiiris-redis redis-cli keys '*'      # cache:<sha256> keys

The Redis service runs with --maxmemory 256mb --maxmemory-policy allkeys-lru — eviction is Redis's job, which is why the redis cache backend requires max_bytes: 0. See caches.md for the backend's semantics.

GitLab CI

When the pipeline runs (a workflow: block, merge-request-pipeline model): on version tags (the release pipeline), on merge requests that touch code/build inputs, and on pushes to the default branch. A docs-only MR runs no pipeline (nothing in the pipeline builds or tests docs — docs/**, *.md, LICENSE/NOTICE, logos/**, examples/**, docker-compose*.yml, .env.example are excluded), and a bare feature-branch push runs nothing until you open an MR. The changes: globs in workflow: enumerate code/build paths — a new top-level code directory must be added there or it won't trigger CI. Docs still get no MR pipeline; they publish only after merge, via the pages job (see Documentation site).

After a ci-image stage that builds the CGO + libvips image the rest of the pipeline reuses, the stages are:

  • lintgolangci-lint (allow_failure: true — advisory).
  • testgo test ./...; load-bearing.
  • benchbench-smoke, a fast subset of make bench (allow_failure: true).
  • builddocker-build (debian-slim) and docker-build-distroless; both load-bearing.
  • validateiiif-validate runs the official IIIF Image API validator for v2 + v3 (allow_failure: true; the residual failures are upstream validator bugs / spec disagreements — see iiif-compliance.md). iiif-auth-validate drives the IIIF Auth 2.0 conformance harness and is load-bearing — a regression in any auth pattern fails the pipeline.

The release stage runs only on version tags; see Releases. The final pages stage publishes the documentation site; see Documentation site.

Runner topology

  • Linux jobs (ci-image, lint, test, bench, docker-build, docker-build-distroless, iiif-validate, iiif-auth-validate, release-binary-*) run on personal runners tagged iiiris. Those runners have a Docker daemon (SaaS shared Linux runners don't — they refuse docker build inside containerised job images). default: tags: [iiiris] at the top of .gitlab-ci.yml enforces this; per-job overrides are explicit.
  • Windows jobs
  • build-windows-amd64 runs on the iiiris-tagged Linux runner via the EC2 build orchestrator: launches an ephemeral m5.large Windows EC2 from a Packer-baked AMI (see deploy/packer/), drives the build via SSM, terminates the instance on exit. Avoids the SaaS Windows minute cap entirely. AWS infrastructure is Terraform-managed (see deploy/aws/).
  • build-windows-msi (tag-only) still uses tags: [saas-windows-medium-amd64] on the GitLab SaaS Windows pool. Migration to the EC2 orchestrator is follow-up work — the MSI build's PowerShell sequence (wix-generate-dlls.ps1 + wix build + smoke-msi.ps1) doesn't share the EC2 orchestrator's template verbatim.

Setting up the Windows EC2 runner

One-time setup on a maintainer machine (the GitLab project must already have CI variables for AWS_*; see step 4):

  1. Terraform — provision IAM, S3, VPC, security group:

    cd deploy/aws terraform init terraform apply

  2. Packer — bake the custom AMI with MSYS2 + Go + WiX pre-installed:

    cd deploy/packer export PKR_VAR_subnet_id=$(terraform -chdir=../aws output -raw subnet_id) export PKR_VAR_security_group_id=$(terraform -chdir=../aws output -raw security_group_id) export PKR_VAR_aws_region=$(terraform -chdir=../aws output -raw aws_region) packer init windows-build-ami.pkr.hcl packer build windows-build-ami.pkr.hcl

  3. Plug Terraform outputs into GitLab CI variables (project Settings → CI/CD → Variables; mark access keys as masked + protected):

    AWS_ACCESS_KEY_ID = $(terraform output -raw ci_user_access_key_id) AWS_SECRET_ACCESS_KEY = $(terraform output -raw ci_user_secret_access_key) AWS_DEFAULT_REGION = $(terraform output -raw aws_region) IIIRIS_ARTIFACT_BUCKET = $(terraform output -raw artifact_bucket) IIIRIS_INSTANCE_PROFILE = $(terraform output -raw instance_profile_name) IIIRIS_SECURITY_GROUP_ID = $(terraform output -raw security_group_id) IIIRIS_SUBNET_ID = $(terraform output -raw subnet_id)

  4. Push a Windows-touching change — first build-windows-amd64 run launches an EC2 from the new AMI, runs the build, tears the instance down. Expected wall-clock: ~3–8 minutes warm.

Total AWS cost: ~$1.50/month idle (mostly AMI snapshot storage) + ~$0.10/build (ephemeral EC2 time). See docs/briefs/WINDOWS_EC2_RUNNER.md for the design + cost decomposition.

Documentation site

The docs you're reading are published to https://docs.iiiris.org by the pages CI job, built from the docs/ Markdown tree with MkDocs + the Material theme. The site config is mkdocs.yml; a build hook (mkdocs_hooks.py) imports README.md and CHANGELOG.md, injects the logo, and rewrites links so the source docs render unchanged. Briefs (docs/briefs/) are excluded.

Publishing model (driven by scripts/docs-deploy.sh):

  • A push to the default branch that changes docs inputs (docs/**, mkdocs.yml, mkdocs_hooks.py, README.md, CHANGELOG.md) deploys the dev version — the docs tracking main.
  • A release tag vX.Y.Z deploys version X.Y aliased latest (the default the site root redirects to).

Versions are managed with mike and stored on the gh-pages branch (mike's version store — GitLab Pages serves the exported public/ artifact, not the branch). The header version selector switches between latest, dev, and each released X.Y.

Local preview

python3 -m venv .venv && . .venv/bin/activate
pip install -r docs/requirements.txt
mkdocs serve            # live-reload preview at http://127.0.0.1:8000
mkdocs build --strict   # what CI runs; fails on broken internal links

Adding a new docs/*.md? Also add it to the nav: in mkdocs.yml — the navigation is hand-authored, so a new page is otherwise unlinked (and --strict flags it as omitted).

One-time GitLab setup

Two things a maintainer configures once, in the GitLab project:

  1. Deploy token. mike pushes the gh-pages version store, which the pipeline's CI_JOB_TOKEN can't do. Create a Project Access Token with the write_repository scope (Settings → Access Tokens) and add it as a masked CI/CD variable named DOCS_DEPLOY_TOKEN (Settings → CI/CD → Variables). Without it the pages job fails fast.

  2. Custom domain + DNS. Under Deploy → Pages → New Domain, add docs.iiiris.org; GitLab returns a verification code. At the DNS provider for iiiris.org, add:

Type Name Value
CNAME docs iiiris-org.gitlab.io.
TXT _gitlab-pages-verification-code.docs gitlab-pages-verification-code=<code>

Then Verify in GitLab and enable automatic certificate management for a Let's Encrypt HTTPS cert. No CNAME file lives in the repo — GitLab uses the Pages settings + DNS, not a repo file.

Environment summary

See configuration.md for the full env-var table. Minimum env for a real deployment:

# filesystem source
IIIRIS_ADDR=:8080
IIIRIS_FS_ROOT=/srv/images
IIIRIS_LOG_FORMAT=json
IIIRIS_ADMIN_USER=admin
IIIRIS_ADMIN_PASS=<random>

# S3 source (additional)
IIIRIS_SOURCE_DEFAULT=s3
IIIRIS_S3_BUCKET=my-images
IIIRIS_S3_REGION=us-east-1
AWS_ACCESS_KEY_ID=...
AWS_SECRET_ACCESS_KEY=...

Health and operations

  • GET /health → 200 (liveness probe — always 200 if the process is alive)
  • GET /ready → 200 with per-source health JSON, or 503 if any source is unreachable. Use this as the readiness probe in Kubernetes etc.
  • /admin/ → operator UI (auth-gated). The dashboard surfaces per-source health alongside cache and route metrics.
  • Logs: structured JSON or text via slog; IIIRIS_LOG_FORMAT=json recommended for production
  • Graceful shutdown: SIGINT/SIGTERM triggers an http.Server.Shutdown with server.shutdown_timeout budget (default 10s)

Kubernetes probe example

livenessProbe:
  httpGet: { path: /health, port: 8080 }
  periodSeconds: 10
readinessProbe:
  httpGet: { path: /ready,  port: 8080 }
  periodSeconds: 30
  failureThreshold: 2

Prometheus metrics

Opt-in via metrics.enabled: true (or IIIRIS_METRICS_ENABLED=true). Metrics are in-process counters scraped per replica — no Redis or any other shared backend is involved, in any deployment shape; Prometheus aggregates across replicas itself. Families cover HTTP (requests/duration/bytes by route class), cache operations and footprint per cache role, source open latency and outcomes, render pipeline timings (by decode path) and queue wait, auth decisions, source health, build info, and the standard Go/process collectors.

Default: GET /metrics on the main listener, no auth.

scrape_configs:
  - job_name: iiiris
    static_configs:
      - targets: ["iiiris.internal:8080"]

To require credentials on the endpoint (metrics.username + metrics.password):

    basic_auth:
      username: scraper
      password: <password>

To keep metrics off the public port entirely, set metrics.addr (e.g. ":9090"); the endpoint then moves to a dedicated listener — firewall it as you see fit — and is not served on the main one:

metrics:
  enabled: true
  addr: ":9090"

Scrape-cost note: cache footprint gauges read Stats() at scrape time; for a filesystem cache that walks the cache tree, so keep scrape intervals at the usual 15–60 s, not sub-second. The per-source health gauge runs the same bounded healthcheck as /ready on each scrape.

The full metric-family and label contract is docs/specs/metrics.md.

/ready runs every configured source's healthcheck in parallel with a 5-second per-source timeout, so probe latency stays bounded even with a slow upstream.

Rate limiting and abuse protection

iiiris deliberately ships no general rate limiter. Abusive-traffic and DDoS defence belong at the edge — a reverse proxy or CDN sees all traffic, rejects bad requests before they reach the origin, and (for a CDN) absorbs volume the origin never could. For an image server a CDN is usually in front anyway; put rate limiting there.

Reverse proxy (nginx)limit_req gives per-IP request-rate limiting:

# http{} block
limit_req_zone $binary_remote_addr zone=iiif:10m rate=20r/s;

# server{} / location{} block
location /iiif/ {
    limit_req zone=iiif burst=40 nodelay;
    proxy_pass http://iiiris_upstream;
}

Caddy (rate_limit), HAProxy (stick-table), and API gateways have equivalents; a CDN (Cloudflare, Fastly, CloudFront) layers volumetric DDoS protection and edge caching on top.

In-app per-IP concurrency cap — as defence-in-depth, and the only option for a bare-binary deployment with nothing in front, iiiris offers one opt-in knob:

image:
  max_concurrent_per_ip: 8   # 0 = disabled (default)

It caps how many IIIF requests a single client IP may have in flight at once. This is a concurrency cap, not a rate limiter: steady, well-paced traffic is never rejected — it only stops one client from occupying every render slot. Over-cap requests get 429 Too Many Requests. It applies to /iiif/... only; /health, /ready, and /admin are never throttled.

It ships disabled, and deliberately so: unlike the global image.max_concurrent guard, a per-IP cap can reject legitimate traffic when many users share one egress IP — a NAT'd reading room, a campus. Two things to get right before enabling it:

  • Behind a reverse proxy, set auth.trusted_proxies to the proxy's CIDRs — otherwise every request carries the proxy's IP and the cap throttles the whole server as if it were a single client.
  • Size it for your busiest shared IP, not one user: a reading room of 30 patrons behind one NAT needs headroom well above 8.

If a proxy or CDN already does per-client limiting, leaving this at 0 is fine — it is a backstop, not a replacement.

Releases

Tagged releases are published by the GitLab CI pipeline when a signed annotated tag matching v*.*.* is pushed. The release contract — versioning rules, latest semantics, rollback policy, the cut-release flow — is in specs/releases.md; the rest of this section is the operator-facing reference for consuming a release.

Each release produces:

  • A distroless multi-arch container image (linux/amd64 + linux/arm64) at $CI_REGISTRY_IMAGE:vX.Y.Z and :latest. This is what docker pull gets by default. Pull with docker pull $CI_REGISTRY_IMAGE:vX.Y.Z.
  • A debian-slim multi-arch container image at $CI_REGISTRY_IMAGE:vX.Y.Z-debian (no :latest-debian tag). Use this if you need a shell in the container — for kubectl exec debugging, sidecar tooling, or to roll back from a distroless-specific issue.
  • A Linux/amd64 binary attached to the GitLab Release. Requires libvips + OpenJPEG to be installed on the host (apt-get install libvips42 libopenjp2-7).
  • A Windows/amd64 zip (iiirisd-vX.Y.Z-windows-amd64.zip) attached to the GitLab Release. Contains iiirisd.exe plus its MSYS2 UCRT64 runtime DLLs (libvips, libjpeg, libtiff, libwebp, libpng, libopenjp2, plus the gcc runtime and transitive deps) and the compliance bundle (LICENSE, NOTICE, THIRD_PARTY_LICENSES.md). Extract anywhere and run iiirisd.exe directly — no MSYS2 install required on the target host. Linkage is dynamic; LGPL §6(d) is satisfied trivially by drop-in replacement of the libvips DLL.
  • A Windows/amd64 MSI installer (iiirisd-vX.Y.Z-windows-amd64.msi) attached to the GitLab Release. Double-click installer with per-user / all-users layout choice, default config drop, MajorUpgrade for clean version bumps, and data preservation on uninstall. Self-signed; SmartScreen shows "Unknown publisher" on first run. See Windows installer (MSI) above for the full operator walkthrough.
  • A .sha256 file alongside each binary artifact (Linux amd64 binary, Windows zip, Windows MSI). Verify a download with sha256sum -c iiirisd-vX.Y.Z-linux-amd64.sha256 (or shasum -a 256 -c on macOS) from the directory containing both files.
  • A GitLab Release entry whose description is built from the [vX.Y.Z] section of CHANGELOG.md, prepended with the annotated-tag message as a one-line supplemental summary.

Choosing distroless vs. debian-slim

Concern Distroless (default) Debian-slim (-debian)
Image size ~46 MB ~120 MB
Shell / package manager No Yes (bash, apt)
Attack surface Smaller Larger
kubectl exec debugging Use ephemeral debug pod Direct
libvips upgrade path Rebuild iiiris release apt-get upgrade in sidecar

Default to distroless. Switch to -debian if you specifically need a shell, or as a rollback if a distroless-specific issue surfaces.

To cut a release as a maintainer, follow the flow in specs/releases.md#how-to-cut-a-release: scripts/cut-release.sh vX.Y.Z flips CHANGELOG.md and opens an MR; after merge, git tag -s vX.Y.Z -m "one-line summary"; git push --tags fires the release pipeline.

The build stamps the version into the binary via -ldflags="-X main.Version=..."; the running binary reports it on the /admin/ status dashboard.

Pre-release smoke: multi-replica EFS + Redis coordinator

Multi-replica iiirisd on a shared NFS/EFS mount is officially supported (via the filesystem cache backend's distributed mode) but not exercised in CI — the synthetic NFS topologies CI could run wouldn't faithfully reproduce EFS's NFSv4 quirks. Run this manual smoke before tagging a release that touches internal/cache/filesystem*.go, internal/cache/build.go, or the coordinator config surface.

Topology.

┌──────────────────────────────────────────────────────────────┐
│  VPC                                                         │
│                                                              │
│   ┌──────────────────┐         ┌──────────────────┐          │
│   │ iiirisd replica A │         │ iiirisd replica B │          │
│   │  EC2 t3.medium    │         │  EC2 t3.medium    │          │
│   └──┬────────────┬───┘         └──┬────────────┬───┘          │
│      │            │                │            │              │
│      │ NFSv4      │ TCP            │ NFSv4      │ TCP           │
│      ▼            ▼                ▼            ▼              │
│   ┌─────┐     ┌───────────┐                ┌───────────┐       │
│   │ EFS │     │ ElastiCache  │              │           │       │
│   │     │     │   Redis     │              │           │       │
│   └─────┘     └───────────┘                            │       │
│                                                              │
└──────────────────────────────────────────────────────────────┘

Setup.

  1. EFS. Create an EFS file system in the same VPC as the test instances; set both replicas' mount-target security groups to allow NFSv4 (TCP 2049) from each other.
  2. ElastiCache for Redis (or any reachable Redis ≥ 7) in the same VPC; allow TCP 6379 from both replicas. Auth is optional but recommended; if set, expose via env var.
  3. Two iiirisd instances running the binary or container under test. Both mount EFS at /mnt/efs and use the same config:

    caches: render: backend: filesystem path: /mnt/efs/iiiris/render max_bytes: 10737418240 # 10 GiB coordinator: redis: addr: :6379 password_env: REDIS_PASSWORD sources: filesystem: root: /mnt/efs/iiiris/sources

Drop two or three test images into the EFS sources directory so both replicas serve the same IIIF identifiers.

Drive the workload.

Pick an ALB (or front the two replicas with nginx in round-robin), then hit the same IIIF tile URL repeatedly so both replicas see writes and reads. The simplest driver:

URL='http://<alb-or-proxy>/iiif/3/sample/0,0,512,512/full/0/default.jpg'
for i in $(seq 1 200); do
  for r in 0,0 512,0 0,512 512,512 1024,0; do
    curl -s -o /dev/null "${URL//0,0,512,512/${r},512,512}"
  done
done

Equivalently, run iiif-validator or just point Mirador at one of the identifiers — anything that produces enough Get/Put activity to exercise the index.

What to verify.

After ~5 minutes of load, on each replica:

  • curl http://localhost:8080/admin/stats should report a Render cache with non-zero entry count. Both replicas should report the same Entries and Bytes (within ±1 entry for in-flight writes; the numbers come from Redis and reflect a shared index).
  • The eviction lease key in Redis (iiiris:fscache:<8-hex>:lease) should exist briefly during eviction and disappear after. If it's permanently held, something is wrong — check that the holder isn't wedged.
  • ls /mnt/efs/iiiris/render | wc -l matches the indexed entry count (modulo tmp-* files; rare in steady state).

What "no drift" looks like.

  • Stop both replicas. Take Stats from Redis directly:

    redis-cli -h HLEN iiiris:fscache::entries redis-cli -h GET iiiris:fscache::total

Then walk EFS and count hash-named files: the entry count should match HLEN. Sum the file sizes: should match total. Any divergence

a few entries indicates a reconciliation bug — surface it before release.

  • Restart one replica. It runs the reconciliation pass on startup (visible at debug log level: cache.filesystem-redis: reconciled). Stats from both replicas should still agree after the restart.

If it diverges, capture the divergence (HLEN, file count, ten sample hashes from each side), open an issue, and do not tag the release. The local-mode test suite catches most regressions, but EFS-specific lease-recovery or rename-atomicity bugs only surface here.

Rolling back a release

If a distroless-specific regression slips through, the debian-slim image for the same release is already published and ready:

# Whatever previously ran:
#   image: registry.example/iiiris:v0.2.0
# Becomes:
image: registry.example/iiiris:v0.2.0-debian

Same iiirisd binary, same config, dynamically linked against the distro libvips instead of static. Use it to unblock production, then file an issue describing the distroless-specific failure so the next release can fix forward.

What's not yet shipped

  • Pre-built Windows artefacts on GitLab Releases. Local Windows builds work end-to-end via MSYS2 (zip + MSI documented above), but the CI path that produces those artefacts on tag-cuts — an EC2-orchestrator that launches Windows builders on demand — is wired up but on hold pending some last-mile AMI-bake reliability work (see MR !19). The relevant CI jobs (build-windows-amd64, build-windows-msi) are kept in .gitlab-ci.yml but gated to RUN_WINDOWS=1 manual triggers so they don't run on every push.
  • Helm chart / Kubernetes manifests.
  • macOS binaries — no published artifact; build locally with make build after brew install vips.
  • In-installer GUI checkbox for service mode. Service-install mode itself ships (enable via msiexec /i ... INSTALLSERVICE=1), but attended GUI installs don't yet expose it as a wizard checkbox — that's a future custom-dialog polish. See docs/specs/windows-build.md "Out of scope".
  • Code-signing with an EV/OV certificate. The MSI is self-signed today; SmartScreen shows "Unknown publisher" on first run. The CI wiring (SIGN_MSI, CERT_PFX_FILE, CERT_PFX_PASSWORD) is ready for a future EV/OV cert procurement — flip the PFX, no code change.
  • Full IIIRIS_* env-var override coverage. Many config fields have no env-var override (image limits, server timeouts, cache backends/TTLs, auth); only the subset wired in internal/config/load.go is covered. Expanding coverage is a surface addition, deferred to post-1.0.
  • source.Cached key namespacing. The OriginCache key prefix is the backend kind (Name()), so two sources of the same kind — e.g. two S3 sources with different buckets — would share a cache namespace. A latent correctness limitation; the fix changes contractual cache keys, so it's deferred to post-1.0 (with a cache-key-version bump when it lands).
  • Per-backend ETag scheme spec note. Cache backends intentionally diverge in how they derive ETags (content hash in heap, size+mtime in filesystem, none in redis). The behavior is contractual as-is; what's deferred is a spec note documenting the scheme per backend.