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.

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.

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.