Homelab Academy / Modules
Guided practice + break/fix + lab gate.
Module: Compose Operations & IaC
Activity type: Lab (Practice)
Lab risk: low
Objective: Create a reusable Compose stack with extension fields and YAML anchors.
Every file created or changed by these steps is under /opt/lab-classroom/class80/. Do not run docker compose up, start, create, run, or pull during this static lesson.
1
Confirm tool compatibility and create the isolated workspace
docker compose version
python3 --version
mkdir -p /opt/lab-classroom/class80/stack/site /opt/lab-classroom/class80/stack/site-blue /opt/lab-classroom/class80/stack/shared
cd /opt/lab-classroom/class80/stack
Confirm that Docker Compose is v2.24.4 or newer because the production overlay uses !override. All subsequent file mutations remain inside the class workspace.
2
Create harmless bind-mount content
cd /opt/lab-classroom/class80/stack
printf '%s\n' 'class80 base site' > site/index.html
printf '%s\n' 'class80 blue site' > site-blue/index.html
printf '%s\n' 'shared read-only content' > shared/readme.txt
The files allow Compose to resolve real bind-mount paths. They are not served because the lab never starts a container.
3
Create interpolation and runtime environment files
cd /opt/lab-classroom/class80/stack
cat > .env <<'EOF'
WEB_PORT=8080
APP_ENV=development
LOG_LEVEL=debug
EOF
cat > .env.prod <<'EOF'
PROD_WEB_PORT=8181
APP_ENV=production
LOG_LEVEL=info
EOF
cat > .env.runtime <<'EOF'
RUNTIME_BANNER=loaded-from-service-env-file
EOF
.env and .env.prod participate in Compose interpolation. The service-level .env.runtime is represented in the model as container environment input and does not provide WEB_PORT or PROD_WEB_PORT to Compose interpolation.
4
Create the reusable base stack
cd /opt/lab-classroom/class80/stack
cat > compose.yaml <<'EOF'
name: class80
x-service-defaults: &service-defaults
restart: unless-stopped
networks:
- frontend
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
services:
web:
<<: *service-defaults
image: nginx:1.27-alpine
env_file:
- .env.runtime
environment:
APP_ENV: "${APP_ENV:-development}"
LOG_LEVEL: "${LOG_LEVEL:-info}"
ports:
- target: 80
published: "${WEB_PORT:-8080}"
host_ip: 127.0.0.1
protocol: tcp
volumes:
- type: bind
source: ./site
target: /usr/share/nginx/html
read_only: true
labels:
academy.class: "80"
academy.role: web
worker:
<<: *service-defaults
image: busybox:1.36
profiles:
- ops
command:
- sh
- -c
- while true; do sleep 3600; done
environment:
APP_ENV: "${APP_ENV:-development}"
labels:
academy.class: "80"
academy.role: worker
networks:
frontend:
name: class80_frontend
EOF
The x-service-defaults extension and YAML anchor centralize restart, network, and logging policy. The worker remains in the application model but is operationally gated by the ops profile.
5
Create an explicit production replacement overlay
cd /opt/lab-classroom/class80/stack
cat > compose.prod.yaml <<'EOF'
services:
web:
environment:
APP_ENV: "${APP_ENV:-production}"
ports: !override
- target: 80
published: "${PROD_WEB_PORT:-8181}"
host_ip: 127.0.0.1
protocol: tcp
EOF
The !override tag replaces the complete inherited port sequence. The rendered production model should contain the loopback port published as 8181 and should not retain 8080.
6
Create a mount merge experiment
cd /opt/lab-classroom/class80/stack
cat > compose.mounts.yaml <<'EOF'
services:
web:
volumes:
- type: bind
source: ./site-blue
target: /usr/share/nginx/html
read_only: true
- type: bind
source: ./shared
target: /srv/shared
read_only: true
EOF
The first mount has the same container target as the base mount, so it represents the same unique resource and changes its source to site-blue. The second mount has a new target and is appended. Rendering the merged model makes both outcomes observable.
7
Render normalized models without starting services
cd /opt/lab-classroom/class80/stack
docker compose -f compose.yaml config --format json > rendered.base.json
docker compose --env-file .env.prod -f compose.yaml -f compose.prod.yaml config --format json > rendered.prod.json
docker compose -f compose.yaml -f compose.mounts.yaml config --format json > rendered.mounts.json
docker compose -f compose.yaml config --services > services.default.txt
docker compose --profile ops -f compose.yaml config --services > services.ops.txt
The full JSON render includes the worker definition. The default --services selection excludes worker because its profile is inactive, while the profile-enabled selection includes it.
8
Inspect canonical long-form port semantics
cd /opt/lab-classroom/class80/stack
python3 - <<'PY'
import json
for filename in ('rendered.base.json', 'rendered.prod.json'):
with open(filename, encoding='utf-8') as handle:
model = json.load(handle)
print(filename)
for port in model['services']['web']['ports']:
print({
'host_ip': port.get('host_ip'),
'published': str(port.get('published')),
'target': int(port.get('target')),
'protocol': port.get('protocol', 'tcp')
})
PY
This verifies semantic fields instead of looking for short YAML strings. Normalization is allowed to change representation while preserving port meaning.
9
Create automated model assertions
cd /opt/lab-classroom/class80/stack
cat > inspect_config.py <<'PY'
import json
from pathlib import Path
ROOT = Path('/opt/lab-classroom/class80/stack')
def load(name):
with (ROOT / name).open(encoding='utf-8') as handle:
return json.load(handle)
def service_lines(name):
return {
line.strip()
for line in (ROOT / name).read_text(encoding='utf-8').splitlines()
if line.strip()
}
def normalized_ports(model):
result = set()
for port in model['services']['web'].get('ports', []):
result.add((
str(port.get('host_ip')),
str(port.get('published')),
int(port.get('target')),
str(port.get('protocol', 'tcp')),
))
return result
def volume_by_target(model):
return {
volume['target']: volume
for volume in model['services']['web'].get('volumes', [])
}
base = load('rendered.base.json')
prod = load('rendered.prod.json')
mounts = load('rendered.mounts.json')
assert set(base['services']) == {'web', 'worker'}, base['services'].keys()
assert base['services']['worker']['profiles'] == ['ops']
assert service_lines('services.default.txt') == {'web'}
assert service_lines('services.ops.txt') == {'web', 'worker'}
assert normalized_ports(base) == {('127.0.0.1', '8080', 80, 'tcp')}
assert normalized_ports(prod) == {('127.0.0.1', '8181', 80, 'tcp')}
assert prod['services']['web']['environment']['APP_ENV'] == 'production'
base_volumes = volume_by_target(base)
merged_volumes = volume_by_target(mounts)
assert set(base_volumes) == {'/usr/share/nginx/html'}
assert set(merged_volumes) == {'/usr/share/nginx/html', '/srv/shared'}
assert str(base_volumes['/usr/share/nginx/html']['source']).endswith('/site')
assert str(merged_volumes['/usr/share/nginx/html']['source']).endswith('/site-blue')
assert str(merged_volumes['/srv/shared']['source']).endswith('/shared')
assert merged_volumes['/usr/share/nginx/html'].get('read_only') is True
assert merged_volumes['/srv/shared'].get('read_only') is True
print('PASS: base model contains web and profile-gated worker')
print('PASS: default service selection is web only')
print('PASS: ops service selection contains web and worker')
print('PASS: base port is 127.0.0.1:8080 -> 80/tcp')
print('PASS: production !override leaves only 127.0.0.1:8181 -> 80/tcp')
print('PASS: same-target mount changed to site-blue and new-target mount was appended')
PY
python3 inspect_config.py
The assertions validate model inclusion separately from profile-aware service selection. They also validate long-form ports and directly demonstrate same-target replacement versus new-target append behavior for mounts.
10
Review the effective differences
cd /opt/lab-classroom/class80/stack
python3 - <<'PY'
import json
from pathlib import Path
root = Path('/opt/lab-classroom/class80/stack')
for name in ('rendered.base.json', 'rendered.prod.json', 'rendered.mounts.json'):
model = json.loads((root / name).read_text(encoding='utf-8'))
web = model['services']['web']
print(f'[{name}]')
print('services:', ', '.join(sorted(model['services'])))
print('ports:', web.get('ports', []))
print('volumes:', web.get('volumes', []))
print('environment:', web.get('environment', {}))
PY
The final review shows that reuse and overlays produce small, explainable changes in the effective model.
| Symptom | Likely cause | Fix |
|---|---|---|
| Compose reports that !override is an unknown tag. | The installed Docker Compose plugin predates v2.24.4. | Upgrade the Docker Compose v2 plugin to v2.24.4 or newer, confirm with docker compose version, and rerun the production render. |
| services.default.txt contains worker. | The ops profile was enabled through a command option or the COMPOSE_PROFILES environment variable. | Run the exact default command from the lab without --profile and with COMPOSE_PROFILES unset for that invocation: env -u COMPOSE_PROFILES docker compose -f compose.yaml config --services > services.default.txt. |
| services.default.txt contains only web, but the learner expected worker too. | The learner is confusing full model inclusion with profile-aware service selection. | Treat web-only output as correct for the default --services query. Inspect rendered.base.json to confirm worker remains in the full model, then run docker compose --profile ops -f compose.yaml config --services to include it in the service selection. |
| The base port is not published as 8080. | A shell variable named WEB_PORT is overriding the value in .env. | Inspect the invoking environment with printenv WEB_PORT. Run the render from a shell where WEB_PORT is unset, or deliberately pass --env-file .env and document the intended precedence. |
| The production model still contains the 8080 port. | The production file omitted !override, was not supplied after the base file, or the learner inspected an older rendered file. | Confirm that compose.prod.yaml uses ports: !override, place it after compose.yaml in the command, regenerate rendered.prod.json, and rerun inspect_config.py. |
| A check for 127.0.0.1:8080:80 or 127.0.0.1:8181:80 fails even though the model looks correct. | The check assumes short port syntax, while Compose normalized ports into long-form objects. | Use config --format json and inspect host_ip, published, target, and protocol as separate semantic fields. |
| The mount experiment still points /usr/share/nginx/html at site. | compose.mounts.yaml was omitted, placed before compose.yaml, or the rendered file was not regenerated. | Run docker compose -f compose.yaml -f compose.mounts.yaml config --format json > rendered.mounts.json and rerun inspect_config.py. |
| The rendered source paths are absolute rather than ./site or ./site-blue. | Compose normalized relative bind paths against the first Compose file's directory. | This is expected. Compare the end of the normalized path or use pathlib rather than requiring the original relative spelling. |
| Compose reports that a bind source does not exist. | One or more of site, site-blue, or shared was not created in the stack directory. | Recreate the missing directory under /opt/lab-classroom/class80/stack and repeat the render. |
| inspect_config.py reports a service-set assertion failure. | The source files were edited, a different working copy was rendered, or stale JSON files are being inspected. | Confirm the absolute workspace path, regenerate every rendered JSON file using the lab commands, and rerun the script. |
The lab performs static rendering only and does not start unreviewed images.
Published ports bind to 127.0.0.1 rather than every host interface.
Bind mounts are read-only in the Compose model.
No secrets are stored in the example environment files.
Runtime environment files should not be mistaken for secret-management systems.
Pinned image tags are clearer than floating latest tags, although production environments may additionally pin immutable digests.
Profiles reduce accidental activation of optional services but are not an authorization boundary.
Rendered configuration can expose interpolated values, so real secret-bearing output should be handled as sensitive data.
Review every overlay in file order because later files can materially change earlier policy.
Confirm that replacement tags are intentional and supported by the deployed Compose version.
Inspect normalized host_ip values before approving published ports.
Check bind sources and targets to ensure a later overlay does not expose an unintended host path.
Keep project names explicit to reduce the chance of operating on an unrelated Compose project.
The lesson does not require cleanup for correctness because all generated files remain inside /opt/lab-classroom/class80/.
cd /opt/lab-classroom/class80 && if [ -d stack ] && [ ! -e stack.disabled ]; then mv stack stack.disabled; elif [ -d stack ] && [ -e stack.disabled ]; then printf '%s\n' 'Both stack and stack.disabled exist; no rename performed.'; else printf '%s\n' 'No active stack directory to disable.'; fi
cd /opt/lab-classroom/class80 && if [ -d stack.disabled ] && [ ! -e stack ]; then mv stack.disabled stack; elif [ -d stack.disabled ] && [ -e stack ]; then printf '%s\n' 'Both stack and stack.disabled exist; no rename performed.'; else printf '%s\n' 'No disabled stack directory to restore.'; fi
The disable and restore commands refuse to overwrite an existing destination. If both directories exist, compare them and resolve the collision manually while remaining under /opt/lab-classroom/class80/.
The prescribed lab never starts the project. Only if a learner independently started it, change to /opt/lab-classroom/class80/stack and run docker compose --profile ops down --remove-orphans after verifying the project name is class80. This removes project runtime resources and is not part of the normal static workflow.