DevOps & Platform

Run a Multi-Service Dev Stack With Apple's container — No Docker Compose Needed

Intermediate40 min to complete11 min readJuly 16, 2026

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.

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
Apple
Containers
macOS
Docker Compose
Postgres
Redis
DevOps

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:

yaml
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:

bash
mkdir container-stack && cd container-stack

Step 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:

bash
sudo container system dns create test

From 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:

bash
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-alpine

One 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:

bash
container ls
ping -c1 db.test

Step 4: Start Redis (Smaller VM This Time)

Redis for a dev stack needs nowhere near a gigabyte:

bash
container run --detach --name cache --memory 512m docker.io/redis:8-alpine

Two 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:

python
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:

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:

bash
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-api

Test it — the API is its own host, so hit it directly on port 8000:

bash
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:

bash
until container exec db pg_isready -U postgres -q; do
  echo "waiting for postgres..."
  sleep 1
done

On 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):

makefile
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 app

The 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:

bash
make down && make up    # rebuild and restart everything
make logs               # follow the app
make psql               # drop into the database

Note 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.test doesn'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 — check container logs api. The pg_isready loop prevents it; if you bypassed the Makefile, wait a second and container 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 with container 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 --memory down to what the service needs, and stop stacks you're not using — idle VMs still hold their allocation. container stats shows 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 a PROJECT variable, or keep one stack per domain (db.myapp, db.other via 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

bash
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 test

Stop the system service too if you're done with containers for the day: container system stop.

Official References

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.