Docker · Development

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 script

The 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
01 Read Git Find the repository and checked-out branch.
02 Build the name Normalize both values into a safe project name.
03 Run Compose Pass the name through 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.