How to Set Up FastAPI + Docker + VS Code Debugging

A complete guide to debugging FastAPI applications in Docker containers with hot reload support and production safety

What You’ll Learn

By the end of this guide, you’ll have:

  1. VS Code debugging – Set breakpoints and step through your FastAPI code running in Docker
  2. Hot reload during development – Code changes automatically restart your server
  3. Multi-service support – Debug across multiple services and shared libraries
  4. Production-safe setup – Zero debug code in production builds
  5. Easy mode switching – Toggle between debug and reload modes with a single command

This setup works for complex multi-service projects and handles the common conflicts that most tutorials ignore.

Example Project Structure

my-project/
├── docker-compose.yml
├── .env                    # Environment overrides
├── .vscode/launch.json     # Debug configuration
├── api-service/            # Main FastAPI service
│   ├── src/main.py
│   └── src/config.py
└── shared-lib/             # Shared code library
    └── my_shared/

This multi-service setup with shared libraries is where the debugging complexity comes from.

Understanding the Debugging Landscape

Before diving into problems, let’s understand how Python debugging works and why Docker changes everything.

Regular Python Debugging (Without Docker)

Traditional setup: Run your FastAPI app directly on your machine:

# Simple case - everything runs locally
uvicorn main:app --reload
# VS Code can directly attach to this process

How it works:
– Your code runs on your local machine
– VS Code debugger connects directly to the Python process
– File paths are straightforward – what you see is what the debugger sees
– Hot reload works because uvicorn can watch your local files

Docker Debugging – The New Challenges

With Docker: Your FastAPI app runs inside a container:

# Your app now runs in an isolated container
docker compose up api-service

What changes:
1. Remote debugging required: VS Code (on your machine) needs to connect to Python (in container)
2. Path mapping needed: Container paths like /app/api-service/main.py must map to local paths like ./api-service/main.py
3. Network boundaries: Debug connections cross Docker network boundaries
4. File watching complexity: Hot reload must watch files across container/host filesystem boundary

Enter debugpy – The Remote Debugging Solution

debugpy is Python’s official debug adapter that enables remote debugging:

# In your container's Python code
import debugpy
debugpy.listen(("0.0.0.0", 5678))  # Listen for VS Code connections

How it works:
– Your Python app (in container) starts a debug server on port 5678
– VS Code (on your machine) connects to that port as a debug client
– debugpy translates debugging commands between VS Code and your Python process

The beautiful part: Once connected, debugging feels native – breakpoints, variable inspection, stepping through code all work as if everything was local.

The FastAPI + Docker Specific Challenges

Hot reload expectation: Developers want uvicorn --reload for rapid development
Debugging need: Developers also want VS Code debugging for complex issues
The conflict: These two features don’t play well together in Docker containers

This is where things get interesting – and where most tutorials fall short.

Step 1: Create Smart Settings Management

The first challenge you’ll encounter is managing different debugging modes cleanly. We’ll use Pydantic settings for type safety, environment variable parsing, and .env file support.

# src/config.py
from pydantic_settings import BaseSettings, SettingsConfigDict

class APISettings(BaseSettings):
    model_config = SettingsConfigDict(env_file=".env", extra="ignore")

    # Debug configuration
    debug: bool = False
    debug_mode: str = "reload"  # "debug", "reload", or "production"

    @property
    def is_debug_mode(self) -> bool:
        return self.debug and self.debug_mode.lower() == "debug"

    @property  
    def is_reload_mode(self) -> bool:
        return self.debug and self.debug_mode.lower() == "reload"

api_settings = APISettings()

Key insight: The extra="ignore" prevents validation errors when other services read the same .env file but don’t recognize all variables.

Step 2: Implement Mode-Aware Application Logic

Now comes the critical part – handling the conflict between uvicorn reload and debugpy. The key insight: They can’t coexist, so we create separate modes.

# src/main.py
from config import api_settings

# Initialize debugpy only in debug mode - avoids port conflicts
if api_settings.is_debug_mode:
    import debugpy
    debugpy.listen(("0.0.0.0", 5678))

if __name__ == "__main__":
    import uvicorn

    if api_settings.is_debug_mode:
        # Debug mode: debugpy enabled, no reload
        uvicorn.run(app, host="0.0.0.0", port=8000, reload=False)
    elif api_settings.is_reload_mode:
        # Reload mode: use import string for proper reload
        uvicorn.run("src.main:app", host="0.0.0.0", port=8000, reload=True)
    else:
        # Production mode: direct app object, maximum performance
        uvicorn.run(app, host="0.0.0.0", port=8000, reload=False)

Why this works:
Debug mode: Only initializes debugpy when needed, avoiding port conflicts
Reload mode: Uses import string "src.main:app" so uvicorn can properly restart the module
Production mode: Direct app object for maximum performance

Step 3: Configure Docker Compose for Easy Mode Switching

Here’s where most tutorials get it wrong. The key is understanding Docker Compose environment variable precedence.

# docker-compose.yml
services:
  api-service:
    ports:
      - "8000:8000"
      - "5678:5678"  # Always expose debug port
    environment:
      - DEBUG=true
      # - DEBUG_MODE=reload  # Commented out to allow .env override
      - PYDEVD_DISABLE_FILE_VALIDATION=1  # Suppress debugpy warnings
    volumes:
      - ./api-service:/app/api-service
      - ./shared-lib:/app/shared-lib  # For shared libraries

Critical insight: DEBUG_MODE is commented out because Docker Compose environment variables override .env files. This allows .env to control the mode without editing docker-compose.yml.

Step 4: Set Up VS Code Path Mapping

Multi-service projects need careful path mapping so VS Code can find your source files when debugging.

// .vscode/launch.json (at project root level)
{
  "configurations": [
    {
      "name": "Python Debugger: Remote Attach",
      "type": "debugpy",
      "request": "attach",
      "connect": {"host": "localhost", "port": 5678},
      "pathMappings": [
        {"localRoot": "${workspaceFolder}/api-service", "remoteRoot": "/app/api-service"},
        {"localRoot": "${workspaceFolder}/shared-lib", "remoteRoot": "/app/shared-lib"}
      ]
    }
  ]
}

Why this works:
Root-level location: Accessible from the entire project workspace
Simple mappings: Just two mappings cover service and shared library code
No complex paths: Volume mounts eliminate need for site-packages mapping

Step 5: Use .env for Easy Mode Switching

Now for the magic – switching between modes without editing any configuration files.

Create a .env file in your project root:

# .env file
DEBUG_MODE=debug

The three modes:

  1. Reload Mode (Default):
# No .env file needed, or:
echo "DEBUG_MODE=reload" > .env
docker compose up api-service
# ✅ Auto-reload on code changes
# ❌ No VS Code debugging
  1. Debug Mode (VS Code Debugging):
echo "DEBUG_MODE=debug" > .env
docker compose up api-service
# Then attach VS Code debugger
# ✅ Full debugging support
# ❌ No auto-reload (restart manually)
  1. Production Mode:
DEBUG=false docker compose up api-service  
# ✅ Maximum performance
# ❌ No debugging, no reload

Key insight: Because DEBUG_MODE is commented out in docker-compose.yml, the .env file takes control. This prevents accidental commits of debug configurations.

Key Considerations

Why separate modes?
– uvicorn’s reload creates multiple processes that conflict with debugpy port binding
– Production builds should never include debug flags or dependencies

Path mapping complexity:
– Shared libraries need multiple mappings (mounted volume + installed package paths)
– Python imports resolve to venv site-packages, not mounted volumes

Environment override:
– Use .env file for quick mode switching during development
– Pydantic settings with extra="ignore" prevents validation errors

Production Safety

Why This Solution Works

Addressing Each Original Problem

Problem 1 (Address Already in Use): ✅ Solved
– Debugpy only initializes in debug mode, never conflicts with reload processes
– Clean separation means no race conditions or port binding issues

Problem 2 (Production Safety): ✅ Solved
– Dockerfile remains completely clean – no debug flags or conditional logic
– Debug dependencies and code only active when explicitly enabled via environment
– Production deployments never see debug-related code paths

Problem 3 (Path Mapping): ✅ Solved
– Simple two-mapping solution works reliably across services
– Root-level launch.json eliminates workspace confusion
– Volume mounts handle package resolution automatically

Problem 4 (Configuration Switching): ✅ Solved
.env file override enables instant mode switching
– No more editing docker-compose.yml or committing debug configurations
– Environment variable priority properly managed

The Developer Experience

Daily workflow:

# Morning: Start with hot reload for rapid development
docker compose up api-service

# Need to debug a tricky issue? 
echo "DEBUG_MODE=debug" > .env && docker compose up api-service
# Attach VS Code debugger, set breakpoints, investigate

# Back to rapid development
echo "DEBUG_MODE=reload" > .env && docker compose up api-service

Production deployment: Zero changes needed. The production Dockerfile and environment naturally exclude all debug behavior.

Key Insights for Multi-Service Projects

  1. Accept the fundamental conflict: Don’t fight uvicorn reload + debugpy – separate them
  2. Environment variable precedence matters: Docker Compose overrides .env, plan accordingly
  3. Path mapping simplicity wins: Complex site-packages mappings often fail, volume mounts work reliably
  4. Production safety by design: Make debug behavior opt-in, never default

This approach scales to complex monorepos with multiple services, shared libraries, and varying debugging needs while maintaining production safety.

Comments

  1. Loading comments…