Deployment
xhr.dev ships as a single Docker image. It runs entirely inside your infrastructure — there is no call back to xhr.dev at runtime, and no traffic, proxy credentials, or target-site data ever leaves your environment.
Multi-platform. The image supports both ARM64 (Apple Silicon, ARM Linux) and x86-64 hosts. Docker pulls the correct architecture automatically.
Prerequisites
Onboarding gives you three things:
- An invite to a private GitHub organisation —
xhrdev-<your-org>, created for you. Accept it before anything else; the image is published there and isn't public. licence.json— your licence (customer ID, enabled features, validity window).licence.sig— a cryptographic signature over the licence.
The container verifies the licence pair locally, at startup — there is no network call to an xhr.dev licence server, and the runtime is not permitted to make one (see Network posture). If the licence is missing, expired, or tampered with, the container exits immediately with {"event":"launcher_failed"} and a non-zero exit code. When your licence nears expiry, xhr.dev sends updated licence.json / licence.sig files — no image rebuild or redeploy of anything but those two files is needed.
Don't have a licence yet? Contact us or book a call.
Pulling the image
The image is private, published to your own organisation's GitHub Container Registry namespace. Accept the org invite first, then authenticate to ghcr.io with a GitHub personal access token that has the read:packages scope:
echo "$GITHUB_TOKEN" | docker login ghcr.io -u <your-github-username> --password-stdin
docker pull ghcr.io/xhrdev-<your-org>/xhrdev:latestSubstitute the namespace xhr.dev gave you — it's xhrdev- followed by your organisation name, and every example below uses ghcr.io/xhrdev-<your-org>/xhrdev:latest for it.
denied / manifest unknown on pull
Almost always one of three things, in this order: you haven't accepted the org invite; your token is missing read:packages; or you're pulling ghcr.io/xhrdev/… rather than your own ghcr.io/xhrdev-<your-org>/… namespace. docker login succeeding proves the token is valid, not that it can see the package.
CI and orchestrators need the same credential. On Kubernetes that's an imagePullSecret of type kubernetes.io/dockerconfigjson; on ECS, a repository credential in Secrets Manager.
Running
Option A — volume mount (recommended)
Create a directory containing both licence files:
licence/
├── licence.json
└── licence.sigThen mount it into the container:
docker run -d \
--name xhrdev \
--restart unless-stopped \
--cap-drop=ALL \
--security-opt no-new-privileges:true \
--read-only \
--tmpfs /tmp:rw,noexec,nosuid,nodev,size=64m \
--pids-limit 512 \
-p 3000:3000 \
-v ./licence:/run/licence:ro \
ghcr.io/xhrdev-<your-org>/xhrdev:latestThe flags after --restart are containment rather than configuration — see Run the container locked down for what each one buys you, and for the resource caps to size to your own host. The solver runs with or without them; we would rather you ran it with them.
Option B — environment variables
Useful for AWS Secrets Manager, Kubernetes Secrets, or any setup where a mounted volume is inconvenient:
docker run -d \
--name xhrdev \
--restart unless-stopped \
--cap-drop=ALL \
--security-opt no-new-privileges:true \
--read-only \
--tmpfs /tmp:rw,noexec,nosuid,nodev,size=64m \
--pids-limit 512 \
-p 3000:3000 \
-e LICENCE_JSON_CONTENT="$(cat licence.json)" \
-e LICENCE_SIG_BASE64="$(base64 licence.sig)" \
ghcr.io/xhrdev-<your-org>/xhrdev:latestOption C — Docker Compose
services:
xhrdev:
image: ghcr.io/xhrdev-<your-org>/xhrdev:latest
restart: unless-stopped
ports:
- "3000:3000"
volumes:
- ./licence:/run/licence:ro
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
read_only: true
tmpfs:
- /tmp:rw,noexec,nosuid,nodev,size=64m
pids_limit: 512docker compose up -dHealth check
curl http://localhost:3000/hc
# {"status":"ok"}The image also declares a Docker-native HEALTHCHECK — docker ps shows (healthy) once the solver is ready to accept requests.
Environment variables
| Variable | Default | Description |
|---|---|---|
LICENCE_JSON_CONTENT | — | Licence JSON inline (alternative to volume mount) |
LICENCE_SIG_BASE64 | — | Licence signature, base64-encoded (alternative to volume mount) |
TIME_SOURCE_PRIORITY, REQUIRE_TIME_SOURCE, ALLOW_SYSTEM_CLOCK_FALLBACK | see below | ECS licence-expiry hardening — see Production notes |
The API itself requires no proxy configuration to be set at the container level — proxies are passed per-request (proxy / proxy_url fields in the Akamai and DataDome APIs), so different requests can use different upstream proxies against the same running container.
Renewing a licence
Licences have an expiry window. Before it lapses, xhr.dev sends you a new licence.json / licence.sig pair. Replace the files in your mounted ./licence directory (or update the LICENCE_JSON_CONTENT / LICENCE_SIG_BASE64 secret values) and restart the container — no image pull or rebuild required:
docker restart xhrdevHow the image is licensed
The solver logic ships protected, and unlocks only against a valid licence. What that means for running it:
- The check is local and offline. Startup verifies the signature over your licence and its
issued_at/expires_atwindow without contacting xhr.dev — there is no licence server, and the runtime isn't permitted to reach one (see Network posture). - It fails closed. A missing, expired, or tampered licence exits immediately with
{"event":"launcher_failed"}and a non-zero code, rather than starting in a degraded state. - The image is the same for every customer. Only the licence differs, which is why renewal is a file swap and a restart rather than a new pull.
See Security for the trust model.
Production notes
Hardening licence-expiry checks on ECS
Licence expiry checks trust your container's clock by default. On AWS/ECS deployments, xhr.dev recommends pinning that check to ECS task metadata instead:
-e TIME_SOURCE_PRIORITY=ecs \
-e REQUIRE_TIME_SOURCE=ecs \
-e ALLOW_SYSTEM_CLOCK_FALLBACK=falseIf you're not on ECS, leave these unset — the defaults are appropriate for most deployments. Contact us if you're deploying somewhere else and want an equivalent hardened setup.
Licence delivery via secrets manager
Set LICENCE_JSON_CONTENT and LICENCE_SIG_BASE64; the launcher falls back to the mounted file paths when those env vars are absent.