In this lab you take a Swift server app (HTTP API + Postgres) through these Apple Container workflows:
| Stage | Tool | What it shows |
|---|---|---|
| 1. Develop | your mac! | The app builds and runs on your Mac |
| 2. Verify on Linux | container machine | A Linux VM that sees your Mac files at the same path, so you can edit on the Mac and rebuild on Linux instantly |
| 3. Containerize | container | Build and run an OCI image of the app |
| 4. Deploy locally | container cluster | Run the app + Postgres on a local cluster before it goes to staging |
| Requirement | Version | Get it |
|---|---|---|
| Apple silicon Mac running macOS 26 | macOS 26 | |
| Apple Container | 1.7.0 or newer | Build from source |
| Swift toolchain on macOS | 6.3 or newer | Xcode or swift.org |
kubectl |
1.37 or newer | Install guide (or brew install kubectl) |
- The container system service running:
container system start container system status
- Host port
8080free (used in Parts 3 and 4).
Run these before the workshop so the large downloads are already cached:
container image pull --platform linux/arm64 swift:6.3-noble
container image pull --platform linux/arm64 swift:6.3-noble-slim
container image pull --platform linux/arm64 postgres:17-alpine
container image pull --platform linux/arm64 docker.io/kindest/node:v1.35.5@sha256:ce977ae6d65918d0b58a5f8b5e940429c2ce42fa3a5619ec2bbc60b949c0ac95Run these and confirm the expected result for each:
sw_vers -productVersion # 26 or later
uname -m # arm64
swift --version # Swift version 6.3 or newer
container --version # container CLI version 1.7.0 or later
container system status # running
kubectl version --client # prints a client version
lsof -i :8080 # prints nothing (port is free)
container image list # shows the images abovecontainer-workflows-lab/
├── README.md # this guide
├── todos-app/ # Swift server todos app (from swiftlang/swift-server-todos-tutorial, Completed/)
│ ├── UPSTREAM.md # upstream source, commit, and lab modifications
│ ├── Package.swift
│ ├── Dockerfile # app image (used in Part 3)
│ └── Sources/SwiftServerTodos/
│ ├── ETagMiddleware.swift # ← the feature you'll be verifying
│ └── ServerService.swift
├── machine/
│ └── swift-dev.Dockerfile # image for the Container Machine
└── cluster/
└── todos-cluster.yaml # Postgres + todos API manifests
You added a feature to the todos API: an ETag middleware that hashes each response body and returns it in an ETag header, so clients can cache responses. Take a look at todos-app/Sources/SwiftServerTodos/ETagMiddleware.swift.
Things to notice in ETagMiddleware.swift:
import CryptoKit: Apple's crypto framework, used forSHA256.hash(...).response.body.collect(...): OpenAPI handlers stream their bodies, so the middleware collects the bytes before hashing.
In ServerService.swift, enable this middleware by uncommenting the ETagMiddleware line:
app.middleware.use(RequestLoggerInjectionMiddleware())
app.middleware.use(TracingMiddleware())
// Enable ETagMiddleware
app.middleware.use(ETagMiddleware())
app.traceAutoPropagation = trueFrom the container-workflows-lab directory:
[macOS]
cd todos-app
swift buildExpected: Build complete!
While building, look around the files: APIHandler (basic CRUD), ETagMiddleware (hashing the response body). Swift build reads Package.swift to read what the package contains and resolves their dependencies.
Recap: The feature builds fine on macOS. Production runs on Linux, so let's better check that.
The machine image is the official swift:6.3-noble image (the same toolchain as the app's own Dockerfile) plus systemd, which makes it bootable as a machine.
Go back up to container-workflows-lab:
[macOS]
cd ..
container build -f machine/swift-dev.Dockerfile -t swift-dev:lab machine/If you didn't pre-pull the image, this downloads the Swift image (around 1 GB) and takes some time.
The swift:6.3-noble image is essentially Ubuntu 24.04 with the Linux Swift 6.3 compiler installed.
[macOS]
container machine create swift-dev:lab --name swift-dev
container machine listOpen a shell in the machine and look around. The shell starts in the same directory as your Mac shell (container-workflows-lab).
Should say Linux instead of Darwin
container machine run -n swift-dev
uname Expected: Swift version 6.3.x (swift-6.3.x-RELEASE)
swift --versionMirrors username and current working directory from your Mac
whoami
pwdIn the machine shell, build the Linux artifacts.
[swift-dev]
cd todos-app
swift build --scratch-path ~/.cache/todos-buildWhy
--scratch-path? It keeps Linux build output on the machine's own disk, separate from the macOS.build/folder on your Mac. The two platforms' build artifacts never collide.
Expected failure:
error: no such module 'CryptoKit'
Why? CryptoKit is an Apple-platform framework and doesn't exist on Linux. The code compiled on macOS, but it would have failed in CI or production. You found this in a couple of minutes, on your laptop.
Make these edits in your editor on the Mac. Leave the machine shell open, because you'll use it again in 2.5.
After doing some research, you find out that the cross-platform fix is swift-crypto, Apple's open-source implementation of the CryptoKit API that works on every platform. Its Crypto module has the same API, so you need to change the import and add the dependency.
In todos-app/Package.swift, uncomment to add the dependency to the package and the executable target.
// Crypto
.package(url: "https://github.com/apple/swift-crypto.git", from: "3.0.0"),// Crypto
.product(name: "Crypto", package: "swift-crypto"),Then modify the import in todos-app/Sources/SwiftServerTodos/ETagMiddleware.swift.
- import CryptoKit
+ import CryptoSwitch back to your machine shell. You don't need to copy or sync anything, because the machine sees your edit at the same path.
[swift-dev]
swift build --scratch-path ~/.cache/todos-buildExpected: Build complete!
Recap: You used Container Machine to build your app on a Linux environment. You found that you're using a platform-specific API. Then you edited on macOS with your editor and verified on Linux right away, and Container Machine never needed a copy or a sync step. You caught a platform bug during development instead of in CI.
The app ships with a multi-stage Dockerfile: a swift:6.3-noble builder stage compiles the app on Linux, and a slim swift:6.3-noble-slim runtime stage runs it. Because you fixed the Linux build in Part 2, this now succeeds.
Exit the machine shell to return to your Mac shell, which is still in container-workflows-lab:
[swift-dev]
exitThen build the image:
[macOS]
cd todos-app
container build -t todos:lab .
container image listThis will take some time.
The app needs a database, so run Postgres as its own container first.
[macOS]
container run -d --name todos-db -e POSTGRES_HOST_AUTH_METHOD=trust postgres:17-alpine
container list
POSTGRES_HOST_AUTH_METHOD=trustis for this local lab only.
Every container gets its own IP address. Find the todos-db address in the ADDR column of container list (it looks like 192.168.64.x)
Now start the app and point it at the database. Replace <DB-IP> with the address you just found:
[macOS]
container run -d --name todos-app -p 8080:8080 \
-e POSTGRES_URL="postgres://postgres@<DB-IP>:5432/postgres?sslmode=disable" \
-e OTEL_LOGS_EXPORTER=none -e OTEL_METRICS_EXPORTER=none -e OTEL_TRACES_EXPORTER=none \
todos:lab-druns the container in the background.-p 8080:8080publishes the app's port to your Mac aslocalhost:8080.POSTGRES_URLtells the app where its database is. The app reaches Postgres directly over the container network.- The
OTEL_*_EXPORTER=nonevariables turn off OpenTelemetry export. There's no OTel collector in this lab, so without them the logs fill up withFailed to exportwarnings.
Tip: Instead of looking up the database IP, you can use
container system dnsto create a local DNS domain (for example,sudo container system dns create test).
Check that it's up, then call the API from your Mac:
[macOS]
container logs todos-app
curl -i http://localhost:8080/api/todosExpected: HTTP/1.1 200 OK with an etag header, which comes from the middleware you fixed and verified in Part 2.
Recap: You built the app for Linux and verified that it can be run as a container!
Stop and remove both containers. Part 4 reuses port 8080 and the todos-app name, so skipping this causes conflicts later.
[macOS]
container stop todos-app todos-db
container delete todos-app todos-dbRecap: We verified this feature works on the Linux environment, so we went ahead and created a container image. Then we spun up two containers: one app container using this image, and another using postgres to confirm that the app is working.
This command creates a single node Kubernetes cluster.
--shared-image-store lets the cluster's node read images straight from your local image store (the one container image list shows).
[macOS]
container cluster create --name todos-cluster --shared-image-store
container cluster list[cluster]
kubectl get nodesExpected: one node in Ready state.
You don't need to load anything into the cluster or push todos:lab to a registry. When the node needs an image, it looks in your local image store first. If the image isn't there (or has no linux/arm64 version), the node pulls it from the registry as usual.
Confirm both images are in your local store:
[macOS]
container image listExpected: todos with tag lab, and postgres with tag 17-alpine.
Go back up to container-workflows-lab:
[macOS]
cd ..
cat cluster/todos-cluster.yamlWhat's in it:
| Resource | Purpose |
|---|---|
PersistentVolume / PersistentVolumeClaim postgres-data |
1 Gi hostPath volume on the node so data survives pod restarts |
Deployment postgres |
Postgres 17 (Alpine), Recreate strategy (never two pods on one volume), pg_isready readiness probe |
Service postgres |
Cluster DNS name postgres:5432 for the app |
Deployment todos |
The todos:lab image with imagePullPolicy: Never (use the image from your local store, never a registry) and a /health readiness probe |
Service todos |
Port 80 → container port 8080 |
Things to notice:
PGDATA=/var/lib/postgresql/data/pgdataputs the data directory in a subfolder of the mount. Postgres refuses to initialize directly in a mount point's root.POSTGRES_HOST_AUTH_METHOD=trustis for this local lab only. Never use it in a real deployment.- The manifests use full names like
docker.io/library/todos:lab. That is the same image astodos:labincontainer image list, becausecontaineraddsdocker.io/library/to short names. - The app finds the database through
POSTGRES_URL=postgres://postgres@postgres:5432/..., wherepostgresis the Service name.
[cluster]
kubectl apply -f cluster/todos-cluster.yaml
kubectl get pods -wPort-forward in a second terminal (it keeps running):
[cluster]
kubectl port-forward service/todos 8080:80Back in your first terminal, create a todo and read it back:
[macOS]
curl -X POST http://localhost:8080/api/todos \
-H 'Content-Type: application/json' \
-d '{"contents": "hello from apple container"}'
curl -i http://localhost:8080/api/todosExpected: the response includes the etag header from the middleware you verified in Part 2:
HTTP/1.1 200 OK
etag: "113937eb7944717f87f153e1377a9917f6d53d0a444956c08840afc5f961bxxx"
...
"contents" : "hello from apple container",
Recap: The app is running on Kubernetes, built from code you verified on Linux and written on macOS.
Delete the Postgres pod. The Deployment recreates it on the same volume.
[cluster]
kubectl delete pod -l app=postgres
kubectl get pods -w # wait for the new postgres pod to be 1/1 Running, then Ctrl+CIf the port-forward dropped, restart it in the second terminal, then:
[macOS]
curl http://localhost:8080/api/todosExpected: hello from apple container is still there.
[cluster]
kubectl get all # deployments, replicasets, pods, services
kubectl describe deployment todos # probes, image, events
kubectl logs deploy/todos # app logs
kubectl get pv,pvc # storage binding
kubectl scale deployment todos --replicas=3 # scale the API (Postgres stays at 1)
kubectl get pods -l app=todos -o wide
kubectl rollout restart deployment todos # rolling restart
kubectl rollout status deployment todosStop the port-forward (Ctrl+C in the second terminal), then, back in your first terminal (still in container-workflows-lab):
[cluster]
kubectl delete -f cluster/todos-cluster.yaml[macOS]
container cluster delete --name todos-cluster
container machine delete swift-dev
container image delete todos:lab swift-dev:lab
container image delete swift:6.3-noble swift:6.3-noble-slim postgres:17-alpine