Deployment
Docker
imgx ships as a multi-arch Docker image (linux/amd64, linux/arm64) built on Alpine Linux.
Pull from GHCR
docker pull ghcr.io/officialunofficial/imgx:latestHTTP origin
docker run -p 8080:8080 \
-e IMGX_ORIGIN_TYPE=http \
-e IMGX_ORIGIN_BASE_URL=https://images.example.com \
ghcr.io/officialunofficial/imgx:latestR2 origin
docker run -p 8080:8080 \
-e IMGX_ORIGIN_TYPE=r2 \
-e IMGX_R2_ENDPOINT=https://<account_id>.r2.cloudflarestorage.com \
-e IMGX_R2_ACCESS_KEY_ID=your-access-key \
-e IMGX_R2_SECRET_ACCESS_KEY=your-secret-key \
-e IMGX_R2_BUCKET_ORIGINALS=originals \
-e IMGX_R2_BUCKET_VARIANTS=variants \
ghcr.io/officialunofficial/imgx:latestEnvironment file
docker run -p 8080:8080 --env-file .env ghcr.io/officialunofficial/imgx:latestBuild locally
docker build -t imgx .
docker run -p 8080:8080 -e IMGX_ORIGIN_BASE_URL=https://images.example.com imgxThe Dockerfile uses a two-stage build:
- Build stage —
rust:1.98.1-alpinewith vips-dev,cargo build --release - Runtime stage — Alpine with only the vips runtime library
Docker Compose
services:
imgx:
image: ghcr.io/officialunofficial/imgx:latest
ports:
- "8080:8080"
environment:
IMGX_ORIGIN_TYPE: http
IMGX_ORIGIN_BASE_URL: https://images.example.com
IMGX_CACHE_ENABLED: "true"
IMGX_CACHE_MAX_SIZE_BYTES: "536870912"
IMGX_SERVER_PORT: "8080"
healthcheck:
test: ["CMD", "wget", "--spider", "-q", "http://localhost:8080/health"]
interval: 10s
timeout: 5s
retries: 3
start_period: 5s
restart: unless-stoppedBuild from source
Requirements:
- Rust 1.98.1 (pinned in
rust-toolchain.toml) - libvips 8.14.0 or later (with development headers)
macOS
brew install vips
cargo build --release -p imgx
./target/release/imgxAlpine Linux
apk add vips-dev musl-dev pkgconfig
cargo build --release -p imgx
./target/release/imgxHealth checks and probes
imgx exposes three endpoints for monitoring.
/health
Returns 200 with {"status":"ok"} when the server is running. Use this for Docker HEALTHCHECK, load balancer health checks, and uptime monitors.
curl http://localhost:8080/health
# {"status":"ok"}/ready
Returns 200 with {"ready":true} when the server is ready to accept requests. Use this for Kubernetes readiness probes.
curl http://localhost:8080/ready
# {"ready":true}/metrics
Returns 200 with text/plain; version=0.0.4 — standard Prometheus exposition format, scrapeable directly by Prometheus, Grafana Agent, the Datadog agent's OpenMetrics check, or any compatible collector:
# HELP imgx_requests_total Total number of HTTP requests handled.
# TYPE imgx_requests_total counter
imgx_requests_total 1042
# HELP imgx_cache_hits_total Total number of image-transform cache hits.
# TYPE imgx_cache_hits_total counter
imgx_cache_hits_total 891
# HELP imgx_cache_misses_total Total number of image-transform cache misses.
# TYPE imgx_cache_misses_total counter
imgx_cache_misses_total 151
# HELP imgx_cache_entries Current number of entries in the cache (0 for backends that don't track this).
# TYPE imgx_cache_entries gauge
imgx_cache_entries 148
# HELP imgx_uptime_seconds Seconds since the server started.
# TYPE imgx_uptime_seconds gauge
imgx_uptime_seconds 3600| Metric | Type | Description |
|---|---|---|
imgx_requests_total | counter | Total HTTP requests served since startup |
imgx_cache_hits_total | counter | Requests served from cache |
imgx_cache_misses_total | counter | Requests that required an origin fetch |
imgx_cache_entries | gauge | Current entries in the L1 memory cache (0 for the R2/Noop backends, which don't track this) |
imgx_uptime_seconds | gauge | Seconds since server startup |
Kubernetes
apiVersion: apps/v1
kind: Deployment
metadata:
name: imgx
spec:
replicas: 2
selector:
matchLabels:
app: imgx
template:
metadata:
labels:
app: imgx
spec:
containers:
- name: imgx
image: ghcr.io/officialunofficial/imgx:latest
ports:
- containerPort: 8080
env:
- name: IMGX_ORIGIN_TYPE
value: "http"
- name: IMGX_ORIGIN_BASE_URL
value: "https://images.example.com"
livenessProbe:
httpGet:
path: /health
port: 8080
initialDelaySeconds: 5
periodSeconds: 10
readinessProbe:
httpGet:
path: /ready
port: 8080
initialDelaySeconds: 3
periodSeconds: 5
resources:
requests:
memory: "256Mi"
cpu: "250m"
limits:
memory: "1Gi"
cpu: "1000m"CDN
imgx is designed to sit behind a CDN. Image responses include:
Cache-Control: public, max-age=<ttl>— controlled byIMGX_CACHE_DEFAULT_TTL_SECONDSETag— content-based hash for conditional requests (304 Not Modified)Vary: Accept— tells the CDN to cache separate variants per format negotiation
Configure your CDN to forward the Accept header and respect Vary: Accept for correct content negotiation.
Upgrades and cached variants
Some releases change the output bytes of a variant. Release v0.1.5 does this for JPEG and WebP sources. Its resize decodes the source at reduced size, so the pixel bytes differ slightly. A WebP source scales with the libwebp scaler in place of the Lanczos filter. Some sizes also change by 1 px for fit=contain, fit=inside, and fit=pad.
The next release changes these variants again:
- It restores the size that a full decode gives. The 1 px change of v0.1.5 goes away.
gravity=smartandgravity=attentiondecode at full size again on the fits that crop (cover,crop, andaspect-crop). The crop window returns to the window of a full decode.rotate=0and a per-side trim of 0 give the bytes of the same request without them.
The cache key does not change. The same URL then maps to a different body and a different ETag.
A later release changes the cache key of every auto request. The format segment changes from auto to auto: plus the formats that the Accept header supports (INV-27). An auto request therefore misses the cache once after the upgrade, and imgx transforms it again. Keys for an explicit format do not change. Old auto entries are never read again. A lifecycle rule on the variants bucket removes them.
What happens with a shared L2 cache
The L2 cache (R2 or S3) is shared by all replicas. imgx does not expire an L2 entry. IMGX_CACHE_DEFAULT_TTL_SECONDS only sets the Cache-Control header. An old variant stays in the variants bucket until a lifecycle rule or a purge removes it.
During a rolling upgrade, old and new replicas share one bucket. Each replica reads the entry that the last writer stored. The same key can therefore return either variant. This has these effects:
- The
ETagof one URL can change between responses. - A request with
If-None-Matchcan return200in place of304. This costs one extra transfer. - A client can see a size that differs by 1 px from an earlier response.
No response is corrupt. Every variant is a valid image of the requested source.
Roll-out options
Choose one of these options.
- Accept the mix. The mix ends when the old L2 entries expire or you purge them. Choose this option when a 1 px change and an extra revalidation are acceptable.
- Drain and replace. Stop all old replicas, then start the new version. Purge the variants bucket. Every variant is then new. This option needs a short window with no service, or a blue-green switch.
- Use a new variants bucket. Set
IMGX_R2_BUCKET_VARIANTSto a new bucket name for the new version. Old replicas keep the old bucket, so the two versions never share an entry. Delete the old bucket after the roll-out. - Expire the old entries. Add a lifecycle rule to the variants bucket that deletes objects after a fixed number of days. The mix ends when the old entries are gone. Choose a period that matches how long you accept a mix.
Also purge the CDN. It keeps a variant for max-age seconds, which is 3600 by default. The L1 memory cache is empty after each restart, so it needs no action.
A rollback has the same effect in the other direction. The new variants stay in the bucket and the old version serves them. Use the same options.