kuluru vineeth
03

Part I · The Yard · 10 min

The Deploy

The last chapter turned a compose file into a service — a normalized row that records everything the platform knows about what should run: an image, ports, environment variables, volumes, a health check. It is complete, and it is inert. Nothing is running.

This chapter closes that gap. A row in a database is not a process on a machine; something has to read the record and hand Docker Swarm a service it can actually schedule. That is the deploy. It has a shape worth stating up front — freeze what you are about to run, assemble the running spec from the record, create the service, then let a health gate decide whether it lives. The question this chapter answers is the one hiding in “assemble the running spec”: when the service and its environment both set the same variable, which value reaches the container?

3.1 What you deployed, frozen

Before anything runs, the deploy takes a photograph. The service row is editable — a user can change the image, add a variable, or delete a volume the moment after they click deploy. If the deployment pointed back at the live row, “roll back to the last deployment” would roll back to whatever the row says now, not what actually shipped. So the deploy copies the record into the deployment itself.

deploy.py
def build_service_snapshot(service) -> dict:
return {
"image": service.image,
"command": service.command,
"healthcheck": _healthcheck_snapshot(service.healthcheck),
"resource_limits": service.resource_limits,
"urls": [
{
"domain": u.domain,
"base_path": u.base_path,
"strip_prefix": u.strip_prefix,
"redirect_to": u.redirect_to,
"associated_port": u.associated_port,
}
for u in service.urls
],
"ports": [{"host": p.host, "forwarded": p.forwarded} for p in service.ports],
"env_variables": [
{"key": e.key, "value": e.value} for e in service.env_variables
],
"volumes": [
{
"name": v.name,
"mode": v.mode,
"container_path": v.container_path,
"host_path": v.host_path,
}
for v in service.volumes
],
"configs": [
{
"name": c.name,
"mount_path": c.mount_path,
"contents": c.contents,
"language": c.language,
}
for c in service.configs
],
}

Listing 3.1 · the deploy freezes the service into a plain dict — image, command, health check, and every url, port, variable, volume, and config, by value.

Read what it does not do. There are no ORM objects in the returned dict — every nested row is unpacked into its own literal ({"key": e.key, "value": e.value}), so the snapshot holds no live reference back to the database. The load-bearing line is the environment comprehension: [{"key": e.key, "value": e.value} for e in service.env_variables] walks the variables in the order the service stores them, and that order is the whole subject of this chapter. Freeze it wrong here and every later question about which variable wins is already lost.

3.2 A probe that keeps asking

A service that starts is not a service that works. A process can boot, bind its port, and then wedge — and from the outside “the container is alive” and “the app is answering” look identical until a user hits it. Something has to keep asking. The deploy does not ask once; it builds a probe and hands it to Swarm, which runs it on a schedule for the life of the container.

deploy.py
def build_container_healthcheck(service) -> Healthcheck | None:
"""Hand the probe to Swarm so it runs continuously, not just once.
Swarm restarts a container that stops answering, which is what makes the
gate below meaningful — it reads a verdict rather than guessing from
whether a process happens to be alive.
"""
healthcheck = service.healthcheck
if healthcheck is None:
return None
if healthcheck.type == HealthCheckType.COMMAND.value:
test = ["CMD-SHELL", healthcheck.value]
else:
port = _healthcheck_probe_port(service, healthcheck)
path = healthcheck.value
# Loopback first, then the container's own hostname. An app that binds
# only to its container IP is unreachable on loopback — Next.js in
# standalone mode does exactly that, because Docker sets HOSTNAME to
# the container id and its server binds that single interface.
# curl and wget are both tried because alpine images ship one or the
# other, rarely both. $HOSTNAME expands in the container at probe time.
attempts = [
f"curl -fsS http://127.0.0.1:{port}{path}",
f"wget -q -O /dev/null http://127.0.0.1:{port}{path}",
f"curl -fsS http://$HOSTNAME:{port}{path}",
f"wget -q -O /dev/null http://$HOSTNAME:{port}{path}",
]
test = ["CMD-SHELL", " || ".join(attempts)]
second = 1_000_000_000
return Healthcheck(
test=test,
interval=healthcheck.interval_seconds * second,
timeout=healthcheck.timeout_seconds * second,
retries=MAX_SERVICE_RESTART_COUNT,
start_period=HEALTHCHECK_INTERVAL_SECONDS * second,
)

Listing 3.2 · the container health check the deploy hands to Swarm — a command probe passes through untouched; a path probe becomes four fallback attempts.

For a command health check the value is a shell line, wrapped verbatim as ["CMD-SHELL", healthcheck.value]. A path health check is where the care lives. The probe tries curl and wget because an alpine image ships one or the other, rarely both; and it tries 127.0.0.1 before $HOSTNAME because an app bound only to its container IP is unreachable on loopback — a Next.js server in standalone mode does exactly that. Four attempts, joined with ||, so the first that succeeds passes. The load-bearing line is retries=MAX_SERVICE_RESTART_COUNT: the probe is allowed to fail three times before Swarm calls the container unhealthy, which is what keeps a slow-starting app from being killed on its first breath.

3.3 From a row to a running spec

Now the assembly. The deploy has a frozen record and a probe; it turns them into the arguments Swarm’s create call wants. This is the transformation at the center of the chapter — and the place the chapter’s question gets answered.

deploy.py
envs: list[str] = [
f"DOCKYARD_DEPLOYMENT_HASH={deployment.unprefixed_hash}",
"DOCKYARD_DEPLOYMENT_TYPE=docker",
]
# environment-level variables first, so service variables can override them
for shared in environment.variables:
envs.append(f"{shared.key}={shared.value}")
for env in service.env_variables:
envs.append(f"{env.key}={env.value}")
mounts: list[str] = []
for volume in service.volumes:
mode = ACCESS_MODE_MAP.get(volume.mode, "rw")
if volume.host_path:
mounts.append(f"{volume.host_path}:{volume.container_path}:{mode}")
else:
mounts.append(
f"{docker_helpers.get_volume_resource_name(volume.id)}"
f":{volume.container_path}:{mode}"
)
exposed_ports: dict[int, int] = {}
for port in service.ports:
if port.host:
exposed_ports[port.host] = port.forwarded
endpoint_spec = EndpointSpec(ports=exposed_ports) if exposed_ports else None

Listing 3.3 · the deploy assembles the container’s environment, mounts, and published ports from the frozen record.

The environment is built in three passes and the order is deliberate. Two platform variables go in first — DOCKYARD_DEPLOYMENT_HASH and DOCKYARD_DEPLOYMENT_TYPE — so a running container can always name its own deployment. Then the environment’s shared variables. Then the service’s own variables, last. That last-ness is the load-bearing line, envs.append(f"{env.key}={env.value}"): the list is a flat sequence of KEY=VALUE strings, and when a key appears twice, Swarm keeps the last one. Appending the service’s variables after the environment’s is the entire mechanism by which a service overrides a value it inherits. The same call goes on to hand Swarm a bounded restart policy — condition="any", three attempts inside a ten-minute window — and a network alias like web.blue.dockyard.internal that the next chapter’s router will resolve.

passenvs.append(KEY=VALUE) · in ordercontainer seesplatformenvironmentservice0DOCKYARD_DEPLOYMENT_HASH=…1DOCKYARD_DEPLOYMENT_TYPE=…2FOO=from-envsuperseded3FOO=from-serviceFOO=from-servicerepeated key → last winsindex 2 read through, 3 keptreverse the last two passes and index 2 wins — the env value reads through instead
The environment is one flat list built in three passes; a repeated key like FOO appears twice and Swarm keeps the last entry, so the service’s value wins only because its pass runs after the environment’s.

3.4 Correct, not just running

Assembling a spec that Swarm accepts is not the same as assembling the right spec. A service can deploy, start, and answer its probe while quietly running the environment’s database URL instead of its own override — nothing errors, and the wrong data is in production. So the spec gets the same treatment every claim in this book gets: an independent check that reads the assembled arguments and asserts the properties that matter, before a container ever runs.

The companion driver builds a service whose FOO is from-service inside an environment whose FOO is from-env, stubs the Docker client so no daemon is touched, and runs the real create_swarm_service_for_deployment against the stub — then reads back the arguments it would have sent. Say which FOO you expect before the run: the service sets its own, the environment sets a different one, and only the order of two loops decides it. Then read the override row.

$ python deploy_spec_check.py

ok snapshot records env in order FOO,BAR ok boot envs injected first HASH,TYPE ok service var overrides environment var svc@3 > env@2 ok restart condition is any any ok healthcheck retries bounded 3 ok probe hits loopback path 127.0.0.1:3000/healthz ok slot address aliased for routing web.blue.dockyard.internal deploy 7/7 PARITY OK

Seven properties, seven passes. Read the third row: svc@3 > env@2 — the service’s FOO lands at index 3 in the environment list, the environment’s at index 2, so the service’s is last and Swarm keeps it. The retries bounded row confirms the three-strike probe from Listing 3.2 survived assembly; the restart condition is any row confirms the bounded policy; and web.blue.dockyard.internal is the alias the router will need. deploy 7/7, and the spec is correct before Swarm ever schedules it. What the check does not prove is that Swarm then runs the container, or that the health gate passes — those need a live daemon, and they are the deploy’s runtime, not its transformation.

The spec is correct and the service is created, but nothing outside the machine can reach it yet — the container answers on an internal alias, web.blue.dockyard.internal, and no browser knows that name. That is the next box: giving it a URL, the router that turns a healthy deployment into an address the outside world can hit.