← Back to DevBytes

Docker Compose GPU Access: Best Practices and Common Pitfalls

What Is Docker Compose GPU Access?

Docker Compose GPU access refers to the ability to expose physical or logical GPU devices from the host machine to containers defined in a docker-compose.yml file. This allows containerized workloads—such as machine learning training, inference servers, video transcoding pipelines, or scientific simulations—to leverage the massive parallel computing power of NVIDIA, AMD, or Intel GPUs directly, without the overhead of virtualization layers.

At its core, GPU access in Docker Compose is about making the host's GPU devices and their driver stacks visible inside the container namespace. This is accomplished through a combination of kernel-level device exposure, driver library mounting, and container runtime configuration. For NVIDIA GPUs, this is orchestrated by the NVIDIA Container Toolkit (formerly nvidia-docker2), which integrates with Docker's runtime layer to inject the necessary device nodes and libraries automatically.

A Brief History of GPU Containerization

Docker's GPU support has evolved significantly:

Why GPU Access Matters in Docker Compose

🚀 Deploy your AI agent in 10 minutes

Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.

Try it free →

Modern development workflows increasingly rely on containerization for reproducibility and portability. When those workflows involve GPU-accelerated computing, proper Compose-level GPU configuration becomes critical for several reasons:

Without proper GPU configuration in Compose, containers default to CPU-only execution, which can turn a 2-hour GPU training job into a 2-day CPU slog—or worse, cause the application to crash when CUDA libraries are missing.

How to Enable GPU Access in Docker Compose

There are three primary approaches to configure GPU access in a docker-compose.yml file, and the choice depends on your Docker version, Compose specification version, and whether you're operating in Swarm mode or standalone mode. Below I'll walk through each method with complete, runnable examples.

Prerequisites Check

Before diving into Compose configurations, verify your host is properly set up:

# 1. Verify NVIDIA drivers are installed and GPU is visible
nvidia-smi

# 2. Verify Docker is installed (19.03 or newer recommended)
docker --version

# 3. Install NVIDIA Container Toolkit
# Debian/Ubuntu:
sudo apt-get update
sudo apt-get install -y nvidia-container-toolkit
sudo systemctl restart docker

# 4. Verify the nvidia runtime is registered
docker info | grep -i nvidia
# Should show: "runtimes: nvidia runc" or similar

# 5. Test GPU access in a standalone container
docker run --rm --gpus all nvidia/cuda:12.1-base nvidia-smi

Method 1: The deploy Resource Reservation (Recommended for Compose v3.x)

This is the most modern and portable approach, defined under the deploy key which aligns with the Docker Swarm resource specification. It works with docker-compose (standalone) and docker stack deploy (Swarm). The key is the devices array under resources.reservations.

# docker-compose.yml
version: '3.8'

services:
  trainer:
    image: nvidia/cuda:12.1-runtime-ubuntu22.04
    command: nvidia-smi
    deploy:
      resources:
        reservations:
          devices:
            - driver: nvidia
              count: 1
              capabilities: [gpu]
    # Optional: specify exact GPU UUIDs or device indices
    # deploy:
    #   resources:
    #     reservations:
    #       devices:
    #         - driver: nvidia
    #           device_ids: ['0', '2']  # Use only GPU 0 and GPU 2
    #           capabilities: [gpu]

  web-api:
    image: my-inference-api:latest
    ports:
      - "8080:8080"
    deploy:
      resources:
        reservations:
          devices:
            - driver: nvidia
              count: 1
              capabilities: [gpu]
    environment:
      - NVIDIA_VISIBLE_DEVICES=0  # Redundancy: restrict to GPU 0

The driver field must be set to nvidia for NVIDIA GPUs. The count field specifies how many GPUs to allocate (use all or an integer). The capabilities array defines what the container can do—[gpu] gives full access, while [utility, compute] or [graphics, compute, display] can be used for more granular control depending on the driver version.

Important: When using this method, you must run docker-compose up with no special flags—the reservations are processed automatically. However, if you're on an older Docker version (<19.03), you may need to fall back to Method 2.

Method 2: The runtime Property (Compose v2.x / Legacy)

Before the deploy.resources.reservations.devices syntax was widely supported in standalone Compose, the runtime property was the standard way to enable GPUs. This requires your Compose file to use a version 2.x schema (or a hybrid approach).

# docker-compose.yml
version: '2.3'

services:
  gpu-app:
    image: tensorflow/tensorflow:latest-gpu
    runtime: nvidia
    environment:
      - NVIDIA_VISIBLE_DEVICES=all
    command: python -c "import tensorflow as tf; print(tf.config.list_physical_devices('GPU'))"
    volumes:
      - ./models:/models

When using runtime: nvidia, Docker invokes the NVIDIA Container Toolkit runtime which automatically mounts /usr/bin/nvidia-container-runtime as the OCI runtime. This method is straightforward but less portable—it ties your Compose file to a specific runtime name and doesn't work in Swarm mode.

Caveat: If you attempt to use runtime: nvidia in a Compose file with version: '3.x', Docker Compose will silently ignore the runtime property (or throw a warning). This is one of the most common pitfalls developers encounter.

Method 3: Explicit Device Mapping (Universal Fallback)

The most low-level approach—directly mapping GPU device files and library paths—works across all Docker versions and Compose specifications but requires you to know exactly which devices and libraries your application needs.

# docker-compose.yml
version: '3.8'

services:
  gpu-app:
    image: nvidia/cuda:12.1-runtime-ubuntu22.04
    command: nvidia-smi
    devices:
      - /dev/nvidia0:/dev/nvidia0
      - /dev/nvidia1:/dev/nvidia1
      - /dev/nvidiactl:/dev/nvidiactl
      - /dev/nvidia-uvm:/dev/nvidia-uvm
      - /dev/nvidia-modeset:/dev/nvidia-modeset
    volumes:
      - /usr/bin/nvidia-smi:/usr/bin/nvidia-smi
      - /usr/lib/x86_64-linux-gnu/libcuda.so.1:/usr/lib/x86_64-linux-gnu/libcuda.so.1
      - /usr/lib/x86_64-linux-gnu/libnvidia-ml.so.1:/usr/lib/x86_64-linux-gnu/libnvidia-ml.so.1
    environment:
      - LD_LIBRARY_PATH=/usr/lib/x86_64-linux-gnu

While this method is brittle and maintenance-heavy (driver updates can change library paths), it's invaluable when debugging why other methods fail or when working in heavily restricted environments where the NVIDIA Container Toolkit cannot be installed.

Hybrid Approach: Using environment Variables for Fine-Grained Control

The NVIDIA Container Toolkit respects several environment variables that give you per-container control over GPU visibility and capabilities, even when using the deploy or runtime methods:

# docker-compose.yml
version: '3.8'

services:
  inference-server:
    image: my-triton-server:latest
    deploy:
      resources:
        reservations:
          devices:
            - driver: nvidia
              count: all
              capabilities: [gpu]
    environment:
      # Restrict to specific GPU UUIDs (overrides deploy count)
      - NVIDIA_VISIBLE_DEVICES=GPU-3ab5c9f2-1a2b-4c3d-5e6f-7a8b9c0d1e2f
      # Enable specific driver capabilities
      - NVIDIA_DRIVER_CAPABILITIES=compute,utility
      # Disable automatic library mounting if you want manual control
      - NVIDIA_DISABLE_REQUIRE=1
    volumes:
      - /custom/cuda/libs:/usr/local/cuda/lib64:ro

This hybrid approach combines the convenience of the deploy reservation with the granularity of environment variables, giving you the best of both worlds.

Best Practices for Docker Compose GPU Access

1. Pin Your CUDA and Driver Versions

GPU container images are tightly coupled to the host's NVIDIA driver version due to CUDA's forward-compatibility model. A container built with CUDA 12.1 requires a host driver that supports CUDA 12.1 (driver version >= 530). Always pin both the image tag and document the minimum driver requirement:

# docker-compose.yml
version: '3.8'

services:
  training-job:
    # Pin exact CUDA version—don't use :latest
    image: pytorch/pytorch:2.1.0-cuda12.1-cudnn8-runtime
    deploy:
      resources:
        reservations:
          devices:
            - driver: nvidia
              count: 1
              capabilities: [gpu]
    # Document driver requirement for ops team
    labels:
      - "nvidia.driver.minimum=530.41.03"
      - "cuda.version=12.1"

2. Use Specific Device IDs for Multi-GPU Setups

On multi-GPU machines, avoid using count: all for every service. Instead, explicitly assign GPU indices or UUIDs to prevent resource contention and enable predictable performance:

services:
  inference-gpu0:
    image: inference-server:latest
    deploy:
      resources:
        reservations:
          devices:
            - driver: nvidia
              device_ids: ['0']
              capabilities: [gpu]
    ports:
      - "8000:8000"

  inference-gpu1:
    image: inference-server:latest
    deploy:
      resources:
        reservations:
          devices:
            - driver: nvidia
              device_ids: ['1']
              capabilities: [gpu]
    ports:
      - "8001:8000"

  background-trainer:
    image: training-job:latest
    deploy:
      resources:
        reservations:
          devices:
            - driver: nvidia
              device_ids: ['2', '3']  # GPUs 2 and 3 for training
              capabilities: [gpu]

3. Always Include Health Checks for GPU Services

GPU containers can appear "running" even when CUDA initialization fails. Add a health check that verifies actual GPU accessibility:

services:
  gpu-app:
    image: my-cuda-app:latest
    deploy:
      resources:
        reservations:
          devices:
            - driver: nvidia
              count: 1
              capabilities: [gpu]
    healthcheck:
      test: ["CMD", "nvidia-smi", "||", "exit", "1"]
      interval: 30s
      timeout: 10s
      retries: 3
      start_period: 30s

4. Prefer the deploy Syntax Over runtime

The deploy.resources.reservations.devices approach is the direction Docker is heading. It's Swarm-compatible, more expressive (supports device_ids, count, capabilities), and doesn't require consumers of your Compose file to know about runtime names. Use version: '3.8' or higher and avoid runtime: nvidia unless you have a specific legacy constraint.

5. Separate GPU and Non-GPU Services

Not every service in your stack needs a GPU. Only attach GPUs to services that actually use them—this conserves GPU resources and makes your Compose file self-documenting about which components are accelerated:

services:
  # GPU-accelerated services
  llm-inference:
    deploy:
      resources:
        reservations:
          devices:
            - driver: nvidia
              count: 1
              capabilities: [gpu]

  # Non-GPU services—no deploy.resources block needed
  redis-cache:
    image: redis:7-alpine

  postgres:
    image: postgres:16

  api-gateway:
    image: nginx:alpine
    ports:
      - "80:80"

6. Validate Your Configuration Before Deployment

Use docker-compose config to render and validate your Compose file, catching syntax errors early:

# Render the effective Compose configuration
docker-compose config > rendered-compose.yml

# Check that GPU reservations appear correctly
grep -A 10 "devices:" rendered-compose.yml

# Dry-run with a GPU check container
docker-compose run --rm gpu-app nvidia-smi

Common Pitfalls and How to Avoid Them

Pitfall 1: Using runtime: nvidia with Compose v3.x

The problem: Developers often copy legacy examples and place runtime: nvidia inside a version: '3.x' Compose file. Docker Compose silently ignores the runtime key in v3.x schemas (it's only recognized in v2.x), and the container runs without GPU access.

The fix: Either switch to version: '2.3' if you must use runtime, or (better) migrate to the deploy.resources.reservations.devices syntax:

# ❌ BROKEN: runtime ignored in v3.x
version: '3.8'
services:
  app:
    runtime: nvidia  # This does NOTHING in v3.x
    image: nvidia/cuda:latest

# ✅ FIXED: Use deploy reservation
version: '3.8'
services:
  app:
    image: nvidia/cuda:latest
    deploy:
      resources:
        reservations:
          devices:
            - driver: nvidia
              count: 1
              capabilities: [gpu]

Pitfall 2: NVIDIA Container Toolkit Not Installed or Wrong Version

The problem: The deploy reservation syntax requires the NVIDIA Container Toolkit (specifically nvidia-container-runtime) to be registered as a Docker runtime. If it's missing, you'll see errors like:

Error response from daemon: could not select device driver "nvidia" 
with capabilities: [[gpu]]

The fix: Install the toolkit properly and restart Docker:

# Add NVIDIA package repositories
distribution=$(. /etc/os-release;echo $ID$VERSION_ID)
curl -s -L https://nvidia.github.io/nvidia-docker/gpgkey | sudo apt-key add -
curl -s -L https://nvidia.github.io/nvidia-docker/$distribution/nvidia-docker.list | \
  sudo tee /etc/apt/sources.list.d/nvidia-docker.list

sudo apt-get update
sudo apt-get install -y nvidia-container-toolkit
sudo systemctl restart docker

# Verify runtime registration
docker run --rm --gpus all nvidia/cuda:12.1-base nvidia-smi

Pitfall 3: Driver-Version Mismatch Between Host and Container

The problem: You pull a container with CUDA 12.2, but your host driver only supports up to CUDA 12.1. The container starts but GPU-accelerated code crashes with:

CUDA error: no kernel image is available for execution on the device

The fix: Always check compatibility. Use nvidia-smi on the host to see the supported CUDA version:

# Host check
nvidia-smi | grep "CUDA Version"
# Output: "CUDA Version: 12.1"

# Then choose a container with matching or lower CUDA version
# ✅ Compatible: cuda:12.1-runtime, cuda:11.8-runtime
# ❌ Incompatible: cuda:12.2-runtime (requires newer driver)

Pitfall 4: Assuming count: all Shares GPUs Across Services

The problem: When multiple services request count: all, each container gets access to all GPUs, but there's no orchestration-level isolation. Two containers can simultaneously allocate memory on the same GPU, leading to out-of-memory (OOM) errors or performance thrashing.

The fix: Use device_ids to partition GPUs explicitly, or leverage NVIDIA MIG to create hardware-isolated GPU instances:

# Partition GPUs explicitly
services:
  service-a:
    deploy:
      resources:
        reservations:
          devices:
            - driver: nvidia
              device_ids: ['0', '1']
              capabilities: [gpu]
  service-b:
    deploy:
      resources:
        reservations:
          devices:
            - driver: nvidia
              device_ids: ['2', '3']
              capabilities: [gpu]

Pitfall 5: GPU Access Works in docker run but Not in Compose

The problem: You successfully test GPU access with docker run --gpus all ..., but the same image fails in Compose. This typically happens because Compose uses a different code path for resource allocation.

The fix: Debug incrementally—first verify the Compose syntax is correct, then test with a minimal Compose file:

# Minimal GPU Compose test
version: '3.8'
services:
  test:
    image: nvidia/cuda:12.1-base
    command: nvidia-smi
    deploy:
      resources:
        reservations:
          devices:
            - driver: nvidia
              count: 1
              capabilities: [gpu]

# Run with explicit GPU flag (some Docker versions need this)
docker-compose up --force-recreate
# Or, if using older Docker Compose, try:
DOCKER_OPTS="--gpus all" docker-compose up

Pitfall 6: SELinux/AppArmor Blocking GPU Device Access

The problem: On systems with SELinux (CentOS, RHEL, Fedora) or AppArmor (Ubuntu with hardened profiles), container access to /dev/nvidia* can be blocked, resulting in "permission denied" errors even though the NVIDIA runtime is correctly configured.

The fix: For SELinux, apply the appropriate context label. For AppArmor, ensure the NVIDIA profiles are loaded:

# SELinux: Add security context to allow GPU access
# Option A: Use the container-selinux policy
sudo setenforce 0  # Temporarily test if SELinux is the culprit

# Option B: Apply proper label in Compose
services:
  gpu-app:
    security_opt:
      - label=type:container_t  # Or use :Z on volume mounts
    deploy:
      resources:
        reservations:
          devices:
            - driver: nvidia
              count: 1
              capabilities: [gpu]

# AppArmor: Load NVIDIA profiles
sudo apparmor_parser -r /etc/apparmor.d/nvidia-container-runtime

Pitfall 7: Using Compose GPU Syntax in CI/CD Environments Without GPUs

The problem: Your Compose file with GPU reservations works perfectly on your workstation but fails in a CI/CD pipeline that runs on CPU-only nodes.

The fix: Make GPU reservations conditional using Compose override files or environment-specific profiles:

# Base Compose file: docker-compose.yml (no GPU)
version: '3.8'
services:
  app:
    image: my-app:latest
    command: python main.py
    profiles:
      - cpu-only

# GPU override: docker-compose.gpu.yml
version: '3.8'
services:
  app:
    profiles:
      - gpu-enabled
    deploy:
      resources:
        reservations:
          devices:
            - driver: nvidia
              count: 1
              capabilities: [gpu]

# Usage:
# CPU-only: docker-compose --profile cpu-only up
# GPU: docker-compose -f docker-compose.yml -f docker-compose.gpu.yml --profile gpu-enabled up

Putting It All Together: A Complete Production-Ready Example

Here's a complete, production-oriented docker-compose.yml that incorporates all the best practices discussed above. It defines an LLM inference service with GPU access, a Redis cache for embeddings, and a monitoring sidecar—all with proper GPU isolation, health checks, and version pinning:

# docker-compose.yml
version: '3.8'

# Networks for service isolation
networks:
  inference-net:
    driver: bridge
  monitoring-net:
    driver: bridge

# Volumes for persistent data
volumes:
  model-cache:
  redis-data:

services:
  # =============================================
  # GPU-Accelerated LLM Inference Service
  # =============================================
  llm-inference:
    image: ghcr.io/myorg/llm-server:v2.1.0-cuda12.1  # Pinned version + CUDA
    container_name: llm-inference-prod
    restart: unless-stopped
    
    # GPU configuration: dedicated GPU 0
    deploy:
      resources:
        reservations:
          devices:
            - driver: nvidia
              device_ids: ['0']
              capabilities: [gpu]
    
    # Environment for fine-grained GPU control
    environment:
      - NVIDIA_VISIBLE_DEVICES=0
      - NVIDIA_DRIVER_CAPABILITIES=compute,utility
      - CUDA_VISIBLE_DEVICES=0  # Framework-level restriction
      - MODEL_PATH=/models/llama-70b
      - REDIS_URL=redis://embeddings-cache:6379
      - LOG_LEVEL=INFO
    
    # Health check that verifies real GPU access
    healthcheck:
      test: ["CMD-SHELL", "nvidia-smi -L | grep GPU || exit 1"]
      interval: 30s
      timeout: 10s
      retries: 3
      start_period: 60s
    
    ports:
      - "8080:8080"   # REST API
      - "9090:9090"   # gRPC
    
    volumes:
      - model-cache:/models:ro  # Read-only model mount
      - /run/nvidia-topology:/run/nvidia-topology:ro  # NVLink topology
    
    networks:
      - inference-net
    
    # Resource limits beyond GPU
    mem_limit: 32g
    cpus: 8
    
    labels:
      - "nvidia.driver.minimum=530.41.03"
      - "service.type=gpu-inference"
      - "monitoring.enable=true"

  # =============================================
  # Embeddings Cache (CPU-only)
  # =============================================
  embeddings-cache:
    image: redis:7.2-alpine
    container_name: redis-embeddings
    restart: unless-stopped
    
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 10s
      timeout: 5s
      retries: 5
    
    volumes:
      - redis-data:/data
    
    networks:
      - inference-net
    
    # No GPU reservation needed—clean separation
    mem_limit: 4g
    cpus: 2

  # =============================================
  # GPU Monitoring Sidecar
  # =============================================
  gpu-exporter:
    image: nvidia/dcgm-exporter:3.3.5
    container_name: gpu-metrics
    restart: unless-stopped
    
    # Access all GPUs for monitoring, but read-only
    deploy:
      resources:
        reservations:
          devices:
            - driver: nvidia
              count: all
              capabilities: [utility]  # Utility only—no compute
    
    environment:
      - NVIDIA_VISIBLE_DEVICES=all
      - NVIDIA_DRIVER_CAPABILITIES=utility
    
    ports:
      - "9400:9400"
    
    networks:
      - monitoring-net
    
    volumes:
      - /run/nvidia-topology:/run/nvidia-topology:ro

  # =============================================
  # Prometheus scraper for GPU metrics
  # =============================================
  prometheus:
    image: prom/prometheus:v2.48.0
    container_name: gpu-prometheus
    restart: unless-stopped
    
    volumes:
      - ./prometheus.yml:/etc/prometheus/prometheus.yml:ro
    
    ports:
      - "9091:9090"
    
    networks:
      - monitoring-net
    
    # No GPU needed

Running the Complete Stack

# 1. Validate the Compose file
docker-compose config | grep -A 15 "devices:"

# 2. Start the stack
docker-compose up -d

# 3. Verify GPU access in the inference service
docker-compose exec llm-inference nvidia-smi

# 4. Check GPU metrics
curl http://localhost:9400/metrics | grep DCGM

# 5. View logs to ensure no CUDA errors
docker-compose logs llm-inference | grep -i cuda

# 6. Stop everything
docker-compose down --volumes

Conclusion

Docker Compose GPU access has matured from a fragile, manual-device-mapping hack into a robust, declarative configuration model that integrates cleanly with modern container orchestration. The key to success is understanding which configuration method applies to your Docker version and Compose specification, and then layering on best practices around version pinning, explicit GPU assignment, health validation, and environment-based fine-tuning.

Start with the deploy.resources.reservations.devices syntax as your default—it's the future-proof, Swarm-compatible approach. Fall back to runtime: nvidia only when legacy constraints demand it, and use explicit device mapping as a last resort or debugging tool. Always validate your setup with nvidia-smi from inside the container before trusting that GPU acceleration is actually working. By following the patterns and avoiding the pitfalls outlined in this tutorial, you'll build GPU-accelerated microservice stacks that are reproducible, predictable, and production-hardened.

🚀 Need a reliable AI agent for your project?

Deploy Hermes Agent in 10 minutes. Managed hosting, zero DevOps.

Get Started — $23.99/mo
← Back to all articles