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:
- They have complex dependencies that are difficult to install natively
- They need process isolation beyond what uWSGI provides
- They are already packaged as Docker images
- 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¶
Explicit Selection (Recommended)¶
Users can explicitly choose Docker via hop3.toml:
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¶
- Traefik Labels (Not chosen)
- Pros: Auto-discovery, no manual config
-
Cons: Traefik-only, requires Docker network management, different pattern from other deployers
-
Docker Network Bridge (Not chosen)
- Pros: No port mapping needed, cleaner networking
-
Cons: Requires nginx in container, complex network setup, diverges from native deployment model
-
Direct Proxy Integration (Chosen)
- Pros: Consistent with existing architecture, works with any proxy, simple implementation
- 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:
EXPOSEdirective in Dockerfile (parsed during build)docker compose portcommand (runtime discovery)- 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:
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:
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¶
- ADR 030: Two-Level Build Architecture
- ADR 032: Deployment Strategies & Artifact Lifecycle
- Docker Compose specification: https://docs.docker.com/compose/compose-file/
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