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-oidcand 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:
- Run it — Running from a binary, or Local development with docker compose for the bundled stack.
- Operate it — Health and operations (with a Kubernetes probe example) and Rate limiting and abuse protection.
- Configure it —
configuration.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:
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 buildfrom an MSYS2 UCRT64 shell to produce aniiirisd.exelocally; 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):
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:
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:
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 atC:\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:
(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") withStart=auto,ErrorControl=normal, running asNT AUTHORITY\LocalSystem. - Configures the service to invoke
iiirisd.exe -config "%ProgramData%\iiiris\config\config.yaml". - Registers the Windows Event Log source
iiirisunderHKLM\...\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):
- Open
services.msc, right-click iiiris, Properties. - Log On tab → "This account" → enter
NT AUTHORITY\NetworkService(network-aware least-priv) or a dedicated domain account. - 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.
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:
- 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.
-
Get the binary — pick one of:
-
Download a release — every tagged release publishes a Linux/amd64
iiirisdbinary on the GitLab Releases page (built byrelease-binary-linux-amd64in CI). -
Build locally —
make buildproduces./bin/iiirisdfor your host platform. This is the only path for macOS, Windows, or architectures other than linux/amd64. -
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
- 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.jsonhttp://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— Collectionhttp://localhost:8080/iiif/presentation/3/library/manuscript— Manifest (themanifest.yamlsidecar 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(setIIIRIS_LOG_LEVELin.envto quiet it). The binary's own default staysinfo; this is a compose-only convenience for local development. - The
/admin/configeditor is read-only — with no-configfile there is nowhere to persist edits. It still shows the effective configuration. To make it editable, pass a-configfile via acommand:override and bind-mount it read-write. Seeadmin.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:
lint—golangci-lint(allow_failure: true— advisory).test—go test ./...; load-bearing.bench—bench-smoke, a fast subset ofmake bench(allow_failure: true).build—docker-build(debian-slim) anddocker-build-distroless; both load-bearing.validate—iiif-validateruns the official IIIF Image API validator for v2 + v3 (allow_failure: true; the residual failures are upstream validator bugs / spec disagreements — seeiiif-compliance.md).iiif-auth-validatedrives 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 refusedocker buildinside containerised job images).default: tags: [iiiris]at the top of.gitlab-ci.ymlenforces this; per-job overrides are explicit. - Windows jobs —
build-windows-amd64runs on the iiiris-tagged Linux runner via the EC2 build orchestrator: launches an ephemeral m5.large Windows EC2 from a Packer-baked AMI (seedeploy/packer/), drives the build via SSM, terminates the instance on exit. Avoids the SaaS Windows minute cap entirely. AWS infrastructure is Terraform-managed (seedeploy/aws/).build-windows-msi(tag-only) still usestags: [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):
-
Terraform — provision IAM, S3, VPC, security group:
cd deploy/aws terraform init terraform apply
-
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
-
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)
-
Push a Windows-touching change — first
build-windows-amd64run 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 thedevversion — the docs trackingmain. - A release tag
vX.Y.Zdeploys versionX.Yaliasedlatest(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 thenav:inmkdocs.yml— the navigation is hand-authored, so a new page is otherwise unlinked (and--strictflags it as omitted).
One-time GitLab setup¶
Two things a maintainer configures once, in the GitLab project:
-
Deploy token. mike pushes the
gh-pagesversion store, which the pipeline'sCI_JOB_TOKENcan't do. Create a Project Access Token with thewrite_repositoryscope (Settings → Access Tokens) and add it as a masked CI/CD variable namedDOCS_DEPLOY_TOKEN(Settings → CI/CD → Variables). Without it thepagesjob fails fast. -
Custom domain + DNS. Under Deploy → Pages → New Domain, add
docs.iiiris.org; GitLab returns a verification code. At the DNS provider foriiiris.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=jsonrecommended for production - Graceful shutdown: SIGINT/SIGTERM triggers an
http.Server.Shutdownwithserver.shutdown_timeoutbudget (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.
To require credentials on the endpoint (metrics.username +
metrics.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:
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:
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_proxiesto 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.Zand:latest. This is whatdocker pullgets by default. Pull withdocker pull $CI_REGISTRY_IMAGE:vX.Y.Z. - A debian-slim multi-arch container image at
$CI_REGISTRY_IMAGE:vX.Y.Z-debian(no:latest-debiantag). Use this if you need a shell in the container — forkubectl execdebugging, 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. Containsiiirisd.exeplus 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 runiiirisd.exedirectly — 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,MajorUpgradefor 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
.sha256file alongside each binary artifact (Linux amd64 binary, Windows zip, Windows MSI). Verify a download withsha256sum -c iiirisd-vX.Y.Z-linux-amd64.sha256(orshasum -a 256 -con macOS) from the directory containing both files. - A GitLab Release entry whose description is built from the
[vX.Y.Z]section ofCHANGELOG.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.
- 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.
- 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.
-
Two iiirisd instances running the binary or container under test. Both mount EFS at
/mnt/efsand 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/statsshould 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 -lmatches 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.ymlbut gated toRUN_WINDOWS=1manual triggers so they don't run on every push. - Helm chart / Kubernetes manifests.
- macOS binaries — no published artifact; build locally with
make buildafterbrew 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. Seedocs/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 ininternal/config/load.gois covered. Expanding coverage is a surface addition, deferred to post-1.0. source.Cachedkey 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.