In the previous chapters, we established a solid foundation for container development on Apple Silicon: setting up Colima to leverage Virtualization.framework for a lightweight Linux VM, configuring efficient volume mounts, and building ARM64 OCI images. Now, we’ll integrate these components to run a practical, multi-service application locally.

This chapter focuses on orchestrating a common development pattern: a Python Flask API service interacting with a PostgreSQL database. You will learn to define these services, manage their lifecycle, ensure secure communication, and expose them to your macOS host. By the end, you’ll have a fully functional, containerized local development environment, making it ready for active development, testing, and even demonstrating your application.

Project Overview: A Local Multi-Service Stack

Building real-world applications often involves multiple interconnected services. Managing these services individually can quickly become cumbersome. This chapter tackles that complexity by guiding you through setting up a cohesive local development stack.

Our goal is to run a simple, yet representative, web application locally:

  • A Python Flask API: This service will handle HTTP requests and interact with a database.
  • A PostgreSQL Database: This service will store application data.

We will use podman-compose to define and orchestrate these services, allowing them to run, communicate, and persist data efficiently within our Colima-managed Linux VM. This setup mirrors a production deployment more closely than running services directly on macOS, offering better isolation and reproducibility.

Tech Stack Deep Dive

To achieve our multi-service orchestration, we’re leveraging a specific set of tools and technologies, each chosen for its performance and compatibility with Apple Silicon.

  • Colima (v0.8.x): Our lightweight Linux VM manager, abstracting Apple’s Virtualization.framework. It provides the ARM64 Linux environment where our containers will run. (Checked 2026-06-22)
  • Podman (v5.x): The OCI-compatible container engine running inside the Colima VM. It manages individual containers and networks. (Checked 2026-06-22)
  • podman-compose (v1.2.x): A Python-based tool that provides a docker-compose-like experience for Podman, allowing us to define and manage multi-container applications using a compose.yaml file. (Checked 2026-06-22)
  • Python (v3.12): The programming language for our API service. We’ll use the latest stable version. (Checked 2026-06-22)
  • Flask (v3.0.3): A micro web framework for Python, used to build our API. (Checked 2026-06-22)
  • PostgreSQL (v16): The relational database for our application. We’ll use an official ARM64 Alpine Linux image for efficiency. (Checked 2026-06-22)

This combination ensures that all components run natively on your Apple Silicon Mac’s ARM64 architecture, avoiding performance penalties associated with x86-64 emulation.

Milestones and Build Plan

To build our multi-service local development environment, we’ll follow these incremental steps:

  1. Project Structure: Set up the basic directory layout for our Flask API and compose.yaml.
  2. API Dockerfile: Define how to build our Python Flask application into an ARM64 OCI image.
  3. API Application Code: Write the Flask application, including database connectivity logic.
  4. compose.yaml Definition: Create the orchestration file that describes our API and PostgreSQL services, their networks, volumes, and dependencies.
  5. podman-compose Installation: Install the necessary tool to run our compose.yaml file.
  6. Service Startup: Launch the entire application stack within the Colima VM.
  7. Verification: Test connectivity and functionality of both the API and database services.

Each milestone builds upon the last, allowing you to verify functionality at every stage.

Service Architecture Overview

Our application consists of two main services, api and db, running within the Colima VM. podman-compose handles the internal networking, allowing services to communicate by their names. External access is provided by mapping ports from the VM to the macOS host.

flowchart TD MacOS_Host[macOS Host] --> Colima_VM[Colima VM] subgraph Colima_VM_Services[Colima VM Services] Podman_Network[Podman Network] Podman_Network --> API_Container[API Container] Podman_Network --> DB_Container[PostgreSQL Container] API_Container -->|Connects to DB| DB_Container DB_Container -->|Persists data to| Data_Volume[Database Data Volume] end API_Container -->|Exposes API Port| MacOS_Host MacOS_Host -->|Mounts Project Files| API_Container

Why this architecture matters:

  • Encapsulation: Each service runs in its own isolated container, minimizing dependency conflicts and simplifying upgrades.
  • Service Discovery: podman-compose creates an internal network where services can find each other by their defined names (e.g., api connects to db).
  • Data Persistence: A named volume ensures that our database’s data is not lost when containers are stopped or recreated.
  • Development Workflow: Volume mounting (./api:/app) allows for real-time code changes on your macOS host to reflect instantly in the running container, accelerating the development cycle.
  • Native Performance: By leveraging Colima on Apple Silicon, all containers run on ARM64 Linux, ensuring optimal CPU and memory usage without emulation overhead.

Step-by-Step Implementation

We’ll now create the necessary files and configure our environment to bring this multi-service stack to life.

1. Initialize Project Structure

Begin by creating a new root directory for your project and a subdirectory for the API service.

mkdir my_flask_app
cd my_flask_app
mkdir api

This sets up my_flask_app/api for our Flask application code and my_flask_app/compose.yaml for orchestration.

2. Define the API Dockerfile

This Dockerfile specifies how to build an ARM64 OCI image for our Python Flask API. It’s designed for efficiency and native performance.

Create the file my_flask_app/api/Dockerfile:

# Use a lightweight ARM64 base image for Python 3.12
# As of 2026-06-22, Python 3.12 is the current stable release.
FROM python:3.12-alpine3.19-arm64v8

# Set the working directory inside the container
WORKDIR /app

# Copy the requirements file first to leverage Docker's build cache
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

# Copy the entire application code
COPY . .

# Expose the port the Flask app will listen on
EXPOSE 5000

# Command to run the Flask application when the container starts
CMD ["python", "app.py"]

Explanation of Decisions:

  • FROM python:3.12-alpine3.19-arm64v8: We choose alpine for its small image size, 3.12 for the latest stable Python version, and arm64v8 to ensure native execution on Apple Silicon. This avoids Rosetta 2 emulation.
  • WORKDIR /app: Establishes /app as the base directory for subsequent commands within the container.
  • COPY requirements.txt . and RUN pip install ...: By copying and installing dependencies before the rest of the application code, Docker can cache this layer. If only application code changes, the dependency installation step won’t need to rerun, speeding up subsequent builds.
  • --no-cache-dir: This pip flag prevents pip from storing downloaded packages in a cache directory, which significantly reduces the final image size.
  • COPY . .: Copies all files from the current directory on the host (which is my_flask_app/api) to /app in the container.
  • EXPOSE 5000: Declares that the container listens on port 5000. This is primarily documentation; port mapping happens in compose.yaml.
  • CMD ["python", "app.py"]: Defines the default command to run the Flask application when the container starts.

3. Create API Requirements and Application Code

Next, we define the Python dependencies and the Flask application itself.

Create my_flask_app/api/requirements.txt:

Flask==3.0.3
psycopg2-binary==2.9.9

Explanation:

  • Flask==3.0.3: Specifies the Flask web framework. Version 3.0.3 is stable as of 2026-06-22. Pinning versions ensures reproducible builds.
  • psycopg2-binary==2.9.9: This is a pre-compiled binary distribution of psycopg2, a PostgreSQL adapter for Python. Using the binary version simplifies installation, especially in container environments, by avoiding the need for libpq development headers. Version 2.9.9 is stable as of 2026-06-22.

Create my_flask_app/api/app.py:

import os
import time
import psycopg2
from flask import Flask, jsonify

app = Flask(__name__)

# Database connection details are loaded from environment variables.
# 'db' is the service name defined in compose.yaml for the PostgreSQL container.
DB_HOST = os.getenv('DB_HOST', 'db')
DB_NAME = os.getenv('DB_NAME', 'mydatabase')
DB_USER = os.getenv('DB_USER', 'myuser')
DB_PASSWORD = os.getenv('DB_PASSWORD', 'mypassword')

def get_db_connection():
    """Attempts to establish a PostgreSQL connection with retries."""
    conn = None
    retries = 10 # Increase retries for robustness during startup
    while retries > 0:
        try:
            conn = psycopg2.connect(
                host=DB_HOST,
                database=DB_NAME,
                user=DB_USER,
                password=DB_PASSWORD
            )
            print("Successfully connected to the database.")
            return conn
        except psycopg2.OperationalError as e:
            print(f"Database connection failed: {e}. Retrying in 3 seconds ({retries-1} attempts left)...")
            time.sleep(3)
            retries -= 1
    raise ConnectionError("Could not connect to the database after multiple retries. Exiting.")

@app.route('/')
def hello():
    """Simple health check endpoint."""
    return jsonify({"message": "Hello from Flask API!", "status": "running"})

@app.route('/data')
def get_data():
    """Endpoint to interact with the database (create table, insert, fetch)."""
    conn = None
    cursor = None
    try:
        conn = get_db_connection()
        cursor = conn.cursor()
        
        # Create table if it doesn't exist
        cursor.execute("CREATE TABLE IF NOT EXISTS messages (id SERIAL PRIMARY KEY, text VARCHAR(255));")
        
        # Insert a new message
        insert_text = f"Message from API at {time.strftime('%H:%M:%S')}"
        cursor.execute("INSERT INTO messages (text) VALUES (%s);", (insert_text,))
        conn.commit()
        
        # Fetch the latest message
        cursor.execute("SELECT text FROM messages ORDER BY id DESC LIMIT 1;")
        message = cursor.fetchone()[0]
        return jsonify({"database_message": message, "retrieved_at": time.strftime('%H:%M:%S')})
    except ConnectionError as ce:
        print(f"Application failed to connect to DB: {ce}")
        return jsonify({"error": f"Database connection error: {ce}"}), 500
    except Exception as e:
        print(f"An unexpected error occurred: {e}")
        return jsonify({"error": f"An API error occurred: {e}"}), 500
    finally:
        if cursor:
            cursor.close()
        if conn:
            conn.close()

if __name__ == '__main__':
    # Run Flask app, accessible from any IP (0.0.0.0) on port 5000.
    # debug=True enables auto-reloading on code changes (useful with volume mounts).
    app.run(host='0.0.0.0', port=5000, debug=True)

Explanation of Decisions:

  • Environment Variables: Database credentials and host are loaded from environment variables (os.getenv). This is a best practice for containerized applications, allowing configuration to be externalized from the code.
  • Service Name Resolution: DB_HOST defaults to 'db'. Within a Podman network created by podman-compose, service names (like db for our PostgreSQL container) are automatically resolved to their internal IP addresses, enabling seamless inter-container communication.
  • Database Connection Retry Logic: The get_db_connection function includes a retry loop. This is critical because the API container might start slightly before the database container is fully initialized and ready to accept connections. This pattern makes our application more resilient during startup.
  • API Endpoints:
    • /: A simple endpoint to confirm the API is running.
    • /data: This endpoint demonstrates database interaction: it creates a table (if not exists), inserts a dynamic message, and then retrieves the latest message. This verifies the full stack from API to database.
  • app.run(host='0.0.0.0', port=5000, debug=True): host='0.0.0.0' makes the Flask server accessible from outside the container’s localhost, allowing the port mapping in compose.yaml to work. debug=True enables Flask’s debugger and auto-reloader, which is highly beneficial with volume mounts, as code changes on your macOS host will trigger an automatic restart of the Flask development server inside the container.

4. Create the compose.yaml File

This file is the heart of our orchestration, defining both the db and api services, their configurations, and how they interact.

Create the file my_flask_app/compose.yaml:

# Use a specific version for podman-compose compatibility.
# As of 2026-06-22, '3.9' is a widely supported and robust version for Compose files.
version: '3.9'

services:
  db:
    image: postgres:16-alpine-arm64 # Official ARM64 PostgreSQL 16 image
    environment:
      POSTGRES_DB: mydatabase
      POSTGRES_USER: myuser
      POSTGRES_PASSWORD: mypassword
    volumes:
      - db_data:/var/lib/postgresql/data # Persist DB data using a named volume
    ports:
      - "5432:5432" # Map host port 5432 to container port 5432 for direct access
    healthcheck: # Define a health check for the database
      test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]
      interval: 5s
      timeout: 5s
      retries: 5
      start_period: 10s # Give the DB time to start before checking health
    restart: unless-stopped # Always restart unless explicitly stopped

  api:
    build: ./api # Build the image from the Dockerfile in the 'api' directory
    ports:
      - "5000:5000" # Map host port 5000 to container port 5000 for API access
    volumes:
      - ./api:/app # Mount the local 'api' directory into the container for live code changes
    environment:
      DB_HOST: db # Use the service name for database connectivity
      DB_NAME: mydatabase
      DB_USER: myuser
      DB_PASSWORD: mypassword
    depends_on: # Ensure DB is healthy before starting API
      db:
        condition: service_healthy
    restart: unless-stopped # Always restart unless explicitly stopped

volumes:
  db_data: # Declare the named volume for database persistence

Explanation of Decisions:

  • version: '3.9': Specifies the Compose file format. This version offers a good balance of features and compatibility.
  • db service:
    • image: postgres:16-alpine-arm64: We explicitly use the ARM64 variant of the PostgreSQL 16 image. This is crucial for native performance on Apple Silicon.
    • environment: Sets standard PostgreSQL environment variables for initial setup. These values must match what the API expects.
    • volumes: - db_data:/var/lib/postgresql/data: This creates a named volume called db_data. Named volumes are managed by Podman and persist data independently of container lifecycles. This ensures your database data remains even if you stop and remove the db container, preventing data loss between development sessions.
    • ports: - "5432:5432": Maps port 5432 on your macOS host to port 5432 inside the db container. This allows you to connect to the database directly from your macOS machine using tools like psql or a GUI client.
    • healthcheck: Defines how Podman should determine if the db container is healthy. The pg_isready command checks if the PostgreSQL server is accepting connections. start_period gives the container time to initialize before health checks begin. The API service uses this health status.
    • restart: unless-stopped: Ensures the database container automatically restarts if it crashes or if the Colima VM is restarted, unless you explicitly stop it.
  • api service:
    • build: ./api: Instead of pulling a pre-existing image, podman-compose will build the image for this service using the Dockerfile located in the ./api directory relative to compose.yaml.
    • ports: - "5000:5000": Maps port 5000 on your macOS host to port 5000 inside the api container. This is how you access your Flask API from your browser or curl.
    • volumes: - ./api:/app: This is a bind mount. It maps your local my_flask_app/api directory directly into the /app directory inside the api container. This is invaluable for development: any code changes you save on your macOS machine will instantly be available within the container, and with Flask’s debug mode, the server will often auto-reload.
    • environment: Passes the database connection details to the API container. DB_HOST: db is crucial for internal service discovery.
    • depends_on: db: condition: service_healthy: This ensures that the api container will only start once the db container has reported itself as healthy via its healthcheck. This prevents the API from trying to connect to a database that isn’t ready, reducing startup errors.
    • restart: unless-stopped: Similar to the db service.
  • volumes: This top-level key declares the named volumes used by our services, in this case, db_data.

5. Install podman-compose

If you haven’t already, install podman-compose on your macOS host. This tool is responsible for parsing your compose.yaml and issuing the corresponding podman commands to your Colima VM.

First, ensure Homebrew is up-to-date:

brew update

Then, install podman-compose. As of 2026-06-22, podman-compose version 1.2.x is stable and recommended.

brew install podman-compose

Verification: Confirm podman-compose is installed and accessible:

podman-compose --version

You should see output similar to podman-compose version 1.2.0 (or a later stable version). If you encounter command not found, ensure Homebrew’s binary path (/opt/homebrew/bin) is in your shell’s PATH.

6. Start the Services

Now, with our compose.yaml in place, it’s time to bring our application stack online. Ensure your Colima instance is running before proceeding.

# 🧠 Important: Ensure Colima is running. If not, start it.
colima start

# Navigate to your project's root directory where compose.yaml is located
cd my_flask_app

# Build images and start containers in detached mode
podman-compose up -d

Explanation:

  • colima start: Verifies or starts your Colima VM. podman-compose relies on podman being connected to this VM.
  • cd my_flask_app: It’s essential to run podman-compose from the directory containing your compose.yaml file.
  • podman-compose up -d: This command orchestrates the entire process:
    • It reads compose.yaml.
    • For the api service, it builds the image using the specified Dockerfile.
    • It pulls the postgres:16-alpine-arm64 image if not already present.
    • It creates a dedicated Podman network for the services.
    • It starts the db container, waiting for its healthcheck to pass.
    • It then starts the api container, linking it to the db service.
    • The -d flag runs all containers in “detached” mode, meaning they run in the background, freeing up your terminal.

Testing & Verification

After starting the services, it’s crucial to verify that everything is running correctly and that the services can communicate.

1. Check Container Status

Use podman ps to see the status of your running containers.

podman ps

Expected Output: You should see output similar to this (IDs and names will vary slightly), showing both db and api containers running and healthy:

CONTAINER ID  IMAGE                           COMMAND               CREATED         STATUS                     PORTS                   NAMES
a1b2c3d4e5f6  docker.io/library/postgres:16-alpine-arm64  postgres -c max_co...  About a minute ago  Up About a minute (healthy)  0.0.0.0:5432->5432/tcp  my_flask_app-db-1
f6e5d4c3b2a1  localhost/my_flask_app-api:latest  python app.py         About a minute ago  Up About a minute          0.0.0.0:5000->5000/tcp  my_flask_app-api-1
  • STATUS: For the db container, look for Up ... (healthy). This confirms the PostgreSQL health check passed. The api container should simply show Up ....
  • PORTS: Confirm that ports 5000 (for API) and 5432 (for DB) are mapped correctly from your macOS host.

2. Inspect Container Logs

Check the logs for each service to ensure they started without errors and are performing expected operations.

podman-compose logs db
podman-compose logs api

Expected Output (for db): You should see PostgreSQL startup messages, eventually ending with something like database system is ready to accept connections. Health check messages might also appear.

Expected Output (for api): You should see Flask startup messages, indicating the server is running on http://0.0.0.0:5000/. If the database connection was successful, you should see Successfully connected to the database. and no repeated “Database connection failed” messages.

3. Test the API from macOS Host

Now, use curl from your macOS terminal to hit the API endpoints exposed on localhost.

First, test the root endpoint:

curl http://localhost:5000/

Expected Output:

{"message": "Hello from Flask API!", "status": "running"}

Next, test the endpoint that interacts with the database:

curl http://localhost:5000/data

Expected Output:

{"database_message": "Message from API at HH:MM:SS", "retrieved_at": "HH:MM:SS"}

(The exact time will vary). If you see this, it means your Flask API successfully connected to the PostgreSQL database, created a table, inserted data, and retrieved it. This confirms full stack functionality.

4. Connect to PostgreSQL Directly (Optional)

Since we mapped port 5432, you can also connect to the database directly from your macOS host using a psql client (if installed, e.g., brew install libpq for client tools).

psql -h localhost -p 5432 -U myuser -d mydatabase

Enter mypassword when prompted. Once connected, you can verify the messages table and its content:

SELECT * FROM messages;
\q

This confirms direct host access to the PostgreSQL database running inside Colima.

5. Stop and Clean Up Services

When you’re finished with your development session, you can stop and remove the services.

# Stop and remove containers and networks created by podman-compose
podman-compose down

Explanation of Decisions (podman-compose down):

  • This command gracefully stops the running containers and then removes them, along with any networks created by podman-compose.
  • By default, podman-compose down does not remove named volumes (like db_data). This is a deliberate design choice: it allows you to preserve your database state between development sessions. When you run podman-compose up -d again, the db service will reuse the existing db_data volume, and your data will still be there.

If you want to explicitly remove named volumes (effectively resetting your database and all its data):

# Stop and remove containers, networks, and named volumes
podman-compose down --volumes

⚠️ What can go wrong: Using --volumes will permanently delete all data stored in the db_data volume. Only use this if you intend to start with a fresh database.

Production Considerations

While this chapter focuses on local development, the patterns we’ve used are highly applicable to production environments.

  • Image Optimization: Our Dockerfile already uses an Alpine base and --no-cache-dir for pip to create smaller images. In production, consider multi-stage builds for even smaller, more secure final images by separating build dependencies from runtime dependencies.
  • Secrets Management: Hardcoding POSTGRES_PASSWORD in compose.yaml is acceptable for local development but a major security risk in production. Production systems use dedicated secret management solutions (e.g., Kubernetes Secrets, AWS Secrets Manager, HashiCorp Vault) to inject sensitive credentials securely at runtime.
  • Resource Limits: In production, define CPU and memory limits for your containers to prevent a single service from monopolizing resources and impacting other services. podman-compose supports resources and deploy keys for this, which translate to underlying container engine limits.
  • Centralized Logging: For local development, podman-compose logs is sufficient. In production, aggregate logs from all containers into a centralized logging system (e.g., ELK stack, Splunk, cloud-provider logging services) for monitoring, troubleshooting, and auditing.
  • Robust Health Checks: The healthcheck for PostgreSQL is a good start. For production APIs, implement comprehensive readiness and liveness probes that check internal dependencies (like database connectivity) to ensure your application is truly ready to serve traffic.

Common Issues & Solutions

Even with a well-defined setup, you might encounter issues. Here are common pitfalls and how to address them.

  1. “Cannot connect to the Docker daemon” / podman not working:

    • Issue: podman is not configured to use the Colima instance, or Colima itself is not running.
    • Solution: Ensure colima start has been run. Verify podman is connected to Colima by checking podman system connection list. If colima is not the default, set it with podman system connection default colima.
    • Verification: Run podman ps. It should list containers or show no errors.
  2. Database Connection Failed (API Container Startup):

    • Issue: The API service attempts to connect to PostgreSQL before the database is fully initialized and ready to accept connections.
    • Solution: We’ve addressed this with depends_on: db: condition: service_healthy in compose.yaml and a retry loop in app.py. Ensure these are correctly implemented. Also, check podman-compose logs db to confirm PostgreSQL is starting without errors and its health checks are passing. Increase start_period for the DB healthcheck if needed.
    • Verification: podman-compose logs api should show successful database connection attempts after a few retries, or no retry messages at all if the database starts quickly.
  3. Port Conflicts:

    • Issue: A port mapped in compose.yaml (e.g., 5000 or 5432) is already in use by another application on your macOS host.
    • Solution: Change the host port mapping in your compose.yaml (e.g., "5001:5000" for the API) or identify and stop the conflicting process on your macOS machine using lsof -i :<PORT>.
    • Verification: podman ps should show the correct port mappings without errors. curl to the new host port should work.
  4. podman-compose command not found:

    • Issue: podman-compose was not installed correctly or its executable path is not in your shell’s PATH environment variable.
    • Solution: Re-run brew install podman-compose. If it’s installed but not found, ensure your shell’s configuration (.zshrc, .bashrc) includes Homebrew’s binary path (typically /opt/homebrew/bin on Apple Silicon).
    • Verification: which podman-compose should return a path like /opt/homebrew/bin/podman-compose.
  5. Volume Mounting Permissions:

    • Issue: Inside the container, the application cannot read or write to files within a mounted volume due to permission issues (e.g., the container user doesn’t have access to your macOS user’s files).
    • Solution: This is less common with Colima and podman-compose as they often handle user ID mapping. If encountered, you might need to ensure the user inside the container matches your host user’s ID, or configure permissions more broadly on the host directory (e.g., chmod -R 777 my_flask_app/api for development, but use with caution).
    • Verification: Check podman-compose logs api for any “Permission denied” errors when the application tries to access files in /app.

Summary & Next Step

You’ve successfully established a robust, multi-service local development environment on your Apple Silicon Mac! You now have a working setup where:

  • Your Python Flask API and PostgreSQL database run in isolated, native ARM64 containers within Colima.
  • Services communicate seamlessly over an internal network.
  • Project code changes on your macOS host are instantly reflected in the running API via efficient volume mounts.
  • Database data persists across container restarts, ensuring a consistent development state.
  • You can access and test your application directly from your macOS host.

This milestone provides a powerful and reproducible foundation for developing complex applications, offering performance advantages and a workflow that closely mirrors production deployments.

In the next chapter, we will delve into advanced techniques for testing and debugging services running within this containerized environment, covering strategies to troubleshoot issues effectively and ensure your application behaves as expected.

References


This page is AI-assisted and reviewed. It references official documentation and recognized resources where relevant.