Use Apple Container Machines To Test A Swift Backend For Your iOS App
The fastest way to test an iOS feature against a backend is usually the least production-like one.
You stub the response. Then you add one more stub. Then the receipt endpoint needs a failure case, the paywall needs a feature flag, and the app needs to prove that it still works when the server says “not yet.” Pretty soon the test target has become a tiny fake backend hiding inside your app code.
Apple’s container machines give you a better local loop for this middle ground. In Discover container machines, Apple shows a persistent Linux environment that is managed by the container tool, shares your working directory from macOS, and can run a Swift web service that your Mac can reach over the machine’s IP address. The command-line tool lives in the open-source apple/container repo, and it is built on the lower-level apple/containerization Swift package.
The practical use case for an iOS team is simple: keep editing in Xcode on macOS, run the backend in Linux, and point your app or tests at that local Linux service.
What we are testing
Imagine a small iOS app that needs three backend behaviors during development:
GET /healthso tests can fail fast when the local backend is not running.GET /feature-flagsso the app can exercise server-driven UI decisions.POST /receipts/validateso StoreKit flows can test success, denial, and malformed-response paths without touching production.
This is not your production receipt validator. It is a local test service with the same shape as the production boundary. That distinction matters. The app should learn how to call a backend, parse responses, and handle failures. The local service should not pretend to be App Store infrastructure.
Build a tiny Swift service
Here is a small Vapor package. Vapor is not required for container machines, but it is a familiar way to show the shape of a Swift backend with real routes.
// Package.swift
// swift-tools-version: 6.0
import PackageDescription
let package = Package(
name: "ReceiptTestService",
platforms: [
.macOS(.v15)
],
products: [
.executable(name: "ReceiptTestService", targets: ["ReceiptTestService"])
],
dependencies: [
.package(url: "https://github.com/vapor/vapor.git", from: "4.100.0")
],
targets: [
.executableTarget(
name: "ReceiptTestService",
dependencies: [
.product(name: "Vapor", package: "vapor")
]
)
]
)
The executable can live at Sources/ReceiptTestService/main.swift:
import Vapor
struct HealthResponse: Content {
var status: String
var service: String
}
struct FeatureFlagsResponse: Content {
var paywallExperiment: String
var receiptRefreshEnabled: Bool
var minimumSupportedBuild: Int
}
struct ReceiptValidationRequest: Content {
var receipt: String
var productID: String
}
struct ReceiptValidationResponse: Content {
var valid: Bool
var productID: String
var environment: String
var reason: String?
}
var environment = try Environment.detect()
try LoggingSystem.bootstrap(from: &environment)
let app = Application(environment)
defer { app.shutdown() }
let host = Environment.get("BIND_HOST") ?? "127.0.0.1"
let port = Environment.get("PORT").flatMap(Int.init) ?? 8080
app.http.server.configuration.hostname = host
app.http.server.configuration.port = port
app.get("health") { _ in
HealthResponse(status: "ok", service: "ReceiptTestService")
}
app.get("feature-flags") { _ in
FeatureFlagsResponse(
paywallExperiment: "annual-default",
receiptRefreshEnabled: true,
minimumSupportedBuild: 120
)
}
app.post("receipts", "validate") { request async throws in
let payload = try request.content.decode(ReceiptValidationRequest.self)
switch payload.receipt {
case "test:valid":
return ReceiptValidationResponse(
valid: true,
productID: payload.productID,
environment: "local",
reason: nil
)
case "test:expired":
return ReceiptValidationResponse(
valid: false,
productID: payload.productID,
environment: "local",
reason: "expired"
)
default:
throw Abort(.badRequest, reason: "Use a local test receipt token.")
}
}
try app.run()
Notice the BIND_HOST default. Local macOS development can use 127.0.0.1, but a service running inside a Linux container machine needs to listen on an interface that the Mac can reach. We will set that explicitly when we run it in the machine.
Create the Linux machine
Install and start Apple’s container tool according to the current release instructions in the container repo. As of the WWDC26-era toolchain, the important setup constraints are Apple silicon and macOS 26, with the underlying Containerization package also tied to current Xcode and macOS releases. Check the repo before scripting this into a team setup guide because the tool is young and the command surface can move.
Create a machine from a Linux image that contains the Swift toolchain you want to use:
# Use the Swift Linux image tag your team has validated.
# For example, this might be the same base image your backend Dockerfile uses.
SWIFT_IMAGE="swift:YOUR_VALIDATED_TAG"
container machine create --name ios-backend --set-default "$SWIFT_IMAGE"
Apple’s WWDC demo shows the command shape with an Alpine image:
container machine create --name demo --set-default alpine
For a Swift backend, the image choice matters. Use a Swift toolchain image that matches the version you compile and deploy with, or use your own OCI image with the toolchain and system packages already installed. The container tool consumes OCI-compatible images, so this can line up with your existing backend image strategy instead of becoming a separate world.
You can run one-off commands in the machine:
container machine run uname
container machine run swift --version
Or start an interactive shell:
container machine run
The useful bit for iOS development is that the machine shares your current working directory. You can edit the Swift package in Xcode on macOS, then build it from Linux without copying the project into the machine.
Build and run from Linux
From the package directory on your Mac, enter the machine:
cd ReceiptTestService
container machine run
Inside the Linux shell, build the package:
swift build
Before running the service, get the machine’s IP address from another terminal on the Mac:
container machine list
The list output includes the machine name and IP address. Suppose your ios-backend machine is reachable at 192.168.64.12. Run the server so it binds to that reachable interface:
BIND_HOST=192.168.64.12 PORT=8080 swift run ReceiptTestService
The exact IP on your Mac will be different. The important part is the binding rule:
- Do not bind only to
127.0.0.1inside the Linux machine if the iOS simulator or Safari on the Mac needs to reach the service. - Bind to the container machine IP, or to all interfaces with
0.0.0.0if that is how your server framework exposes the external interface. - Use
http://<machine-ip>:8080, nothttp://localhost:8080, from the app and tests.
That networking detail is the difference between “the backend works in the machine” and “the iOS app can actually call it.”
Smoke test the endpoints
From macOS, use the machine IP:
curl http://192.168.64.12:8080/health
curl http://192.168.64.12:8080/feature-flags
Then check the receipt path:
curl \
-X POST \
-H "Content-Type: application/json" \
-d '{"receipt":"test:valid","productID":"pro.monthly"}' \
http://192.168.64.12:8080/receipts/validate
If those requests work from macOS, the iOS simulator can usually reach the same URL. If they work inside the machine but fail from macOS, look at the bind address first, then firewall, VPN, and local network filtering.
One iOS-specific wrinkle: curl and Safari proving the URL works from macOS does not prove the app can use it. A plain HTTP URL to a local IP may require debug or test-only App Transport Security configuration, and physical devices may also trigger local-network privacy. Prefer HTTPS if you can. If you keep HTTP for this local workflow, add a narrow test-only exception for the machine IP or local domain, do not include the port in the ATS exception key, include NSLocalNetworkUsageDescription for device testing when needed, and do not ship broad NSAllowsArbitraryLoads.
Pass the base URL into Xcode
Do not hard-code a container machine IP in the app. Treat the backend URL as test configuration.
In the app target, read an environment override:
import Foundation
enum APIEnvironment {
static var baseURL: URL {
if let value = ProcessInfo.processInfo.environment["API_BASE_URL"] {
guard let url = URL(string: value),
["http", "https"].contains(url.scheme),
url.host != nil else {
fatalError("Invalid API_BASE_URL")
}
return url
}
return URL(string: "https://api.example.com")!
}
}
For unit or integration tests, add the same value in the Xcode test plan editor. I usually create a Local Linux Backend configuration, then add an enabled environment variable named API_BASE_URL:
API_BASE_URL = http://192.168.64.12:8080
The normal test configuration can still run against mocks or disabled network dependencies. The container-machine configuration is the one you run when you want to test the app’s real networking boundary.
For UI tests, pass the same value through the launched app’s environment:
import XCTest
final class PurchaseFlowUITests: XCTestCase {
func testValidReceiptUnlocksProState() {
let app = XCUIApplication()
let baseURL = ProcessInfo.processInfo.environment["API_BASE_URL"]
?? "http://192.168.64.12:8080"
app.launchEnvironment["API_BASE_URL"] = baseURL
app.launch()
app.buttons["Buy Pro"].tap()
app.buttons["Use Local Valid Receipt"].tap()
XCTAssertTrue(app.staticTexts["Pro is active"].waitForExistence(timeout: 5))
}
}
That fallback URL is convenient while you are writing the test, but remove or centralize it once the team adopts the workflow. A stale IP is a boring reason for a good test to fail.
Write a small integration test
You do not need to launch the full app to prove the backend contract is alive. Run this as a separate preflight test target, tag, or test-plan step before UI tests. Do not rely on Swift Testing to order this test ahead of unrelated tests automatically.
import Foundation
import Testing
struct LocalBackendTests {
private var baseURL: URL {
get throws {
let value = try #require(ProcessInfo.processInfo.environment["API_BASE_URL"])
let url = try #require(URL(string: value))
try #require(["http", "https"].contains(url.scheme))
try #require(url.host != nil)
return url
}
}
@Test func healthEndpointIsReachable() async throws {
let url = try baseURL.appendingPathComponent("health")
let (data, response) = try await URLSession.shared.data(from: url)
let httpResponse = try #require(response as? HTTPURLResponse)
#expect(httpResponse.statusCode == 200)
#expect(String(decoding: data, as: UTF8.self).contains("ok"))
}
@Test func validLocalReceiptReturnsAValidResponse() async throws {
let url = try baseURL
.appendingPathComponent("receipts")
.appendingPathComponent("validate")
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = try JSONEncoder().encode(
ReceiptValidationRequest(receipt: "test:valid", productID: "pro.monthly")
)
let (data, response) = try await URLSession.shared.data(for: request)
let httpResponse = try #require(response as? HTTPURLResponse)
#expect(httpResponse.statusCode == 200)
let decoded = try JSONDecoder().decode(ReceiptValidationResponse.self, from: data)
#expect(decoded.valid)
#expect(decoded.productID == "pro.monthly")
}
}
private struct ReceiptValidationRequest: Encodable {
var receipt: String
var productID: String
}
private struct ReceiptValidationResponse: Decodable {
var valid: Bool
var productID: String
var environment: String
var reason: String?
}
This test is intentionally small. Its job is to catch the local-environment mistakes early: the machine is stopped, the server is bound to the wrong interface, the port changed, the test plan is missing API_BASE_URL, or the request/response contract drifted.
The app’s higher-level tests can then focus on behavior:
- A disabled feature flag hides an entry point.
- A valid local receipt unlocks the paid screen.
- An expired receipt shows recovery UI.
- A
500response produces a retry path instead of a dead end. - A malformed response is handled as a recoverable backend error.
Keep the machine state useful
The best part of a container machine is also the easiest part to misuse: it is persistent.
Persistence is great when your backend needs a tool, cache, fixture database, or generated artifact that would be annoying to reinstall every morning. It is risky when tests accidentally depend on state left behind by yesterday’s run.
For this kind of local iOS backend, I would keep two modes:
- A persistent developer machine for interactive work.
- A clean image or scripted reset path for test runs that must be reproducible.
For the tiny service above, keep state out of the server entirely. If you add a database later, add explicit reset endpoints or test-only seed scripts. Do not let “it works on my machine” become “it works only because my machine has three weeks of leftovers.”
What still belongs in Docker and CI
Container machines are a terrific local Linux workbench. They do not replace your release pipeline.
Keep Docker or another image-based workflow for:
- Building the production backend image.
- Pinning exact system packages and Swift toolchain versions.
- Running clean, repeatable CI jobs.
- Starting multi-service dependencies such as Postgres, Redis, queues, and object storage.
- Publishing images to registries.
- Running vulnerability scans and deployment checks.
- Proving the backend works outside one developer’s persistent environment.
Keep CI responsible for the final answer. The local machine shortens the feedback loop before CI. It lets you find “the app cannot parse this backend response” while you are still sitting in Xcode, not ten minutes later in a remote log.
Caveats before you adopt it
Container machines are local Linux environments, not magic localhost forwarding.
The main caveats I would write into a team README are:
containeris for Apple silicon Macs and current macOS releases. The GitHub repos should be your source of truth for exact macOS, Xcode, and tool versions.- A machine IP can change when you recreate the machine. Update
API_BASE_URLwhen it does. 127.0.0.1inside the Linux machine is not the same thing as127.0.0.1from the iOS simulator.- Binding to the wrong host is the most common failure. Start by checking
BIND_HOST,PORT, andcontainer machine list. - Physical iPhones usually cannot reach the Mac-only container-machine subnet directly. For device testing, expose the service through the Mac’s LAN address with a proxy or tunnel, use HTTPS, and account for local-network privacy prompts.
- VPNs, firewalls, content filters, and corporate endpoint tools can block local machine networking.
- A persistent machine can hide dependency drift. Keep a documented creation command and a reset path.
- The local receipt endpoint should use fake local tokens. Real App Store validation, entitlement decisions, and fraud handling still belong on a trusted backend.
The workflow I would use
For day-to-day iOS development, the loop becomes:
- Edit the app and backend package in Xcode on macOS.
- Run the Swift backend in a container machine on Linux.
- Bind the backend to the machine IP or another reachable interface.
- Put the reachable service URL in
API_BASE_URL, with debug-only ATS and local-network privacy settings handled if you are using plain HTTP. - Run the Xcode test-plan configuration that talks to the local Linux backend.
- Keep Docker and CI as the clean, production-shaped validation layer.
That is the sweet spot. The iOS app gets a real HTTP boundary, the backend runs where Linux-specific problems can show up, and you still get to work in the Mac tools that make app development pleasant.
The important shift is not the command itself. It is treating the backend as part of the local app test loop, without smuggling the backend into the app target.