Skip to content
LogoLogo

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

HTTP origin

docker run -p 8080:8080 \
  -e IMGX_ORIGIN_TYPE=http \
  -e IMGX_ORIGIN_BASE_URL=https://images.example.com \
  ghcr.io/officialunofficial/imgx:latest

R2 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:latest

Environment file

docker run -p 8080:8080 --env-file .env ghcr.io/officialunofficial/imgx:latest

Build locally

docker build -t imgx .
docker run -p 8080:8080 -e IMGX_ORIGIN_BASE_URL=https://images.example.com imgx

The Dockerfile uses a two-stage build:

  1. Build stage — rust:1.98.1-alpine with vips-dev, cargo build --release
  2. 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-stopped

Build 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/imgx

Alpine Linux

apk add vips-dev musl-dev pkgconfig
cargo build --release -p imgx
./target/release/imgx

Health 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
MetricTypeDescription
imgx_requests_totalcounterTotal HTTP requests served since startup
imgx_cache_hits_totalcounterRequests served from cache
imgx_cache_misses_totalcounterRequests that required an origin fetch
imgx_cache_entriesgaugeCurrent entries in the L1 memory cache (0 for the R2/Noop backends, which don't track this)
imgx_uptime_secondsgaugeSeconds 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 by IMGX_CACHE_DEFAULT_TTL_SECONDS
  • ETag — 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=smart and gravity=attention decode at full size again on the fits that crop (cover, crop, and aspect-crop). The crop window returns to the window of a full decode.
  • rotate=0 and 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 ETag of one URL can change between responses.
  • A request with If-None-Match can return 200 in place of 304. 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.

  1. 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.
  2. 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.
  3. Use a new variants bucket. Set IMGX_R2_BUCKET_VARIANTS to 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.
  4. 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.