Run Docker Compose per Git branch
Give each checkout its own Compose project name. Branches can then run separate containers, networks, and named volumes on the same machine.
Project names do not isolate published host ports. Use the PORT mapping and wrapper option below when several branches run at once.
The quick command
Pass a unique project name with -p:
docker compose -p myapp-feature-branch up -d
You can also set a per-checkout default in .env:
COMPOSE_PROJECT_NAME=myapp-feature-branch
Use the branch wrapper
Download Python scriptThe script builds the project name from the repository and current branch, then forwards your arguments to Docker Compose.
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import hashlib
import os
import re
import shlex
import subprocess
import sys
from pathlib import Path
class ComposeBranchCommand:
max_project_length = 63
def __init__(self, working_directory: Path) -> None:
self.working_directory = working_directory
def _git(self, *arguments: str) -> str:
result = subprocess.run(
["git", *arguments],
cwd=self.working_directory,
check=False,
capture_output=True,
text=True,
)
if result.returncode != 0:
raise RuntimeError(result.stderr.strip() or "Git command failed")
return result.stdout.strip()
@classmethod
def project_name(cls, repository: str, branch: str) -> str:
raw_name = f"{repository}-{branch}".lower()
safe_name = re.sub(r"[^a-z0-9_-]+", "-", raw_name).strip("-_")
safe_name = re.sub(r"-{2,}", "-", safe_name)
if not safe_name:
raise RuntimeError("Git repository and branch names are empty")
if safe_name == raw_name and len(safe_name) <= cls.max_project_length:
return safe_name
digest = hashlib.sha256(raw_name.encode()).hexdigest()[:8]
prefix = safe_name[: cls.max_project_length - len(digest) - 1].rstrip("-_")
return f"{prefix}-{digest}"
def run(
self,
compose_arguments: list[str],
dry_run: bool,
port: str | None,
) -> int:
repository_root = Path(self._git("rev-parse", "--show-toplevel"))
branch = self._git("branch", "--show-current")
if not branch:
raise RuntimeError("Check out a Git branch before running this command")
try:
origin = self._git("config", "--get", "remote.origin.url")
except RuntimeError:
origin = ""
repository = Path(origin.removesuffix(".git")).name or repository_root.name
project = self.project_name(repository, branch)
command = ["docker", "compose", "-p", project, *compose_arguments]
rendered_command = shlex.join(command)
print(f"PORT={port} {rendered_command}" if port is not None else rendered_command)
if dry_run:
return 0
environment = None
if port is not None:
environment = os.environ.copy()
environment["PORT"] = port
return subprocess.run(
command,
cwd=self.working_directory,
check=False,
env=environment,
).returncode
def compose_port(value: str) -> str:
if value == "auto":
return "0"
try:
port = int(value)
except ValueError as error:
raise argparse.ArgumentTypeError("port must be auto or a number") from error
if not 1 <= port <= 65535:
raise argparse.ArgumentTypeError("port must be between 1 and 65535")
return str(port)
def argument_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="Run Docker Compose in a project isolated by Git branch."
)
parser.add_argument(
"--dry-run",
action="store_true",
help="Print the Docker Compose command without running it.",
)
parser.add_argument(
"--port",
type=compose_port,
metavar="PORT",
help="Set PORT for Compose; use 'auto' to let Docker choose a port.",
)
parser.add_argument(
"compose_arguments",
nargs=argparse.REMAINDER,
help="Arguments passed to docker compose.",
)
return parser
def main() -> int:
parser = argument_parser()
arguments = parser.parse_args()
compose_arguments = arguments.compose_arguments
if compose_arguments[:1] == ["--"]:
compose_arguments = compose_arguments[1:]
if not compose_arguments:
parser.error("provide a Docker Compose command, for example: up -d")
try:
return ComposeBranchCommand(Path.cwd()).run(
compose_arguments,
arguments.dry_run,
arguments.port,
)
except (OSError, RuntimeError) as error:
print(f"compose_branch.py: {error}", file=sys.stderr)
return 1
if __name__ == "__main__":
raise SystemExit(main())
Copy the block above or download the same file:
curl -fsSLO https://damln.com/tools/resources/docker-compose-per-branch/compose_branch.py
chmod +x compose_branch.py
./compose_branch.py up -d
Use -- before Compose-level options:
./compose_branch.py -- -f docker-compose.dev.yml up -d
docker compose -p.
Avoid host port collisions
Read the host port from PORT in your Compose file. Keep the container port fixed.
services:
web:
ports:
- "127.0.0.1:${PORT:-3000}:8000"
Pass --port auto to set PORT=0. Docker will choose an available host port. Then ask Compose which port it assigned:
./compose_branch.py --port auto up -d
./compose_branch.py port web 8000
Pass a number when you need a predictable address:
./compose_branch.py --port 3001 up -d
Daily commands
./compose_branch.py ps
./compose_branch.py logs -f
./compose_branch.py down
docker compose ls
Run the wrapper from the same Git checkout each time. A detached HEAD has no branch name, so the script stops instead of selecting an ambiguous project.