Skip to content

ADR 033: Docker Integration Strategy

Status: Final Type: Feature Created: 2025-12-04 Related-ADRs: 022, 030, 032, 035

Context

Hop3 supports native deployment using uWSGI and nginx. However, some applications require containerization, either because:

  1. They have complex dependencies that are difficult to install natively
  2. They need process isolation beyond what uWSGI provides
  3. They are already packaged as Docker images
  4. They require multi-container setups (app + database + cache)

The Docker plugin (hop3/plugins/docker/) provides an alternative build and deployment path for containerized applications.

Decision

Architecture

Docker integration follows the two-level build architecture (ADR 030):

Level 1 (Builder)          Level 2 (Toolchain)
┌─────────────────┐        ┌─────────────────────┐
│ LocalBuilder    │───────▶│ PythonToolchain     │
│                 │        │ NodeToolchain       │
│                 │        │ ...                 │
└─────────────────┘        └─────────────────────┘
┌─────────────────┐
│ DockerBuilder   │ (standalone - no toolchains)
└─────────────────┘

DockerBuilder is a Level 1 Builder that: - Does NOT use Level 2 toolchains - Builds directly from a Dockerfile - Produces docker-image artifacts

Build Flow

Source Code                DockerBuilder              DockerComposeDeployer
┌───────────────┐           ┌─────────────┐           ┌────────────────┐
│ app/          │           │             │           │                │
│ ├─ src/       │──────────▶│ docker      │──────────▶│ docker compose │
│ ├─ Dockerfile │           │ build       │           │ up -d          │
│ └─ compose.yml│           │             │           │                │
└───────────────┘           └─────────────┘           └────────────────┘
                                │                            │
                                ▼                            ▼
                         BuildArtifact               DeploymentInfo
                         kind="docker-image"         protocol="http"
                         location="hop3/app:latest"  port=8080

Note: Docker images manage their own runtime environment internally (PATH, env vars, etc. are baked into the image layers). Unlike native builds (ADR 035), DockerBuilder does not need to produce a RuntimeConfig - the container runtime handles this.

Builder Selection

Users can explicitly choose Docker via hop3.toml:

# hop3.toml
[build]
builder = "docker"

Available builder names: - "docker" - Use DockerBuilder (requires Dockerfile) - "local" - Use LocalBuilder (native toolchains) - "auto" - Auto-detect (default)

Auto-Detection Rules

If builder = "auto" or not specified, the first matching builder wins:

Builder Accepts When
DockerBuilder Dockerfile exists in source directory
LocalBuilder Any language toolchain accepts (requirements.txt, package.json, etc.)

Note: If both Dockerfile and requirements.txt exist, use explicit selection to ensure predictable behavior.

Deployer Selection

Deployer Accepts When
UWSGIDeployer Artifact kind is virtualenv, node, ruby, etc.
StaticDeployer Artifact kind is static
DockerComposeDeployer Artifact kind is docker-image

Docker Compose File Generation

By default, Hop3 generates a docker-compose.yml based on the Dockerfile. This simplifies deployment and ensures correct port binding.

The generated compose file:

# Generated by Hop3 - do not edit manually
services:
  web:
    image: ${HOP3_IMAGE_TAG}
    ports:
      - "127.0.0.1:${PORT:-8080}:8080"  # Port from EXPOSE directive
    environment:
      - PORT=8080
    restart: unless-stopped

For advanced use cases (multi-container, volumes, custom networks), users can provide their own docker-compose.yml, compose.yml, or docker-compose.yaml. If a user-supplied compose file exists, Hop3 uses it instead of generating one.

Hop3 provides these environment variables to the compose file: - HOP3_IMAGE_TAG: The Docker image tag (e.g., hop3/myapp:latest) - HOP3_APP_NAME: The application name - HOP3_APP_PORT: The exposed port from Dockerfile (if detected) - PORT: The allocated host port for the container

Host Reference Substitution

A container cannot reach a service bound to 127.0.0.1/localhost on the host through that name, since localhost inside the container resolves to the container itself. When passing environment values into the container, Hop3 rewrites host references of localhost and 127.0.0.1 to host.docker.internal, the Docker-provided alias for the host.

The substitution is value-based: it matches host references at host boundaries within the value (via regex), not against a whitelist of known env-var names. A name-based whitelist would silently miss custom variables such as GF_DATABASE_HOST; a value-based rewrite catches any variable that carries a host reference, regardless of its name.

Proxy Integration

Decision: Use the Direct Proxy Integration pattern, following StaticDeployer's approach.

This approach: - Works with any configured proxy (nginx, caddy, traefik) - Is consistent with existing Hop3 architecture - Requires no special proxy-specific configuration - Uses the same get_proxy_strategy() / proxy.setup() flow as other deployers

Architecture

                                    ┌─────────────────────────────────┐
                                    │         Hop3 Server             │
                                    │                                 │
                             ┌─────────────┐    ┌──────────────────┐  │
                             │   Proxy     │    │ Docker Container │  │
Internet ──▶ Port 80/443 ──▶ │ nginx/caddy │──▶ │   127.0.0.1:PORT │  │
                             │  /traefik   │    │   (app:8080)     │  │
                             └─────────────┘    └──────────────────┘  │
                                    │                                 │
                                    └─────────────────────────────────┘

Key insight: The proxy doesn't care whether the backend is a uWSGI process, a static folder, or a Docker container. It only needs: 1. A HOST_NAME to route requests 2. A BIND_ADDRESS:PORT to forward to 3. Standard environment configuration

Container Port Binding

Docker containers MUST bind to the host's loopback interface:

# docker-compose.yml - CORRECT
services:
  web:
    image: ${HOP3_IMAGE_TAG}
    ports:
      - "127.0.0.1:${HOP3_HOST_PORT:-8080}:8080"  # Host-only binding

Why 127.0.0.1? - Prevents direct external access to container - All traffic goes through Hop3's proxy (enabling SSL, rate limiting, etc.) - Consistent with uWSGI apps that bind to localhost

Environment Variables for Proxy

DockerComposeDeployer provides these variables:

Variable Purpose Example
HOP3_IMAGE_TAG Docker image to run hop3/myapp:latest
HOP3_APP_NAME Application name myapp
HOP3_HOST_PORT Port on host (for proxy) 8080
HOP3_APP_PORT Port inside container 8080
HOST_NAME Domain name(s) for routing myapp.example.com
BIND_ADDRESS Always 127.0.0.1 127.0.0.1
PORT Alias for HOP3_HOST_PORT 8080

Implementation in DockerComposeDeployer

The deployer follows StaticDeployer's pattern:

def deploy(self, deltas: dict[str, int] | None = None) -> DeploymentInfo:
    # 1. Start container
    self._start_container()

    # 2. Build environment for proxy
    env = self._make_proxy_env()

    # 3. Setup proxy (if HOST_NAME configured)
    host_name = env.get("HOST_NAME", "_")
    if host_name and host_name != "_":
        proxy = get_proxy_strategy(self.app, env, self._get_workers())
        proxy.setup()

    # 4. Update app state
    self.app._transition_state(AppStateEnum.RUNNING)

    return DeploymentInfo(
        protocol="http",
        address="127.0.0.1",
        port=self._get_host_port(),
    )

Alternatives Considered

  1. Traefik Labels (Not chosen)
  2. Pros: Auto-discovery, no manual config
  3. Cons: Traefik-only, requires Docker network management, different pattern from other deployers

  4. Docker Network Bridge (Not chosen)

  5. Pros: No port mapping needed, cleaner networking
  6. Cons: Requires nginx in container, complex network setup, diverges from native deployment model

  7. Direct Proxy Integration (Chosen)

  8. Pros: Consistent with existing architecture, works with any proxy, simple implementation
  9. Cons: Requires explicit port mapping in compose file

Lifecycle Management

DockerComposeDeployer implements the full Deployer protocol:

Method Docker Compose Command
deploy() docker compose up -d --remove-orphans
start() docker compose up -d
stop() docker compose stop
restart() docker compose restart
destroy() docker compose down --volumes
scale() docker compose up -d --scale web=N
check_status() docker compose ps

Port Discovery

Ports are discovered in this order:

  1. EXPOSE directive in Dockerfile (parsed during build)
  2. docker compose port command (runtime discovery)
  3. Fallback to 8080

Future Work

This design leaves room for the following extensions:

  • Build improvements: build arguments derived from environment variables, multi-stage Dockerfiles, BuildKit/buildx features, and layer caching reused across deployments.
  • Advanced orchestration: HTTP-endpoint container health checks, zero-downtime blue-green rolling updates, CPU/memory limits expressed via compose, and secrets management integrating Docker secrets or Hop3 secrets.
  • Multi-container apps: sidecar services (redis, postgres, etc.), internal DNS for service discovery between containers, and persistent volume management across deployments.

Consequences

Positive

  • Supports applications that require containerization
  • Consistent lifecycle management (same API as uWSGI deployer)
  • Multi-container applications supported via user-supplied compose
  • Proxy integration uses same pattern as other deployers (consistency)
  • Works with any configured proxy (nginx, caddy, traefik)
  • Simple deployment: Just provide a Dockerfile, Hop3 generates compose file
  • Correct port binding ensured by generated compose file

Negative

  • Requires Docker to be installed on the server
  • Port discovery is heuristic-based
  • Scaling requires user-supplied compose file
  • Generated compose only supports single-container apps

Neutral

  • Docker apps are isolated from native apps
  • Different deployment model than uWSGI (containers vs processes)

Example Usage

Minimal Docker App (Compose Generated)

For simple single-container apps, just provide a Dockerfile:

myapp/
├── hop3.toml        # Optional: explicit builder selection
├── Dockerfile
├── app.py
└── requirements.txt

hop3.toml:

[build]
builder = "docker"   # Use Docker even if requirements.txt exists

Dockerfile:

FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install -r requirements.txt
COPY . .
EXPOSE 8080
CMD ["python", "app.py"]

Hop3 automatically generates the docker-compose.yml based on the EXPOSE port.

Deploy:

hop deploy myapp
hop config:set myapp HOST_NAME=myapp.example.com  # Enable proxy routing

Docker App with Database (User-Supplied Compose)

For multi-container apps, provide your own docker-compose.yml:

myapp/
├── hop3.toml
├── Dockerfile
├── docker-compose.yml   # User-supplied for multi-container
├── app.py
└── requirements.txt
# docker-compose.yml - User-supplied for advanced use cases
services:
  web:
    image: ${HOP3_IMAGE_TAG}
    ports:
      - "127.0.0.1:${PORT:-8080}:8080"
    environment:
      - DATABASE_URL=postgres://postgres:postgres@db:5432/app
    depends_on:
      - db

  db:
    image: postgres:16
    environment:
      - POSTGRES_PASSWORD=postgres
      - POSTGRES_DB=app
    volumes:
      - pgdata:/var/lib/postgresql/data

volumes:
  pgdata:

References


Related ADRs: ADR 022: Build and Deployment Plugin System, ADR 030: Two-Level Build Architecture, ADR 032: Deployment Strategies and Artifact Lifecycle, ADR 035: Build Artifacts as Runtime Contract