Building a Slim PDAL Docker Image
TL;DR: Install PDAL, python-pdal and GDAL from conda-forge with micromamba in a builder stage, run micromamba clean --all and strip static libraries and headers, then copy only the environment into a minimal runtime stage and run as a non-root user. A typical result is well under half the size of a naive single-stage conda image, starts faster on every Batch or Kubernetes node, and contains exactly the packages your lock file names.
# Context and Motivation
This guide is part of PDAL Docker Containers. The official pdal/pdal image is convenient for trying commands, as in running PDAL pipelines in Docker, but production batch work usually needs your own image: PDAL plus python-pdal, laspy, NumPy, boto3 and your code, at pinned versions. Built naively, such an image carries a full conda installation, package caches and build headers, and can reach several gigabytes. Every node that runs a tile pulls it, so image size turns directly into start-up latency and registry egress across a large fleet.
# Prerequisites and Assumptions
- Docker or BuildKit-compatible builder (
docker buildx). - A conda lock file for the environment, as produced in pinning PDAL versions with conda-lock, or an
environment.ymlto start from. - A registry (ECR, GHCR, or similar) that the compute nodes pull from.
# Step-by-Step Implementation
# Step 1 — Install into a prefix in a builder stage
Use the mambaorg/micromamba image and create the environment at a fixed prefix such as /opt/env from the lock file.
# Step 2 — Clean inside the same layer
Run micromamba clean --all --yes and delete headers, static libraries and bytecode caches in the same RUN so the removed files never land in a layer.
# Step 3 — Copy the environment into a slim runtime
FROM debian:bookworm-slim, then COPY --from=builder /opt/env /opt/env and put /opt/env/bin first on PATH.
# Step 4 — Set the runtime environment
Set PROJ_DATA and GDAL_DATA to the environment’s share directories, add a non-root user, and copy the application code last so code changes do not invalidate the environment layer.
# Step 5 — Check the image
Run pdal --version, pdal --drivers, a Python import test and docker image ls to confirm drivers and size.
# Complete Working Example
Dockerfile:
# syntax=docker/dockerfile:1.7
FROM mambaorg/micromamba:1.5-bookworm-slim AS builder
COPY conda-linux-64.lock /tmp/env.lock
RUN micromamba create -y -p /opt/env -f /tmp/env.lock \
&& micromamba clean --all --yes \
&& find /opt/env -name '*.a' -delete \
&& rm -rf /opt/env/include /opt/env/share/doc /opt/env/share/man \
&& find /opt/env -name '__pycache__' -type d -prune -exec rm -rf {} +
FROM debian:bookworm-slim AS runtime
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates \
&& rm -rf /var/lib/apt/lists/* \
&& useradd --create-home --uid 1000 lidar
COPY /opt/env /opt/env
ENV PATH=/opt/env/bin:$PATH \
PROJ_DATA=/opt/env/share/proj \
GDAL_DATA=/opt/env/share/gdal \
GDAL_DISABLE_READDIR_ON_OPEN=EMPTY_DIR \
PYTHONDONTWRITEBYTECODE=1
WORKDIR /app
COPY app/ /app/
USER lidar
ENTRYPOINT ["python", "/app/entrypoint.py"]Build and check:
docker buildx build --platform linux/amd64 -t lidar-pdal:2026.09 --load .
docker run --rm --entrypoint pdal lidar-pdal:2026.09 --version
docker run --rm --entrypoint pdal lidar-pdal:2026.09 --drivers | grep -E 'readers.copc|filters.smrf|writers.gdal'
docker run --rm --entrypoint python lidar-pdal:2026.09 -c "import pdal, laspy, numpy; print(pdal.__version__, laspy.__version__)"
docker image ls lidar-pdal:2026.09 --format '{{.Size}}'# Why Not Alpine or pip Wheels
Alpine images are small, but PDAL and GDAL are not packaged for musl in conda-forge, so building them from source on Alpine is slow and fragile. The python-pdal wheel on PyPI needs a matching system PDAL to build against rather than bundling one, which pushes you back to installing PDAL separately. The conda-forge route gives PDAL, GDAL, PROJ and their plugins built consistently against each other, and the multi-stage copy removes most of the cost of using conda. Distroless bases can go smaller still, but debugging a failed tile without a shell is painful; a slim Debian base is a sensible middle ground.
Keep the environment focused. Every optional package — Jupyter, matplotlib, a full scientific stack — pulls in its own dependency tree. A batch image needs PDAL, python-pdal, NumPy, your I/O libraries and your code; notebooks belong in a separate development image built from the same lock file with extra packages.
# Tagging and Rebuilding
Tag images with something that identifies their contents, not just latest: a date plus the lock file’s hash, or the PDAL version plus a build number. Job definitions and DAGs then reference an exact tag, and an old run can be reproduced months later by pulling the same image. Rebuild on a schedule — monthly is common — to pick up security fixes in the base image, but treat each rebuild as a new version that must pass the same one-tile comparison before production jobs switch to it. Registry lifecycle rules can expire untagged layers so old builds do not accumulate storage costs.
# Key Parameter Table
| Choice | Recommended | Reason |
|---|---|---|
| Builder base | mambaorg/micromamba |
Fast solver, small footprint |
| Environment spec | conda-lock file | Exact, reproducible packages |
| Runtime base | debian:bookworm-slim |
glibc, shell for debugging |
| Clean step | same RUN as install |
Deleted files never enter a layer |
PROJ_DATA, GDAL_DATA |
env share dirs | Grids and definitions found |
| User | non-root, uid 1000 | Least privilege, matching bind mounts |
| Platform | explicit linux/amd64 or arm64 |
Match the compute fleet |
# Verification
- Drivers present.
pdal --driverslists every stage your pipelines use; a missing plugin (for examplereaders.copcorfilters.hag_dem) fails here instead of in production. - PROJ grids.
projinfo -s EPSG:4269 -t EPSG:6318 --spatial-test intersectslists operations; missing grid files show as unavailable operations. - Same outputs. Run one tile through the old image and the new one and compare point counts and raster checksums.
# Gotchas and Edge Cases
Deleting too much. Some packages load data from share/ at run time — PROJ, GDAL and certificates. Remove share/doc and share/man, not share/ wholesale.
Architecture mismatch. Building on an Apple Silicon laptop produces arm64 images by default. Graviton instances can run them, x86 fleets cannot; set --platform explicitly.
Layer ordering. Copy application code after the environment so a code change rebuilds only the last layers and nodes pull a few kilobytes instead of the whole environment.
# Frequently Asked Questions
What is the easiest way to get a small PDAL image?
Install PDAL from conda-forge with micromamba in a builder stage, clean caches in the same step, and copy only the environment directory into a slim Debian runtime stage.
Why not use Alpine for a PDAL image?
PDAL and GDAL are not packaged for Alpine’s musl libc on conda-forge, so they would have to be compiled from source. A slim Debian base with a conda-forge environment is simpler and still small.
Which environment variables does a PDAL image need?
Set PROJ_DATA and GDAL_DATA to the environment’s share directories so projections and GDAL data files are found, and put the environment’s bin directory first on PATH.
How do I check that the image has the drivers my pipeline needs?
Run pdal --drivers inside the image and search for each stage your pipelines use, then process a test tile and compare its output with a known-good result.
# Related
- PDAL Docker Containers — containerising PDAL
- Running PDAL Pipelines in Docker — the official image
- Pinning PDAL Versions with conda-lock — the lock file used here
- Array Jobs for LiDAR Tiles in AWS Batch — where the image runs