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. A versioned file makes those inputs reviewable only when they are identified.
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.
Check it quickly
Run the script with host Python, Docker Compose, and the pinned Python image. It was executed with Compose 5.1.1 and Docker 29.3.1 on Linux arm64. The generated Compose document uses JSON syntax accepted by YAML, requires no extra Python parser, and has a unique project name. The API is published only on host loopback at an automatically assigned port:
from pathlib import Path
from tempfile import TemporaryDirectory
from urllib.request import urlopen
import json,os,subprocess,time,uuid
BASE='python@sha256:528257d48c1da0dcecc2e725d1ae34498d60c965f1241e39cd6a85a8859bdf84'
def run(args,check=True,env=None):
result=subprocess.run(args,text=True,capture_output=True,timeout=120,env=env)
if check:assert result.returncode==0,result.stderr
return result
def docker(*args,check=True):return run(['docker',*args],check)
name='xbcatm-project-'+uuid.uuid4().hex[:10];environment=os.environ.copy();environment.pop('TASK_COMPOSE_GREETING',None)
with TemporaryDirectory(prefix=name+'-') as directory:
root=Path(directory);file=root/'compose.yaml'
def compose(*args,check=True,env=None):return run(['docker','compose','-p',name,'--env-file',str(root/'.env'),'-f',str(file),*args],check,env or environment)
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')
try:
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'
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:compose('down','--volumes','--remove-orphans',check=False)
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 verifies both the effective configuration and the intended exposure. 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.
The fixture’s final cleanup includes --volumes because all of its data is disposable. That option has a different destructive scope from the earlier ordinary down used to demonstrate retention. In a real environment, apply it only when removal of those volumes is intended; it should not be appended casually to every restart or troubleshooting command.
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
- Compose services reference, service configuration, environment, ports, and volume mounts, checked 2026-09-12.
- Networking in Compose, project networks and service-name discovery, checked 2026-09-12.
- Compose variable interpolation, shell and env-file interpolation precedence, checked 2026-09-12.
- Docker compose down, network removal and optional volume removal, checked 2026-09-12.