Building a Minimal TileServer GL Docker Image

A repeatable recipe for producing a small, non-root TileServer GL image whose config, styles, and tiles are mounted read-only and whose health is reported to the orchestrator.

This task is the packaging half of Containerizing TileServer GL for High Availability, and it belongs to the broader Infrastructure Orchestration & Configuration Management practice; read the parent guide first if you need the replica and failover rationale that motivates a lean, immutable image. Here the scope is deliberately tight: turn the upstream TileServer GL package into a hardened artifact that boots fast, exposes a health signal, and carries no build tooling into production.

Prerequisites

Confirm the following before you write the first FROM line. Each maps to a failure you would otherwise hit on the first docker run.

  • Docker 24+ (or Podman 4+) with BuildKit enabled — DOCKER_BUILDKIT=1 — so multi-stage caching and --mount work.
  • A pinned base tag, e.g. node:20.18-bookworm for the build stage and node:20.18-bookworm-slim for runtime; never node:latest.
  • Your config.json, a styles/ directory, and one or more .mbtiles archives staged in a build context you control.
  • The native dependencies TileServer GL’s canvas rendering needs at build time: build-essential, libcairo2-dev, libpango1.0-dev, libjpeg-dev, libgif-dev, librsvg2-dev.
  • Read access to the registry you will push to, and a namespace where you can tag immutable digests.

Step-by-step implementation

The image is assembled in two stages so the compiler toolchain and dev dependencies never reach the shipped layer. The diagram below traces what each stage contributes and which layers survive into the final artifact.

Multi-stage build funnels only runtime layers into the final TileServer GL image On the left, the build stage starts from a full Node image, installs native build dependencies such as libcairo and libpango, runs npm ci to compile TileServer GL, then prunes development dependencies. An arrow crosses to the right, where the slim runtime stage starts from a slim Node base and copies only the pruned node_modules and application code. The runtime stage then adds a non-root tileserver user, mounts config.json, styles, and mbtiles read-only, and declares a healthcheck that polls the slash health endpoint. Build-only layers containing compilers and headers are discarded and never ship. Stage 1 · build FROM node:20-bookworm apt build deps cairo · pango · jpeg · rsvg npm ci compile native modules npm prune --omit=dev drop dev dependencies compilers & headers stay here COPY --from=build node_modules + app Stage 2 · runtime FROM node:20-bookworm-slim USER tileserver non-root uid 10001 read-only mounts config · styles · mbtiles HEALTHCHECK GET /health final image no build tooling shipped

1. Compile in a full build stage

The first stage carries the toolchain. Copy only package*.json before npm ci so the dependency layer caches independently of application changes, then compile against the native libraries TileServer GL’s @mapbox/mapbox-gl-native bindings require.

# syntax=docker/dockerfile:1.7
FROM node:20.18-bookworm AS build
WORKDIR /build

RUN apt-get update && apt-get install -y --no-install-recommends \
      build-essential libcairo2-dev libpango1.0-dev libjpeg-dev \
      libgif-dev librsvg2-dev pkg-config \
    && rm -rf /var/lib/apt/lists/*

COPY package.json package-lock.json ./
RUN npm ci --no-audit --no-fund

COPY . .
RUN npm prune --omit=dev

Copying manifests first keeps the expensive npm ci layer valid across source edits, which is the single largest saving on rebuild time.

2. Ship from a slim runtime base

The runtime stage starts from the -slim variant and installs only the shared libraries needed to run the compiled modules — the -dev headers stay behind in stage one. Copy the pruned tree across with --from=build.

FROM node:20.18-bookworm-slim AS runtime
WORKDIR /app

RUN apt-get update && apt-get install -y --no-install-recommends \
      libcairo2 libpango-1.0-0 libpangocairo-1.0-0 libjpeg62-turbo \
      libgif7 librsvg2-2 curl \
    && rm -rf /var/lib/apt/lists/*

COPY --from=build /build/node_modules ./node_modules
COPY --from=build /build/package.json ./package.json
COPY --from=build /build/src ./src

3. Create and drop to a non-root user

TileServer GL never needs to write to its own filesystem, so run it as an unprivileged account. Create the user in the image and switch to it before the entrypoint.

RUN groupadd --gid 10001 tileserver \
    && useradd --uid 10001 --gid 10001 --no-create-home --shell /usr/sbin/nologin tileserver

USER 10001:10001
EXPOSE 8080

A container that starts as root and later drops privileges still exposes a root process at PID 1; declaring USER in the image closes that window and satisfies most cluster admission policies.

4. Mount config, styles, and tiles read-only

Bake nothing mutable into the image. The config.json, styles/, and .mbtiles archives are supplied at run time as read-only mounts, so a single image serves every environment and no process can corrupt a tile archive in place.

services:
  tileserver:
    image: registry.example.org/geoportal/tileserver-gl:2.6.0
    read_only: true
    command: ["-c", "/data/config.json", "-p", "8080"]
    tmpfs:
      - /tmp
    volumes:
      - ./config.json:/data/config.json:ro
      - ./styles:/data/styles:ro
      - ./tiles:/data/tiles:ro
    ports:
      - "8080:8080"

The read_only: true root filesystem plus a small tmpfs for scratch is the container-level equivalent of an immutable deployment: the same digest that passed staging is byte-for-byte what runs in production.

5. Declare a healthcheck on /health

TileServer GL exposes a lightweight /health endpoint. Wiring a HEALTHCHECK into the image lets Docker, Compose, and Kubernetes readiness gates converge on one signal instead of guessing from the TCP port.

HEALTHCHECK --interval=15s --timeout=3s --start-period=40s --retries=3 \
  CMD curl -fsS http://127.0.0.1:8080/health || exit 1

ENTRYPOINT ["node", "src/main.js"]

The start-period covers the seconds TileServer GL spends opening .mbtiles archives and building the style index, so the orchestrator does not kill the container during a legitimately slow first boot.

6. Trim the image and pin everything

A .dockerignore keeps the build context — and therefore the cache key and any accidental secrets — small. Combined with the pruned node_modules and the slim base, the shipped image typically lands well under half the size of a single-stage build.

.git
node_modules
test
docs
*.md
*.mbtiles
docker-compose*.yml
.env

Pin the digest, not just the tag, when you reference the image downstream so a re-pushed tag can never silently change what runs: registry.example.org/geoportal/tileserver-gl@sha256:.... To decide whether this container tier is even the right tile delivery choice for your workload, weigh it against the caching-proxy alternative in MapProxy vs TileServer GL for Tile Delivery.

What belongs in the image and what belongs beside it

The size of a renderer image is decided less by the base distribution than by a single question asked of every file: does this change when the code changes, or when the data changes? Anything on the data side of that line should be mounted rather than baked, because baking it means rebuilding and redeploying the whole fleet whenever a cartographer edits a colour.

Baked into the image, or mounted beside it Two columns splitting renderer assets by what triggers their change: code-cadence assets in the image, data-cadence assets on a read-only mount. In the image changes when the code changes runtime and native libraries font glyph ranges, sprite sheets the server itself and its defaults rebuilt on a release; pinned by digest On a read-only mount changes when the data changes tile archives, often gigabytes style definitions and colour ramps per-deployment configuration swapped without touching the fleet

Fonts sit on the code side despite feeling like cartography, because a glyph range is loaded once at start-up and a missing one fails the render rather than changing its appearance. Styles sit firmly on the data side. Getting that split right is what makes a colour change a configuration update measured in seconds instead of a fleet-wide rollout measured in minutes.

Verification

Build the image, inspect what it actually contains, and confirm the health signal flips to healthy.

# 1. Build and read back the final image size
DOCKER_BUILDKIT=1 docker build -t tileserver-gl:local .
docker image ls tileserver-gl:local
#   REPOSITORY       TAG     SIZE
#   tileserver-gl    local   ~340MB   (vs ~900MB single-stage)

# 2. Confirm the process runs as the non-root uid, not 0
docker run -d --name tsgl -p 8080:8080 tileserver-gl:local
docker exec tsgl id
#   uid=10001(tileserver) gid=10001(tileserver)

# 3. Watch the healthcheck settle to "healthy"
docker inspect --format '{{.State.Health.Status}}' tsgl
#   healthy

# 4. Prove no compiler leaked into the runtime layer
docker run --rm tileserver-gl:local sh -c 'command -v gcc || echo "no toolchain: good"'
#   no toolchain: good

A healthy status plus the absence of gcc confirms both goals: the orchestrator has a real readiness signal and the build tooling stayed in stage one. Feed the running container into a latency test to size replicas, as covered in Benchmarking Tile Latency with k6.

Proving the image is actually minimal

“Minimal” is a claim that decays. A base image update adds a package, a debugging session leaves a tool behind, a dependency pulls in a toolchain, and six months later the image is three times its original size with nobody having decided that. Make the claim measurable in the build so the decay is visible at the moment it happens.

Three assertions that keep the claim honest Three build-stage checks — size budget, contents audit, start-up time — each with what it asserts and the regression it catches. Size budget fail the build above a stated compressed ceiling catches: a base image that quietly grew raise it deliberately, in a commit Contents audit assert no compiler, package manager or shell history catches: a debugging tool left in the final stage also removes attack surface Start-up assertion run the image, request a tile, fail above a time budget catches: an added init step that slows every scale-out the number that decides availability

The third assertion is the one that pays for itself, because start-up time is the availability figure that a fleet’s scaling behaviour depends on, and it is the number most easily degraded by a change that looks harmless. Asserting it in the build means the regression is attributed to the commit that caused it rather than discovered during the next traffic spike.

Troubleshooting matrix

Symptom Likely cause Fix
Build fails with node-gyp / cairo.h not found Native -dev headers missing in the build stage Install libcairo2-dev, libpango1.0-dev, libjpeg-dev, librsvg2-dev before npm ci
Runtime crashes with error while loading shared libraries: libpango Slim base lacks the runtime shared libs the compiled modules link against Add the non--dev runtime packages (libpango-1.0-0, libcairo2, …) to stage two
HEALTHCHECK never reaches healthy curl absent, wrong port, or start-period shorter than tile index build Install curl, match the -p port, raise --start-period to cover .mbtiles open time
Container exits EACCES: permission denied writing /tmp read_only root filesystem with no scratch space Add a tmpfs mount for /tmp; keep TileServer’s config and tiles read-only
Image still hundreds of MB larger than expected Single-stage build, or .mbtiles/.git swept into the context Split into build + runtime stages; exclude data and VCS via .dockerignore
Tag pulled today behaves differently than last week Consuming a mutable tag instead of a digest Reference the image by @sha256: digest in Compose/Helm

For the build directives used above — cache mounts, stage targeting, and COPY --from — the official Dockerfile reference is authoritative.

Up one level: Containerizing TileServer GL for High Availability.