Run a Multi-Service Dev Stack With Apple's container — No Docker Compose Needed
Quick answer
Apple's container tool has no compose command — the most common reason people bounce off it. This tutorial rebuilds a classic docker-compose dev stack (Postgres, Redis, and an app) using per-container IPs, built-in DNS service discovery, named volumes, and a small Makefile that gives you compose-style up/down/logs back.
- Step 1: The Compose File We're Replacing
- Step 2: Create the Service-Discovery Domain
- Step 3: Start Postgres With a Named Volume
- Step 4: Start Redis (Smaller VM This Time)
- Step 5: Write the App
intermediate · 40 min
Before you begin
- Apple's container tool installed and running — see the getting-started tutorial
- An Apple silicon Mac on macOS 26 (container-to-container networking requires it)
- Familiarity with docker-compose basics (services, volumes, environment)
- make — included with the Xcode Command Line Tools
The first question everyone asks after installing Apple's container tool: where is container compose? There isn't one, and nothing that speaks the Docker Compose API works with it. For a lot of developers that's the end of the evaluation, because real local dev is rarely one container — it's an app, a database, a cache, and maybe a queue, all defined in one YAML file.
Here's the part that gets missed: most of what Compose does for you exists in container as platform features. Compose gives you a network where services find each other by name — container gives every container its own IP and a built-in DNS server. Compose gives you named volumes — container volume does that. Compose gives you up, down, and logs — that's twenty lines of Makefile. What you lose is the YAML; what you gain is that every service is a real host with its own address, so there are no port mappings to juggle at all.
This tutorial takes a classic compose stack — Postgres, Redis, and a small Python API — and rebuilds it natively. If you haven't installed the tool yet, do the getting-started tutorial first; this one picks up where it ends.
What You'll Build
- A three-service dev stack: Postgres 17, Redis, and a Flask API that uses both
- DNS-based service discovery — the app reaches the database at
db.test, no IPs or port maps anywhere - A named volume so Postgres data survives container rebuilds
- A health-check wait so the app doesn't start before the database is ready
- A Makefile giving you
make up,make down,make logs,make psql— the compose ergonomics you actually miss
Step 1: The Compose File We're Replacing
For orientation, this is the docker-compose.yml equivalent of what we're about to build:
1services:
2 db:
3 image: postgres:17-alpine
4 environment: { POSTGRES_PASSWORD: devpass, POSTGRES_DB: app }
5 volumes: [pgdata:/var/lib/postgresql/data]
6 cache:
7 image: redis:8-alpine
8 api:
9 build: .
10 environment:
11 DATABASE_URL: postgresql://postgres:devpass@db:5432/app
12 REDIS_URL: redis://cache:6379
13 ports: ["8000:8000"]
14 depends_on: [db, cache]
15volumes:
16 pgdata:Every concept in that file — service names as hostnames, a named volume, environment wiring, startup ordering, and the port mapping — has a native counterpart. Except the last one: the port mapping simply disappears, because the API container will have its own IP.
Create a project directory:
mkdir container-stack && cd container-stackStep 2: Create the Service-Discovery Domain
Compose services resolve each other by service name because Compose runs an embedded DNS server on its network. container has the same thing — you just name the domain yourself, once:
sudo container system dns create testFrom now on any container started with --name db is resolvable as db.test — from your Mac and from other containers, since both sides query the same embedded DNS on the container network. That one command is the entire service-discovery story.
One deliberate choice here: the domain is test, not something cuter like dev. .test is reserved by the IETF for exactly this purpose and will never exist on the real internet. .dev, by contrast, is a real Google-owned TLD that every browser force-upgrades to HTTPS — and claiming it locally would hijack your Mac's DNS for real sites like web.dev until you tear the domain down.
Step 3: Start Postgres With a Named Volume
Postgres data must outlive the container, so create a named volume first, then run the database on it:
1container volume create pgdata
2
3container run --detach --name db \
4 --volume pgdata:/var/lib/postgresql/data \
5 --env POSTGRES_PASSWORD=devpass \
6 --env POSTGRES_DB=app \
7 --memory 1g \
8 docker.io/postgres:17-alpineOne flag deserves a comment: --memory 1g. Every container here is its own VM with its own kernel, and each defaults to 1 GB. That's the right ballpark for Postgres, but it's worth setting explicitly for every service in the stack, because per-VM memory is the real cost of this architecture — three services at defaults is ~3 GB committed. Budget deliberately.
Verify it's up and reachable by name:
container ls
ping -c1 db.testStep 4: Start Redis (Smaller VM This Time)
Redis for a dev stack needs nowhere near a gigabyte:
container run --detach --name cache --memory 512m docker.io/redis:8-alpineTwo services running, each a separate VM with its own IP, both resolvable by name. Notice what you didn't do: no network create, no port publishing, no worrying that something else on your Mac already uses 5432 or 6379. Those ports are taken on those containers' IPs, which nothing else shares.
Step 5: Write the App
A minimal Flask API that exercises both backends — it counts visits in Redis and reads the server version from Postgres. Create app.py:
1import os
2import psycopg
3import redis
4from flask import Flask, jsonify
5
6app = Flask(__name__)
7
8@app.route("/")
9def index():
10 r = redis.Redis.from_url(os.environ["REDIS_URL"])
11 visits = r.incr("visits")
12 with psycopg.connect(os.environ["DATABASE_URL"]) as conn:
13 pg_version = conn.execute("SHOW server_version").fetchone()[0]
14 return jsonify(visits=visits, postgres=pg_version, status="ok")
15
16if __name__ == "__main__":
17 app.run(host="0.0.0.0", port=8000)And a Dockerfile:
FROM docker.io/python:3.13-alpine
WORKDIR /app
RUN pip install --no-cache-dir flask "psycopg[binary]" redis
COPY app.py .
CMD ["python", "app.py"]Step 6: Build and Wire It Up
Build the image, then run it with the service names in the connection URLs — exactly where compose would have put bare service names, you put the DNS names:
1container build --tag stack-api .
2
3container run --detach --name api \
4 --env DATABASE_URL=postgresql://postgres:[email protected]:5432/app \
5 --env REDIS_URL=redis://cache.test:6379 \
6 --memory 512m \
7 stack-apiTest it — the API is its own host, so hit it directly on port 8000:
curl http://api.test:8000
# {"postgres":"17.5","status":"ok","visits":1}
curl http://api.test:8000
# {"postgres":"17.5","status":"ok","visits":2}The visit counter proves Redis works, the version string proves Postgres works, and api.test proves you never needed a port mapping. If a browser tool insists on localhost, add -p 127.0.0.1:8000:8000 to the run command — but try living without it first.
Step 7: Handle Startup Order
Compose's depends_on doesn't wait for readiness anyway — everyone ends up writing a health-check wait. Here it's explicit and honest. Postgres ships pg_isready, so poll it before starting the app:
until container exec db pg_isready -U postgres -q; do
echo "waiting for postgres..."
sleep 1
doneOn a cold make up this typically waits one or two seconds. It belongs in the Makefile, which is where this all comes together.
Step 8: The Makefile — Compose Ergonomics Restored
Create a Makefile (recipes must be indented with tabs, not spaces):
1DB_URL = postgresql://postgres:[email protected]:5432/app
2
3.PHONY: up down status logs psql build
4
5up: build
6 -container volume create pgdata
7 container run -d --name db -v pgdata:/var/lib/postgresql/data \
8 -e POSTGRES_PASSWORD=devpass -e POSTGRES_DB=app --memory 1g \
9 docker.io/postgres:17-alpine
10 container run -d --name cache --memory 512m docker.io/redis:8-alpine
11 until container exec db pg_isready -U postgres -q; do sleep 1; done
12 container run -d --name api --memory 512m \
13 -e DATABASE_URL=$(DB_URL) -e REDIS_URL=redis://cache.test:6379 stack-api
14 @echo "stack up: http://api.test:8000"
15
16build:
17 container build --tag stack-api .
18
19down:
20 -container stop api cache db
21 -container delete api cache db
22
23status:
24 container ls
25
26logs:
27 container logs --follow api
28
29psql:
30 container exec -ti db psql -U postgres appThe leading - on container volume create and the down recipes tells make to ignore errors (volume already exists, containers already gone) — the same idempotence compose gives you. Now your daily loop is:
make down && make up # rebuild and restart everything
make logs # follow the app
make psql # drop into the databaseNote what down doesn't touch: the pgdata volume. Destroy and recreate the stack all day — your database contents persist, exactly like compose named volumes.
Common Issues
db.testdoesn't resolve inside the api container. First check the domain exists:container system dns list. Then confirm you're on macOS 26 — container-to-container networking (and therefore cross-container DNS) needs it; on Sequoia this tutorial stops at Step 3. Recreating the domain and restarting the stack (sudo container system dns create test,make down && make up) fixes stale resolver state.- The api container exits immediately after
make up. Almost always the database wasn't ready and the first connection attempt killed the process — checkcontainer logs api. Thepg_isreadyloop prevents it; if you bypassed the Makefile, wait a second andcontainer start api. - Postgres data vanished. You ran the db container without
--volume pgdata:..., so data went to the container's own disk and died with it. Data you care about lives in a named volume, period. Check what exists withcontainer volume list. - Your Mac feels memory-pressured with the stack up. Per-VM memory is additive: this stack commits ~2 GB as configured. Tune each
--memorydown to what the service needs, and stop stacks you're not using — idle VMs still hold their allocation.container statsshows actual usage per container. - You need two copies of the stack (e.g. two branches). Names collide — there's only one
db.test. Prefix per project (myapp-db,myapp-cache) and template the Makefile with aPROJECTvariable, or keep one stack per domain (db.myapp,db.othervia two DNS domains).
Frequently Asked Questions
Will Docker Compose ever work with Apple's container tool?
Not directly — Compose talks to a Docker API socket that container deliberately doesn't implement. Apple froze the CLI and XPC APIs at 1.0 precisely so third parties can build orchestration on top, and community compose-like frontends are appearing, but nothing is standard yet. For a handful of services, the Makefile pattern here is honestly hard to beat: it's explicit, debuggable, and has zero extra dependencies.
How do containers find each other without Compose networking?
Every container gets its own IP on a shared vmnet network, and the system service runs an embedded DNS server. Creating a domain (sudo container system dns create test) makes every named container resolvable as <name>.<domain> from the host and from other containers. That's the same mechanism Compose uses internally — service names resolving via an embedded DNS — minus the YAML.
Isn't a VM per service wasteful for a dev stack?
It's heavier on memory than a shared-kernel runtime — each service carries its own kernel plus its memory allocation, so budget explicitly with --memory per service (this stack: 1 GB Postgres, 512 MB each for Redis and the app). In exchange, each service is hardware-isolated and boots in under a second. For stacks of three to five services on a 16 GB+ Mac it's a non-issue; for a twelve-service monolith-in-pieces, a shared-VM tool still fits better — see the comparison with OrbStack and Colima.
Can I run this alongside Docker Desktop or OrbStack?
Yes — they don't conflict. container uses its own storage, network range, and CLI. A common setup is keeping Docker Desktop (or Colima) for compose-heavy and testcontainers work while using container for everything else, then migrating project by project.
How do I add a fourth service, like a message queue?
One more container run line in the Makefile with a name, a memory budget, and any env vars — then reference it as <name>.test from the app. That's the whole pattern; it scales linearly until the day you want declarative config, at which point your compose file translates line by line the same way this one did.
Tear Down
make down
container volume delete pgdata
container image delete stack-api docker.io/postgres:17-alpine docker.io/redis:8-alpine
sudo container system dns delete testStop the system service too if you're done with containers for the day: container system stop.
Official References
- How-to guide — volumes, networking, DNS domains, and resource flags in depth
- Command reference — every subcommand, including
container volumeandcontainer system dns - Technical overview — how per-container VMs and vmnet networking fit together
- apple/container releases — changelogs; networking behavior improves release to release
Next steps: if you skipped it, the getting-started tutorial covers installation and single-container workflows; the 1.0 deep dive explains when this tool beats Docker Desktop and when it doesn't; and if your stack outgrows a Makefile, Kubernetes vs Docker Compose is the conversation about what comes next.
We built Podscape to simplify Kubernetes workflows like this — logs, events, and cluster state in one interface, without switching tools.
Struggling with this in production?
We help teams fix these exact issues. Our engineers have deployed these patterns across production environments at scale.