Docker Compose turns service declarations into containers, networks, and storage attachments managed as a project. The file describes useful relationships: which image runs a service, how configuration reaches it, and which storage or network it uses. Reproducing a stack still depends on external inputs such as image availability, variable values, host capabilities, and existing data.

When it breaks

The same Compose text can resolve differently when the invoking shell supplies different variables. An env file participates in interpolation, but it is not automatically equivalent to the environment eventually injected into every container. Service-level environment declarations and env_file settings have their own roles. Inspect the rendered configuration and the running service rather than assuming a value in one file must be the final value.

Network addresses are another trap. Services on a Compose network can resolve one another by service name and use the container port. Published host ports serve a different path. Pointing a client container at localhost usually sends the request back to that client, not to the sibling API. Hardcoding a container IP also makes replacement more fragile than using the stable service name.

Project lifecycle does not give every resource the same lifetime. A normal down operation removes the project’s containers and networks, while named volumes remain unless volume removal is requested. Recreating the network can change its identity and assigned addresses. Retained data does not mean the old network survived, and a newly created container does not imply an empty data volume.

A client-to-API arrow sits inside one project network. Separate rows compare the changed network identity with the retained volume marker after ordinary down and up. Text distinguishes service-name traffic, configured host-loopback publishing and interpolation. Static source explanation; no article exercise was run for this graphic.
Compose service networking, host-port publishing, variable interpolation and named-volume lifetime are separate relationships.

Check it quickly

Use host Python 3.9 or later with assertions enabled, Docker Compose, and the pinned image on a compatible platform. The Docker endpoint must expose its published loopback port to this host process, without proxy interference. Earlier records report Compose 5.1.1 and Docker 29.3.1 on Linux arm64; that run has not been reproduced here. The unique project uses YAML-compatible JSON and requests a loopback-only host binding at an automatically assigned port:

from pathlib import Path
from tempfile import mkdtemp
from urllib.request import urlopen
import json,os,subprocess,sys,time,uuid

BASE='python@sha256:528257d48c1da0dcecc2e725d1ae34498d60c965f1241e39cd6a85a8859bdf84'
def run(args,check=True,env=None,timeout=120):
    result=subprocess.run(args,text=True,capture_output=True,timeout=timeout,env=env)
    if check:assert result.returncode==0,result.stderr
    return result
def docker(*args,check=True,timeout=120):return run(['docker',*args],check,timeout=timeout)

def cleanup_steps(steps, primary):
    failures=[]
    for label, action in steps:
        try:action()
        except BaseException as error:
            failures.append(label+': '+type(error).__name__+': '+str(error))
    if failures:
        message='cleanup incomplete or uncertain: '+'; '.join(failures)
        try:print(message,file=sys.stderr)
        except (OSError,ValueError):pass
        if primary is None:raise RuntimeError(message)
    return failures

def remove_file(path):
    try:path.unlink()
    except FileNotFoundError:pass

def require_empty(value):
    if value:raise RuntimeError('residual resources: '+str(value))

name='xbcatm-project-'+uuid.uuid4().hex;environment=os.environ.copy();environment.pop('TASK_COMPOSE_GREETING',None)
root=None;project_started=False
label='com.docker.compose.project='+name
kinds=[('container',['ps','-aq'],2),('network',['network','ls','-q'],1),('volume',['volume','ls','-q'],1)]
def listed(command):return docker(*command,'--filter','label='+label,timeout=10).stdout.split()
def clear_kind(kind,command,limit):
    identities=listed(command)
    if len(identities)>limit:raise RuntimeError('unexpected project resource count; inspect '+name)
    cleanup_steps([(kind+' '+identity,lambda identity=identity:docker(kind,'rm',*(['-f'] if kind=='container' else []),identity,timeout=10)) for identity in identities],None)
for kind,command,limit in kinds:require_empty(listed(command))
print('fixture project:',name,flush=True)
try:
    root=Path(mkdtemp(prefix=name+'-'));file=root/'compose.yaml'
    def compose(*args,check=True,env=None,timeout=120):return run(['docker','compose','-p',name,'--env-file',str(root/'.env'),'-f',str(file),*args],check,environment if env is None else env,timeout)
    def container(service):return compose('ps','-q',service).stdout.strip()
    def inspect(identity):return json.loads(docker('inspect',identity).stdout)[0]
    def request():
        port=inspect(container('api'))['NetworkSettings']['Ports']['8080/tcp'][0]
        assert port['HostIp']=='127.0.0.1'
        deadline=time.monotonic()+10
        while time.monotonic()<deadline:
            try:
                with urlopen('http://127.0.0.1:'+port['HostPort'],timeout=1) as response:return response.read().decode()
            except OSError:time.sleep(.1)
        raise AssertionError('published endpoint unavailable')
    server='from http.server import BaseHTTPRequestHandler,HTTPServer\nimport os\nclass H(BaseHTTPRequestHandler):\n def do_GET(self):self.send_response(200);self.end_headers();self.wfile.write(os.environ["GREETING"].encode())\n def log_message(self,*args):pass\nHTTPServer(("0.0.0.0",8080),H).serve_forever()\n'
    spec={'services':{'api':{'image':BASE,'command':['python','-c',server],'environment':{'GREETING':'${TASK_COMPOSE_GREETING:-default}'},'ports':[{'target':8080,'published':'0','host_ip':'127.0.0.1'}],'volumes':['state:/state']},'client':{'image':BASE,'command':['python','-c','import time;time.sleep(180)']}},'volumes':{'state':{}}}
    file.write_text(json.dumps(spec,indent=2));(root/'.env').write_text('TASK_COMPOSE_GREETING=from-file\n')
    rendered=json.loads(compose('config','--format','json').stdout);assert rendered['services']['api']['environment']['GREETING']=='from-file'
    override=environment|{'TASK_COMPOSE_GREETING':'from-shell'}
    rendered=json.loads(compose('config','--format','json',env=override).stdout);assert rendered['services']['api']['environment']['GREETING']=='from-shell'
    project_started=True
    compose('up','-d',env=override);assert request()=='from-shell'
    api=container('api');client=container('client');first_network=next(iter(inspect(api)['NetworkSettings']['Networks'].values()))['NetworkID']
    result=docker('exec',client,'python','-c','import urllib.request;print(urllib.request.urlopen("http://api:8080",timeout=2).read().decode())')
    assert result.stdout.strip()=='from-shell'
    docker('exec',client,'python','-c','import socket;s=socket.socket();assert s.connect_ex(("127.0.0.1",8080))!=0;s.close()')
    docker('exec',api,'python','-c','from pathlib import Path;Path("/state/record").write_text("preserved")')
    volume=next(m['Name'] for m in inspect(api)['Mounts'] if m['Destination']=='/state')
    assert volume==name+'_state'
    compose('down');assert docker('network','inspect',first_network,check=False).returncode!=0
    assert docker('volume','inspect',volume,check=False).returncode==0
    compose('up','-d',env=override);assert request()=='from-shell'
    api=container('api');assert next(iter(inspect(api)['NetworkSettings']['Networks'].values()))['NetworkID']!=first_network
    docker('exec',api,'python','-c','from pathlib import Path;assert Path("/state/record").read_text()=="preserved"')
    assert docker('exec',container('client'),'python','-c','import urllib.request;print(urllib.request.urlopen("http://api:8080",timeout=2).read().decode())').stdout.strip()=='from-shell'
    print('shell interpolation overrides .env; internal clients resolve api:8080 while localhost remains their own container')
    print('the host endpoint is published only on loopback; down/up recreates the project network and retains the named data volume')
finally:
    primary=sys.exc_info()[1];steps=[]
    if project_started:
        steps.append(('compose down',lambda:compose('down','--volumes','--remove-orphans','--timeout','5',timeout=10)))
        steps.extend((kind+' removal',lambda kind=kind,command=command,limit=limit:clear_kind(kind,command,limit)) for kind,command,limit in kinds)
        steps.extend((kind+' absence',lambda command=command:require_empty(listed(command))) for kind,command,limit in kinds)
    if root is not None:
        steps.extend([('env file',lambda:remove_file(root/'.env')),('compose file',lambda:remove_file(root/'compose.yaml')),('temporary directory',root.rmdir)])
    cleanup_steps(steps,primary)

The private env file supplies TASK_COMPOSE_GREETING=from-file. The script explicitly selects that file and removes the same variable from its initial shell environment. compose config --format json resolves the service’s GREETING value to from-file. Providing from-shell in the invoking environment then changes the rendered value, establishing the tested interpolation precedence.

Starting the project with the override makes the API return from-shell. The host-side request uses the published port discovered through container inspection, and the binding is asserted to be 127.0.0.1. This checks effective configuration and the requested binding, not every remote-access path. Merely rendering a valid document would not prove the container was started with that value or port binding.

The client service successfully requests http://api:8080 through Compose’s network and gets the same response. A connection attempt to its own loopback port 8080 fails. These two observations distinguish service-name discovery from host publishing and container-local loopback. No fixed container IP is embedded in the application request.

The API writes a marker to its named state volume. After ordinary compose down, inspection confirms the old network no longer exists while the named volume still does. Bringing the project up again creates a different network identity. The marker remains readable, and the client can still reach the API using the unchanged service name.

This revised fixture is unrun. Use an exclusively controlled disposable endpoint. The guard precedes setup; preflight rejects existing project resources. Cleanup independently attempts down, bounded project-label removal, residual checks and local-file removal. Ten-second CLI timeouts and resource-count limits bound individual attempts. Errors preserve the original exception; cleanup-only failures also fail the script. Images remain. A timed-out daemon operation can finish later: inspect the printed project name after the endpoint settles. Process creation, stalled filesystem calls and abrupt termination remain outside these guarantees. Ordinary down retains the volume; final --volumes intentionally destroys disposable data and is inappropriate for casual production troubleshooting.

Record the project inputs that matter

Keep required variable names, image digests, storage ownership, and exposed ports explicit. Render configuration in the intended execution environment before starting the stack, but avoid publishing rendered output containing credentials. Pinning an image reduces one source of drift; it does not freeze host architecture, mounted files, runtime configuration, or persistent database contents.

Use separate project names for independent local or CI runs when resources should be isolated. Explicitly named external networks or volumes can intentionally cross that boundary, so review those references rather than assuming project naming isolates every attachment. Port bindings can also conflict across otherwise separate projects.

Compose can support a managed single-host deployment as well as local testing. Its suitability depends on operational requirements, not a blanket ban on production use. Host redundancy, rollout coordination, backup, readiness, and monitoring still need deliberate solutions. The value of the declaration is a clear, testable resource model with known limits.

Sources