Service Containers, Job Containers, Docker Builds, and Integration-Test Environments: Guided Hands-On Workflow
This guided lab runs the same tiny Redis interaction in two topologies: directly on an Ubuntu runner with a mapped service port, then inside a Python job container that reaches Redis by service hostname. A third host job builds a tiny Docker image locally, records image identity and performs bounded cleanup without authenticating to or pushing into a registry.
Learning objectives
- Run a healthy Redis service from a host job and reach it through localhost plus a mapped port.
- Run the same interaction from a Python job container and reach Redis by the service hostname.
- Capture safe Docker, service, runner and network evidence without exposing credentials.
- Build a tiny Docker image locally on the hosted runner and verify its local image ID.
- Perform explicit lab cleanup while understanding what GitHub tears down automatically.
1. Disposable lab and preflight
Create a throwaway GitHub.com repository named
gha-container-lab. The workflow is manual, uses
permissions: {}, GitHub-hosted
ubuntu-24.04, public Redis/Python images, synthetic
keys and no secret. The lab never logs into or pushes to a registry.
Before the first run, predict three states: the host job will use
127.0.0.1:6379; the container job will use
redis:6379 without a host-port mapping; and the build
job will create a local image whose sha256: ID
disappears with the ephemeral runner after cleanup/teardown.
2. One workflow, three bounded jobs
name: chapter15-container-lab
on:
workflow_dispatch:
permissions: {}
jobs:
host-service:
runs-on: ubuntu-24.04
services:
redis:
image: redis:7.4-alpine
ports:
- 6379:6379
options: >-
--health-cmd "redis-cli ping"
--health-interval 5s
--health-timeout 3s
--health-retries 10
steps:
- name: Inspect host and Docker state
shell: bash
run: |
echo "run=$GITHUB_RUN_ID attempt=$GITHUB_RUN_ATTEMPT sha=$GITHUB_SHA"
echo "runner=$RUNNER_OS/$RUNNER_ARCH"
docker version
docker ps --format 'table {{.ID}} {{.Image}} {{.Status}} {{.Ports}}'
docker image inspect redis:7.4-alpine --format 'redis_id={{.Id}} repo_digests={{json .RepoDigests}}' || true
- name: Exercise Redis from the runner host
shell: bash
env:
REDIS_HOST: 127.0.0.1
REDIS_PORT: '6379'
run: |
python3 - <<'PY'
import os, socket
host=os.environ['REDIS_HOST']; port=int(os.environ['REDIS_PORT'])
def command(*parts):
payload=f"*{len(parts)}\r\n" + ''.join(f"${len(p)}\r\n{p}\r\n" for p in parts)
with socket.create_connection((host,port), timeout=5) as s:
s.sendall(payload.encode())
return s.recv(4096).decode(errors='replace')
print('PING =>', command('PING').strip())
print('SET =>', command('SET','chapter15:key','host-ok').strip())
print('GET =>', command('GET','chapter15:key').strip())
PY
container-service:
runs-on: ubuntu-24.04
container:
image: python:3.13-slim
services:
redis:
image: redis:7.4-alpine
options: >-
--health-cmd "redis-cli ping"
--health-interval 5s
--health-timeout 3s
--health-retries 10
steps:
- name: Exercise Redis from the job container
shell: sh
env:
REDIS_HOST: redis
REDIS_PORT: '6379'
run: |
python - <<'PY'
import os, socket, platform
print('python', platform.python_version())
host=os.environ['REDIS_HOST']; port=int(os.environ['REDIS_PORT'])
def command(*parts):
payload=f"*{len(parts)}\r\n" + ''.join(f"${len(p)}\r\n{p}\r\n" for p in parts)
with socket.create_connection((host,port), timeout=5) as s:
s.sendall(payload.encode())
return s.recv(4096).decode(errors='replace')
print('PING =>', command('PING').strip())
print('SET =>', command('SET','chapter15:key','container-ok').strip())
print('GET =>', command('GET','chapter15:key').strip())
PY
build-local:
needs: [host-service, container-service]
runs-on: ubuntu-24.04
steps:
- name: Create deterministic tiny build context
shell: bash
run: |
mkdir -p .lab
cat > .lab/app.py <<'PY'
import platform
print('chapter15 image self-test')
print('python', platform.python_version())
PY
cat > .lab/Dockerfile <<'DOCKER'
FROM python:3.13-slim
WORKDIR /app
COPY app.py /app/app.py
CMD ["python", "/app/app.py"]
DOCKER
cat > .lab/.dockerignore <<'EOF'
image-id.txt
EOF
- name: Build and verify a local image
shell: bash
run: |
tag="gha-ch15-local:${GITHUB_RUN_ID}"
docker version
docker build --pull --iidfile .lab/image-id.txt -t "$tag" .lab
iid_file="$(cat .lab/image-id.txt)"
iid_inspect="$(docker image inspect "$tag" --format '{{.Id}}')"
printf 'iid_file=%s
iid_inspect=%s
' "$iid_file" "$iid_inspect"
test "$iid_file" = "$iid_inspect"
docker run --rm "$tag"
docker image inspect python:3.13-slim --format 'base_id={{.Id}} repo_digests={{json .RepoDigests}}' || true
- name: Bounded cleanup
if: ${{ always() }}
shell: bash
run: |
tag="gha-ch15-local:${GITHUB_RUN_ID}"
docker image rm "$tag" 2>/dev/null || true
3. Why the host job publishes a port
The first job's Python process runs on the Ubuntu runner, not inside
the Redis service network. The Redis container therefore publishes
container port 6379 to host port 6379. The client connects to
127.0.0.1:6379.
The health check is executed as part of the service lifecycle. If Redis never becomes healthy, GitHub fails the service setup before the application step. Keep that distinction: “service did not become healthy” is a different failure layer from “application used the wrong endpoint.”
4. Why the job-container variant removes the port mapping
The second job runs its ordinary steps inside
python:3.13-slim. GitHub creates a Docker network
shared by the job container and the Redis service. The service label
redis becomes the DNS hostname. No host-port mapping is
required because both peers are inside the same Docker network.
The step explicitly uses shell: sh because the Python
slim image does not promise Bash. This keeps the example coherent
with GitHub's documented default shell behavior for container jobs.
5. Evidence to capture from the two integration jobs
| Evidence | Host-service job | Job-container job |
|---|---|---|
| Client location | Ubuntu runner host | Python job container |
| Redis address | 127.0.0.1:6379 |
redis:6379 |
| Port publication | required for host process | not required |
| Shell/runtime | Bash + host Python | sh + Python 3.13 image |
| Service evidence | Docker ps/status, health, image ID/digest if visible | job conclusion + service health; container-level host Docker inspection is not assumed inside the job container |
| Run identity | run ID, attempt, source SHA | same run identity but independent job/runner allocation |
6. Local Docker build: no registry is involved
The build job creates its entire source and Dockerfile inside a
bounded .lab/ context.
docker build --iidfile writes the local image ID. The
workflow independently asks Docker for .Id and requires
the two values to match. That is a content-addressed identity inside
this daemon.
The tag gha-ch15-local:<run-id> is only a
convenient local name. Since the image is never pushed, there is no
registry digest for the new application image. The base image may
have a RepoDigest after pull, so the workflow records
it when Docker exposes it.
7. Cleanup has two layers
The workflow explicitly removes the locally built image by its run-bounded tag. GitHub separately manages service and job-container teardown. Finally, because each GitHub-hosted job gets an ephemeral VM, any remaining Docker state disappears with the runner.
This does not generalize to persistent self-hosted runners. There, explicit image/container/volume lifecycle policy is required, and cleanup must be scoped so one workflow cannot delete another workload's state.
8. Challenge: choose the layer before changing YAML
You add a PostgreSQL service and the application runs in a job
container. A teammate publishes 5432:5432 and connects
to localhost:5432. Without running anything, identify
the unnecessary configuration and the wrong assumption. Then state
the smallest correction and the evidence that would prove it.
Answer: remove the host port publication unless a host process truly
needs it, connect the job-container application to the service label
such as postgres:5432, and verify service health plus
application connection logs from the same run/SHA.
9. Local faithful simulation
With Docker installed locally, create a user-defined network, start
redis:7.4-alpine on it, then run
python:3.13-slim on the same network and connect to
hostname redis. Repeat by publishing Redis port 6379
and connecting from the host to 127.0.0.1:6379. Build
the .lab Dockerfile and inspect its image ID.
The simulation validates Docker topology and image identity, but it does not validate GitHub's managed service lifecycle, contexts, run IDs or runner teardown. Record that limitation.
Knowledge check
Why is no port mapping needed in
container-service?
Because the job container and Redis service share GitHub’s Docker network and the service is reachable by its label/hostname.
Why does build-local use a separate host
job?
The host runner exposes Docker CLI/daemon state needed for the local image build, while the earlier job container is intentionally just the application runtime.
Does the run-bounded image tag prove image content?
No. The workflow compares the tag’s inspected local image ID with the IID file to identify the built content.
What is the cleanup difference on a self-hosted runner?
The VM may persist, so images, volumes and containers can survive jobs; cleanup must be explicit and safely scoped.
What should the learner change when moving from host process to job container?
Re-derive the service address from the new network namespace, usually switching from localhost + published host port to the service hostname on the shared network.
Official references and version notes
- GitHub Docs — workflow syntax: job containers and services — Linux requirement, shells, volumes, options and service networking.
- GitHub Docs — Redis service containers — host versus job-container topology and health checks.
- GitHub Docs — run jobs in a container — container credentials, ports, volumes and default shell behavior.
-
docker/setup-buildx-action v4.1.0
— current optional Buildx setup action, production reference
d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5. -
docker/build-push-action v7.3.0
— current optional Docker build action, production reference
53b7df96c91f9c12dcc8a07bcb9ccacbed38856a. -
docker/login-action v4.6.0
— registry authentication action, production reference
dbcb813823bdd20940b903addbd779551569679f.
Version-sensitive behavior was rechecked on
2026-09-09. GitHub job containers, service
containers and Docker container actions require a Linux runner; on
GitHub-hosted runners that means Ubuntu. When a job runs on the
host, mapped service ports are reached through
localhost; when the job itself runs in a container,
service containers share a Docker network and are reached by their
service labels without host-port publication. The mandatory labs
target ubuntu-24.04, use public version-family images
redis:7.4-alpine and python:3.13-slim,
and record resolved image metadata rather than claiming those tags
are immutable. They use the Docker CLI already present on the
hosted Ubuntu runner and perform no registry login or push.
Optional production Buildx examples should pin the full SHAs
listed above.
Keep the academy open
Support free, practical DevOps education.
Every lesson is designed to remain readable in a browser, downloadable from GitHub, and usable without a paid learning platform. Contributions help expand and maintain the curriculum.
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0Send only Ethereum/ERC-20 compatible assets to this
address.