Skip to content

Deploy via Docker Image

Run a FHIR R4 SNOMED CT terminology server on a clean VPS using the published pacharanero/sct Docker Hub image - HTTPS included, no git clone, no local build. You fetch four small config files, fill in one .env, and docker compose up. The stack is still two containers: sct (builds the database on first boot and serves FHIR) and Caddy in front of it (automatic TLS, optional basic auth, CORS). Caddy owns the public ports; sct is never reachable directly.

If you'd rather build from source - to patch the code, pin a specific commit, or target a platform the published image doesn't cover - see Build From Source instead. Both routes produce the same running stack.

Prerequisites

  • Docker with the Compose plugin. No git needed.
  • An NHS TRUD account subscribed to the SNOMED CT UK Monolith Edition, and your TRUD API key. It is used once, to download the release under your own licence - sct does not redistribute SNOMED CT content, and the running server never contacts TRUD again once the database is built.
  • For real HTTPS: a domain name with its DNS already pointing at this server, and ports 80 + 443 reachable from the internet (Let's Encrypt needs both to issue a certificate).

The compose file

This is the full compose.yaml for the image-based deployment - copy it as-is, or fetch it in step 2 below:

# SPDX-FileCopyrightText: 2026 Marcus Baw and Baw Medical Ltd
# SPDX-License-Identifier: AGPL-3.0-or-later
#
# Deploy sct using the published Docker Hub image - no git clone, no local
# build. This is compose.yaml's sct service with `build:` swapped for
# `image:`; everything else (the caddy service, volumes, healthcheck) is
# identical. See docs/deploy/docker-image.md for the full walkthrough, or
# compose.yaml + docs/deploy/terminology-server.md to build from source
# instead.

services:
  # Not directly published - reached only through caddy, which owns TLS
  # termination and the public ports. See spec/deployment.md decision 3.
  sct:
    image: pacharanero/sct:latest
    restart: unless-stopped
    environment:
      TRUD_API_KEY: ${TRUD_API_KEY:-}
      SCT_DATA_HOME: /data
      SCT_CODELISTS: /codelists
      SCT_SERVE_HOST: 0.0.0.0
      SCT_SERVE_PORT: ${SCT_SERVE_PORT:-8080}
      SCT_FHIR_BASE: /fhir
      SCT_TRUD_EDITION: ${SCT_TRUD_EDITION:-uk_monolith}
      SCT_REFSETS: ${SCT_REFSETS:-all}
      SCT_LOCALE: ${SCT_LOCALE:-en-GB}
      SCT_INCLUDE_INACTIVE: ${SCT_INCLUDE_INACTIVE:-false}
      SCT_BOOTSTRAP: ${SCT_BOOTSTRAP:-true}
    volumes:
      - sct-data:/data
      - ./codelists:/codelists:ro
    healthcheck:
      test: ["CMD", "curl", "-fsS", "http://localhost:${SCT_SERVE_PORT:-8080}/fhir/metadata"]
      interval: 30s
      timeout: 5s
      retries: 5
      start_period: 20m

  # TLS termination, optional basic auth, and CORS in front of sct. Caddyfile
  # + docker/caddy-entrypoint.sh (see there for why a wrapper script is
  # needed) are bind-mounted read-only into the stock upstream Caddy image -
  # no custom image to build or keep patched. Fetch both alongside this file
  # (see the walkthrough) - they are not baked into either published image.
  caddy:
    image: caddy:2-alpine
    restart: unless-stopped
    depends_on:
      - sct
    entrypoint: ["/bin/sh", "/caddy-entrypoint.sh"]
    environment:
      DOMAIN: ${DOMAIN:-}
      ACME_EMAIL: ${ACME_EMAIL:-}
      BASIC_AUTH_USER: ${BASIC_AUTH_USER:-}
      BASIC_AUTH_HASH: ${BASIC_AUTH_HASH:-}
      CORS_ORIGINS: ${CORS_ORIGINS:-*}
      SCT_SERVE_PORT: ${SCT_SERVE_PORT:-8080}
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile:ro
      - ./caddy-entrypoint.sh:/caddy-entrypoint.sh:ro
      - caddy-data:/data
      - caddy-config:/config

volumes:
  sct-data:
  caddy-data:
  caddy-config:

The only difference from building-from-source is the sct service's image: pacharanero/sct:latest in place of a build: block - the caddy service, volumes, and healthcheck are identical either way.

Also on GitHub Container Registry

The same multi-arch image is mirrored to ghcr.io/pacharanero/sct on every release. GHCR pulls are not rate-limited the way anonymous Docker Hub pulls are, so if your server sits behind a shared IP (CI, a cluster, a corporate NAT) and hits Docker Hub's 429 Too Many Requests, just swap the image: line to ghcr.io/pacharanero/sct:latest.

The four-step self-host

  1. DNS - point fhir.example.org at your server. Do this first; Let's Encrypt needs it live before Caddy can request a certificate.

  2. ssh in, fetch the four config files, and configure:

mkdir sct-server && cd sct-server
curl -o compose.yaml https://raw.githubusercontent.com/pacharanero/sct/main/compose.hub.yaml
curl -O https://raw.githubusercontent.com/pacharanero/sct/main/Caddyfile
curl -O https://raw.githubusercontent.com/pacharanero/sct/main/docker/caddy-entrypoint.sh
curl -o .env https://raw.githubusercontent.com/pacharanero/sct/main/.env.example
$EDITOR .env

That's every file this deployment needs - sct's own Dockerfile and entrypoint are already baked into the published image, so unlike building from source, nothing else needs to come along. Set at minimum in .env:

TRUD_API_KEY=your-trud-api-key
DOMAIN=fhir.example.org
ACME_EMAIL=you@example.org
  1. Bring the stack up:
docker compose up -d

No --build - sct is pulled from Docker Hub, not compiled locally.

  1. curl https://fhir.example.org/fhir/metadata - works once the first-run build and certificate issuance complete (see What first boot does).

Fast path (local / no domain)

To try it locally without DNS or a real certificate, just skip DOMAIN:

mkdir sct-server && cd sct-server
curl -o compose.yaml https://raw.githubusercontent.com/pacharanero/sct/main/compose.hub.yaml
curl -O https://raw.githubusercontent.com/pacharanero/sct/main/Caddyfile
curl -O https://raw.githubusercontent.com/pacharanero/sct/main/docker/caddy-entrypoint.sh
TRUD_API_KEY=your-key-here docker compose up -d

Caddy serves plain HTTP on port 80 (no DOMAIN means no automatic HTTPS - there's no hostname to get a certificate for):

curl http://localhost/fhir/metadata

What first boot does

If the sct-data volume doesn't already contain a database, the entrypoint baked into the image runs:

sct trud download --edition uk_monolith --skip-if-current --pipeline --refsets all --locale en-GB

That downloads the release, builds a SNOMED SQLite database (a few minutes for the UK Monolith), and starts sct serve on an internal port. Meanwhile Caddy:

  • with DOMAIN set: requests a certificate via Let's Encrypt and starts serving HTTPS, redirecting HTTP to HTTPS;
  • with DOMAIN unset: serves plain HTTP immediately.

Either way, while sct is still building, Caddy returns a 503 with a clear message ("sct is starting up (downloading/building the SNOMED database) - retry shortly.") instead of a bare connection error - so a curl during the first few minutes is expected and self-explanatory, not a sign anything is broken. It resolves to 200 as soon as the build finishes.

Subsequent starts reuse the existing database in the sct-data volume, so they skip the TRUD download and build step entirely - only the very first boot is slow.

Check it works

curl 'https://fhir.example.org/fhir/metadata'

Look up a concept:

curl 'https://fhir.example.org/fhir/CodeSystem/$lookup?system=http://snomed.info/sct&code=22298006'

Expand an ECL ValueSet:

curl 'https://fhir.example.org/fhir/ValueSet/$expand?url=http://snomed.info/sct?fhir_vs=ecl/%3C%3C73211009&count=10'

(Substitute http://localhost for the fast-path / no-domain case.)

Configuration

Edit .env - the fetched .env.example has the full annotated list. The essentials:

Variable Default Description
TRUD_API_KEY - Required for first boot, unless you provide an existing database. Build-time only - the running server never uses it again.
SCT_TRUD_EDITION uk_monolith Built-in TRUD edition to download.
SCT_REFSETS all all enables ICD-10 / OPCS-4 maps and concept history.
SCT_LOCALE en-GB Preferred-term locale.
SCT_INCLUDE_INACTIVE false Set true to retain inactive concepts.
DOMAIN (unset) Your public hostname. Set it for automatic HTTPS; leave unset for plain HTTP on :80.
ACME_EMAIL (unset) Let's Encrypt account email (cert-expiry notices). Optional but recommended when DOMAIN is set.
BASIC_AUTH_USER / BASIC_AUTH_HASH (unset) Optional HTTP basic auth - see below. Both must be set together.
CORS_ORIGINS * Allowed CORS origins for browser-based FHIR clients.

Optional basic auth

Terminology data is non-PHI and read-only, so authentication is opt-in - mainly abuse control on a publicly reachable endpoint. Generate a password hash with Caddy's own tool (no need to install Caddy locally - this runs it in a throwaway container):

docker run --rm caddy:2-alpine caddy hash-password --plaintext 'your-password'

Set both in .env:

BASIC_AUTH_USER=yourusername
BASIC_AUTH_HASH=$2a$14$...output from the command above...

Leave both unset for no auth (the default). Setting only one has no effect - both are required together.

Operations

Stop the server:

docker compose down

Upgrade to a newer release:

docker compose pull
docker compose up -d

There's no source to rebuild - docker compose pull fetches the latest pacharanero/sct:latest layer, and up -d recreates the container from it.

Force a fresh download/build by removing the volumes (this also discards the issued TLS certificate, which Caddy will simply re-request):

docker compose down -v
docker compose up -d

The sct-data volume contains licensed SNOMED CT data. Do not publish it as part of an image or commit exported database files to git.

FHIR surface

The Docker setup runs the same server as sct serve, including:

  • CodeSystem/$lookup
  • CodeSystem/$validate-code
  • CodeSystem/$subsumes
  • ValueSet/$expand
  • ValueSet/$validate-code
  • ConceptMap/$translate when the database has crossmaps loaded

For the full operation reference, see sct serve. For the deployment design and rationale, see spec/deployment.md.