Build a Declarative Infrastructure CLI

Build a CLI tool that deploys containers from a single YAML manifest.

Introduction

30 Second Summary

Every time you deploy an application, you probably edit multiple configuration files by hand. Miss one setting and your entire deployment breaks.

In this project, you will build SwiftDeploy, a CLI tool that reads a single YAML manifest and automatically generates all the configuration files needed to deploy a containerized application with a reverse proxy.

What You'll Build

SwiftDeploy is a declarative infrastructure CLI tool that generates production-grade Nginx and Docker Compose configurations from Jinja2 templates, all driven by a single YAML manifest.

By the end of this project, you'll have:

  • A working CLI tool (swiftdeploy) with 5 subcommands that manage the full deployment lifecycle.
  • A containerized API service with stable/canary deployment modes, chaos engineering endpoints, and health monitoring.
  • A complete declarative pipeline where a single manifest.yaml controls all generated Nginx and Docker Compose configurations.
  • Secret Mission: A real-time log viewer that tails and filters nginx access logs with colour-coded status codes.

Are there any prerequisites?

None. This guide explains Python and Docker concepts as you encounter them. You will install all required tools in Step 1.

Set Up Your Environment

Your CLI tool will orchestrate Docker containers and generate config files from templates. Before you can build it, you need the right tools on your machine.

In this step, you'll install Docker Desktop (for running containers), Python 3.12+ (for writing the CLI and API service), and create the project folder structure.

In this step, get ready to:
  • Install and verify Docker Desktop.
  • Install Python 3.12+ with PyYAML and Jinja2.
  • Create the project folder with Git.
Install Docker Desktop

Docker lets you run applications in isolated containers. Think of containers as lightweight virtual machines that package your app with everything it needs to run.

Docker Desktop is the desktop application that provides the Docker engine on your machine. It bundles everything including docker compose, which you'll use to run multiple containers together.

  • Download Docker Desktop from docker.com/get-docker.
  • Run the installer and follow the setup prompts.
  • Launch Docker Desktop after installation completes.

What to expect during installation

Docker Desktop will ask you to accept a license agreement on first launch. On Windows, it may also prompt you to enable WSL 2 (Windows Subsystem for Linux). Accept all defaults and allow the installation to complete.

Docker Desktop must be running (you'll see its icon in your system tray) whenever you use docker commands.

Now open a terminal to verify Docker is working.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Terminal (macOS) or PowerShell (Windows) and press Enter to open it.
  • Check Docker is installed by running this command:
docker --version
  • Check Docker Compose is available by running:
docker compose version

✔️ I see version numbers for both

Docker is ready to go. You should see output like Docker version 27.x.x and Docker Compose version v2.x.x. The exact numbers don't matter as long as both commands return something.

ⓧ Command not found or error

Docker is not installed or Docker Desktop is not running. Follow these steps:

  • Go to docker.com/get-docker and download Docker Desktop for your operating system.
  • Run the installer and follow all prompts.
  • Launch Docker Desktop and wait for the engine to finish starting (you'll see a green indicator in the app).
  • Close and reopen your terminal, then try both commands again.
Install Python and dependencies

Python is the language you'll write both the CLI tool and the API service in. This project requires Python 3.12 or higher.

  • Check your Python version by running this command:
python --version

✔️ I see version 3.12 or higher

Python is ready. You should see something like Python 3.12.x or Python 3.13.x.

ⓧ I see an older version

You have Python installed but it's below 3.12. You need to install a newer version.

  • Go to python.org/downloads and download the latest Python 3.12+ installer for your operating system.
  • Run the installer. On Windows, check the box that says Add python.exe to PATH before clicking Install Now.
  • Close and reopen your terminal after installation.
  • Run python --version again to confirm the new version.

macOS users seeing the old system Python

macOS ships with an older Python by default. After installing Python 3.12+ from python.org, try running python3 --version instead. If python3 works but python does not, use python3 throughout this project wherever you see python.

ⓧ Command not found

Python is not installed on your machine.

  • Go to python.org/downloads and download the latest Python 3.12+ installer for your operating system.
  • Run the installer. On Windows, check the box that says Add python.exe to PATH before clicking Install Now.
  • Close and reopen your terminal after installation.
  • Run python --version to confirm it shows 3.12 or higher.

macOS users

On macOS, the command may be python3 rather than python. If python3 --version works, use python3 throughout this project wherever you see python.

Now install the two Python libraries this project needs.

  • Install PyYAML and Jinja2 by running:
pip install pyyaml jinja2

What are these libraries?

PyYAML reads YAML files and converts them into Python dictionaries. Your CLI will use it to parse the manifest.

Jinja2 is a template engine. It takes files with placeholders like {{ port }} and fills them in with real values. Your CLI will use it to generate Nginx and Docker Compose configs from templates.

pip command not found?

If pip is not recognized, try pip3 install pyyaml jinja2 instead. On some systems, pip is named pip3 to match python3.

help me fix a pip command not found error when installing Python packages

Create the project folder structure

Your project needs a specific folder layout. The CLI tool lives at the root, the API service code goes in app/, and the config templates go in templates/.

First, check that Git is installed. You'll use it to track changes to your project.

  • Verify Git is installed by running:
git --version

✔️ I see a version number

Git is ready. Any version is fine for this project.

ⓧ Command not found

Git is not installed. Follow these steps:

  • On Windows: download the installer from git-scm.com/download/win and run it with all default settings.
  • On macOS: run git --version in Terminal. macOS will prompt you to install the Xcode Command Line Tools which include Git. Click Install.
  • Close and reopen your terminal after installation, then run git --version again.
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir swiftdeploy
cd swiftdeploy
  • Create the two subfolders and initialise a Git repository by running:
mkdir app
mkdir templates
git init
  • Confirm everything exists by running:
ls

You should see app and templates listed in the output. The git init command also printed a message confirming the repository was initialised.

What is this folder structure for?

The app/ folder will hold your Python API service and its Dockerfile. The templates/ folder will hold Jinja2 template files that your CLI reads to generate production configs.

The CLI tool itself (swiftdeploy) and the manifest (manifest.yaml) will live at the root of this folder.

Don't see app and templates?

Make sure you ran cd swiftdeploy before creating the subfolders. If you ran mkdir app from your Desktop, the folders were created in the wrong location.

help me fix my project folder structure for the swiftdeploy project

Your environment is ready. Next up, you'll write the declarative manifest that serves as the single source of truth for your entire deployment.

Write the Declarative Manifest

Your environment is ready. Now it's time to describe what you want your deployment to look like.

In this step, you'll write a single YAML file that acts as the source of truth for your entire system. Your CLI tool will read this file later and use it to generate all the Docker Compose and Nginx configuration automatically.

In this step, get ready to:
  • Define the services, networking, and proxy settings for your deployment in one file.
  • Learn how each field in the manifest maps to real container behavior.
Understand what a manifest is

Right now, if you wanted to deploy a web app with a reverse proxy, you'd need to write a Docker Compose file, an Nginx config file, and manually keep them in sync. A manifest changes that.

What is a manifest?

A manifest is a configuration file that describes your entire system in one place. Instead of manually writing Docker Compose files and Nginx configs by hand, you write one simple YAML file and let your tool generate the rest.

YAML is a human-readable data format that uses indentation (like Python) to show structure. You describe WHAT you want, and your tool figures out HOW to make it happen.

Create manifest.yaml

Your manifest needs three sections: services (what containers to run), nginx (how the reverse proxy should behave), and network (how containers communicate).

  • In VS Code's Explorer sidebar, right-click on the swiftdeploy folder root.
  • Select New File.
  • Name the file manifest.yaml and press Enter.
  • Paste the following content into manifest.yaml:
services:
  app:
    image: swift-deploy-1-node:latest
    port: 3000
    mode: stable
    version: "1.0.0"
    restart: unless-stopped

nginx:
  image: nginx:1.30
  port: 8080
  proxy_timeout: 30
  error_contact: admin@swiftdeploy.local

network:
  name: swiftdeploy-net
  driver_type: bridge

What does this manifest define?

  • services.app describes the Python API container. It names the Docker image, the internal port, the deployment mode, and the restart policy.
  • nginx describes the reverse proxy that sits in front of your app. It specifies the public-facing port and timeout settings.
  • network creates an isolated bridge network so the app and Nginx containers can communicate by name.
  • Save manifest.yaml by pressing Cmd+S (macOS) or Ctrl+S (Windows).
  • Confirm the file exists by running this in your terminal:
ls manifest.yaml

You should see manifest.yaml printed back in the output.

Don't see the file?

  • Make sure your terminal is inside the swiftdeploy folder. Run pwd to check your current directory.
  • Check that the file is named exactly manifest.yaml (not manifest.yml or Manifest.yaml).
  • Confirm you saved the file in VS Code. The filename tab should not show a dot or circle indicating unsaved changes.

Still stuck? Help me find my manifest.yaml file. I created it in VS Code but ls doesn't show it in my terminal.

✔️ Awesome, I've got everything!

Great. Double check you've saved your file before moving on.

ⓧ I'd like to double check the full code

Here is the complete manifest.yaml for comparison:

services:
  app:
    image: swift-deploy-1-node:latest
    port: 3000
    mode: stable
    version: "1.0.0"
    restart: unless-stopped

nginx:
  image: nginx:1.30
  port: 8080
  proxy_timeout: 30
  error_contact: admin@swiftdeploy.local

network:
  name: swiftdeploy-net
  driver_type: bridge
Understand each field in the manifest

Each field in the manifest maps directly to a real piece of infrastructure behavior. Knowing what they do helps you customize the deployment later.

What does each field control?

  • services.app.port tells your Python server which port to listen on internally (inside the container). External traffic never hits this port directly.
  • nginx.port is the port exposed to the outside world. When you visit http://localhost:8080, you hit Nginx on this port.
  • network.name creates an isolated network so your containers can talk to each other by name (e.g., Nginx can reach the app by calling app:3000).
  • mode controls whether the app runs in stable or canary deployment mode. Canary mode lets you test new changes on a small slice of traffic before fully rolling out.

Why one file instead of many?

Professional tools like Terraform, Helm, and Ansible all work this way. You declare your desired state in one place, and the tool generates the implementation files. If something drifts, you regenerate from the manifest and everything is back in sync.

Your manifest is locked in. Next up, you'll build the Python API service that this manifest describes.

Build the API Service

Your manifest describes the full system. Now you need the actual application that runs inside the container.

In this step, you will write a Python web server that responds to HTTP requests and package it into a Docker image. The server reads its configuration from environment variables, which Docker injects at runtime. This means the same code behaves differently depending on what the manifest says.

In this step, get ready to:
  • Write a Python HTTP server with three endpoints (welcome, health check, chaos).
  • Implement canary deployment mode and chaos engineering capabilities.
  • Create a security-hardened Dockerfile to package the server.
Write the Python HTTP server

Python ships with a built-in http.server module that lets you create a web server without installing extra packages. Your server will expose three URL paths (endpoints) and read its settings from environment variables that Docker sets at container startup.

  • Create a new file called main.py inside your app folder.
  • Add the following imports, configuration, and request handler skeleton to app/main.py:
#!/usr/bin/env python3
"""SwiftDeploy API Service - supports stable and canary modes."""

import json
import os
import random
import threading
import time
from http.server import HTTPServer, BaseHTTPRequestHandler
from datetime import datetime, timezone

# Read configuration from environment variables (Docker injects these)
MODE = os.environ.get("MODE", "stable")
APP_VERSION = os.environ.get("APP_VERSION", "1.0.0")
APP_PORT = int(os.environ.get("APP_PORT", "3000"))

# Track when the server started (for uptime calculation)
START_TIME = time.time()

# Shared state for chaos engineering (thread-safe)
chaos_state = {
    "mode": None,
    "duration": 0,
    "rate": 0.0,
    "lock": threading.Lock(),
}


class RequestHandler(BaseHTTPRequestHandler):
    def do_GET(self):
        if self.path == "/":
            self.handle_root()
        elif self.path == "/healthz":
            self.handle_healthz()
        else:
            self.send_json(404, {"error": "Not Found", "path": self.path})

    def do_POST(self):
        if self.path == "/chaos":
            self.handle_chaos()
        else:
            self.send_json(404, {"error": "Not Found", "path": self.path})

What does this code do?

The top section reads three environment variables (MODE, APP_VERSION, APP_PORT) with sensible defaults. Docker will override these at runtime based on your manifest.

The chaos_state dictionary holds shared state for chaos engineering. The threading.Lock() ensures that multiple requests cannot corrupt this state simultaneously.

The RequestHandler class routes incoming requests by URL path. do_GET handles GET requests. do_POST handles POST requests. Each path maps to a dedicated handler method you will write next.

You won't see a change yet. The next chunk adds the handler methods that these routes call.

Seeing a syntax error?

Make sure you saved the file as main.py inside the app folder (not in the project root). Check that the indentation uses spaces, not tabs. Python is sensitive to whitespace.

help me fix a syntax error in my Python HTTP server skeleton

Implement canary mode and chaos engineering

Canary mode is a deployment strategy where you test new code on a small portion of traffic before rolling it out fully. Chaos engineering means intentionally injecting failures to test how resilient your system is.

Your handler methods combine both concepts. When the server runs in canary mode, it enables the chaos endpoint and adds an X-Mode: canary header to every response so you can verify which mode is active.

  • Continue building app/main.py by adding the GET endpoint handlers below the do_POST method (still inside the RequestHandler class):
    def handle_root(self):
        # Check if chaos mode is active (canary only)
        with chaos_state["lock"]:
            if chaos_state["mode"] == "slow" and MODE == "canary":
                time.sleep(chaos_state["duration"])
            if chaos_state["mode"] == "error" and MODE == "canary":
                if random.random() < chaos_state["rate"]:
                    self.send_json(500, {"error": "Chaos-induced error"})
                    return

        body = {
            "message": f"Welcome to SwiftDeploy API ({MODE} mode)",
            "version": APP_VERSION,
            "mode": MODE,
            "timestamp": datetime.now(timezone.utc).isoformat(),
        }
        self.send_json(200, body)

    def handle_healthz(self):
        # Health check also respects chaos error mode
        with chaos_state["lock"]:
            if chaos_state["mode"] == "error" and MODE == "canary":
                if random.random() < chaos_state["rate"]:
                    self.send_json(500, {"error": "Chaos-induced error"})
                    return

        uptime = round(time.time() - START_TIME, 2)
        body = {
            "status": "healthy",
            "mode": MODE,
            "version": APP_VERSION,
            "uptime_seconds": uptime,
        }
        self.send_json(200, body)

What does this code do?

Both handlers check chaos_state before responding. If chaos mode is slow, the root endpoint sleeps for the configured duration (simulating network latency). If chaos mode is error, both endpoints randomly return 500 errors at the configured rate.

These chaos effects ONLY activate in canary mode. In stable mode, the chaos state is ignored entirely. This is a safety net so chaos experiments cannot affect production traffic.

The send_json helper and handle_chaos method come next.

  • Add the chaos endpoint handler, the JSON response helper, the logging override, and the server startup block below handle_healthz (still inside the class, except the final if __name__ block which is at module level):
    def handle_chaos(self):
        # Only available in canary mode
        if MODE != "canary":
            self.send_json(404, {"error": "Chaos endpoint only available in canary mode"})
            return

        content_length = int(self.headers.get("Content-Length", 0))
        if content_length == 0:
            self.send_json(400, {"error": "Request body required"})
            return

        raw_body = self.rfile.read(content_length)
        try:
            data = json.loads(raw_body)
        except json.JSONDecodeError:
            self.send_json(400, {"error": "Invalid JSON"})
            return

        chaos_mode = data.get("mode")
        with chaos_state["lock"]:
            if chaos_mode == "slow":
                chaos_state["mode"] = "slow"
                chaos_state["duration"] = data.get("duration", 5)
                self.send_json(200, {"status": "chaos enabled", "mode": "slow", "duration": chaos_state["duration"]})
            elif chaos_mode == "error":
                chaos_state["mode"] = "error"
                chaos_state["rate"] = data.get("rate", 0.5)
                self.send_json(200, {"status": "chaos enabled", "mode": "error", "rate": chaos_state["rate"]})
            elif chaos_mode == "recover":
                chaos_state["mode"] = None
                chaos_state["duration"] = 0
                chaos_state["rate"] = 0.0
                self.send_json(200, {"status": "recovered", "mode": "normal"})
            else:
                self.send_json(400, {"error": f"Unknown chaos mode: {chaos_mode}"})

    def send_json(self, status_code, body):
        # Serialize response as JSON
        response = json.dumps(body, indent=2).encode("utf-8")
        self.send_response(status_code)
        self.send_header("Content-Type", "application/json")
        self.send_header("Content-Length", str(len(response)))
        # Tag canary responses so you can identify them
        if MODE == "canary":
            self.send_header("X-Mode", "canary")
        self.end_headers()
        self.wfile.write(response)

    def log_message(self, format, *args):
        print(f"[{datetime.now(timezone.utc).isoformat()}] {args[0]}")


if __name__ == "__main__":
    server = HTTPServer(("0.0.0.0", APP_PORT), RequestHandler)
    print(f"SwiftDeploy API running on port {APP_PORT} in {MODE} mode (v{APP_VERSION})")
    try:
        server.serve_forever()
    except KeyboardInterrupt:
        server.shutdown()
        print("Server stopped.")

What does this code do?

The handle_chaos method accepts three commands via POST: slow (delays responses by a duration you specify), error (randomly returns 500 errors at a rate you specify), and recover (resets everything to normal). It only works when the server is in canary mode.

The send_json helper converts a Python dictionary to a JSON response and sets the correct HTTP headers. When running in canary mode, it attaches an X-Mode: canary header so you can verify the deployment mode from outside.

The if __name__ == "__main__" block starts the server. It listens on all network interfaces (0.0.0.0) so Docker can route traffic to it.

  • Save app/main.py.
  • Test the server locally by running this command from your swiftdeploy folder:
python app/main.py

You should see the message SwiftDeploy API running on port 3000 in stable mode (v1.0.0) printed in your terminal.

  • Open a second terminal window and test the root endpoint by running:
curl http://localhost:3000/

You should see a JSON response with a welcome message, version, mode, and timestamp.

  • Go back to your first terminal and press Ctrl+C to stop the server.

Server not starting or curl failing?

If you see Address already in use, another process is using port 3000. Close any other servers running on that port.

If curl shows Connection refused, make sure you ran python app/main.py from the swiftdeploy root folder (not from inside app/).

my Python HTTP server won't start or curl is returning Connection refused

✔️ Awesome, I've got everything!

Great. Double check you have saved app/main.py and that the server ran successfully before you stopped it.

ⓧ I'd like to double check the full code

#!/usr/bin/env python3
"""SwiftDeploy API Service - supports stable and canary modes."""

import json
import os
import random
import threading
import time
from http.server import HTTPServer, BaseHTTPRequestHandler
from datetime import datetime, timezone

MODE = os.environ.get("MODE", "stable")
APP_VERSION = os.environ.get("APP_VERSION", "1.0.0")
APP_PORT = int(os.environ.get("APP_PORT", "3000"))

START_TIME = time.time()

chaos_state = {
    "mode": None,
    "duration": 0,
    "rate": 0.0,
    "lock": threading.Lock(),
}


class RequestHandler(BaseHTTPRequestHandler):
    def do_GET(self):
        if self.path == "/":
            self.handle_root()
        elif self.path == "/healthz":
            self.handle_healthz()
        else:
            self.send_json(404, {"error": "Not Found", "path": self.path})

    def do_POST(self):
        if self.path == "/chaos":
            self.handle_chaos()
        else:
            self.send_json(404, {"error": "Not Found", "path": self.path})

    def handle_root(self):
        with chaos_state["lock"]:
            if chaos_state["mode"] == "slow" and MODE == "canary":
                time.sleep(chaos_state["duration"])
            if chaos_state["mode"] == "error" and MODE == "canary":
                if random.random() < chaos_state["rate"]:
                    self.send_json(500, {"error": "Chaos-induced error"})
                    return

        body = {
            "message": f"Welcome to SwiftDeploy API ({MODE} mode)",
            "version": APP_VERSION,
            "mode": MODE,
            "timestamp": datetime.now(timezone.utc).isoformat(),
        }
        self.send_json(200, body)

    def handle_healthz(self):
        with chaos_state["lock"]:
            if chaos_state["mode"] == "error" and MODE == "canary":
                if random.random() < chaos_state["rate"]:
                    self.send_json(500, {"error": "Chaos-induced error"})
                    return

        uptime = round(time.time() - START_TIME, 2)
        body = {
            "status": "healthy",
            "mode": MODE,
            "version": APP_VERSION,
            "uptime_seconds": uptime,
        }
        self.send_json(200, body)

    def handle_chaos(self):
        if MODE != "canary":
            self.send_json(404, {"error": "Chaos endpoint only available in canary mode"})
            return

        content_length = int(self.headers.get("Content-Length", 0))
        if content_length == 0:
            self.send_json(400, {"error": "Request body required"})
            return

        raw_body = self.rfile.read(content_length)
        try:
            data = json.loads(raw_body)
        except json.JSONDecodeError:
            self.send_json(400, {"error": "Invalid JSON"})
            return

        chaos_mode = data.get("mode")
        with chaos_state["lock"]:
            if chaos_mode == "slow":
                chaos_state["mode"] = "slow"
                chaos_state["duration"] = data.get("duration", 5)
                self.send_json(200, {"status": "chaos enabled", "mode": "slow", "duration": chaos_state["duration"]})
            elif chaos_mode == "error":
                chaos_state["mode"] = "error"
                chaos_state["rate"] = data.get("rate", 0.5)
                self.send_json(200, {"status": "chaos enabled", "mode": "error", "rate": chaos_state["rate"]})
            elif chaos_mode == "recover":
                chaos_state["mode"] = None
                chaos_state["duration"] = 0
                chaos_state["rate"] = 0.0
                self.send_json(200, {"status": "recovered", "mode": "normal"})
            else:
                self.send_json(400, {"error": f"Unknown chaos mode: {chaos_mode}"})

    def send_json(self, status_code, body):
        response = json.dumps(body, indent=2).encode("utf-8")
        self.send_response(status_code)
        self.send_header("Content-Type", "application/json")
        self.send_header("Content-Length", str(len(response)))
        if MODE == "canary":
            self.send_header("X-Mode", "canary")
        self.end_headers()
        self.wfile.write(response)

    def log_message(self, format, *args):
        print(f"[{datetime.now(timezone.utc).isoformat()}] {args[0]}")


if __name__ == "__main__":
    server = HTTPServer(("0.0.0.0", APP_PORT), RequestHandler)
    print(f"SwiftDeploy API running on port {APP_PORT} in {MODE} mode (v{APP_VERSION})")
    try:
        server.serve_forever()
    except KeyboardInterrupt:
        server.shutdown()
        print("Server stopped.")
Create the Dockerfile

A Dockerfile is a recipe for building a Docker image. An image is a snapshot of your application and all its dependencies, frozen at a point in time. When you run an image, it becomes a container.

Your Dockerfile uses python:3.12-slim as the base (a minimal Linux with Python pre-installed), creates a non-root user for security, and sets up a health check that Docker uses to monitor the container.

  • Create a new file called Dockerfile inside your app folder.
  • Add the following content to app/Dockerfile:
FROM python:3.12-slim AS runtime

# Create a non-root user for security
RUN groupadd -r appuser && useradd --no-log-init -r -g appuser -u 1000 appuser

WORKDIR /app

# Copy application code
COPY main.py .

# Give the non-root user ownership of the app directory
RUN chown -R appuser:appuser /app

# Switch to non-root user
USER appuser

EXPOSE 3000

# Docker uses this to check if the container is healthy
HEALTHCHECK --interval=10s --timeout=3s --start-period=5s --retries=3 \
    CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:3000/healthz')" || exit 1

ENTRYPOINT ["python", "main.py"]

What does this Dockerfile do?

The FROM python:3.12-slim line starts with a minimal Linux image that already has Python installed. The slim variant keeps the image small (under 200MB compared to 900MB+ for the full image).

Creating a non-root user (appuser) means the application cannot modify system files even if an attacker compromises it. This is a security best practice for production containers.

The HEALTHCHECK instruction tells Docker to call your /healthz endpoint every 10 seconds. If it fails 3 times in a row, Docker marks the container as unhealthy. Your CLI will use this later to know when the app is ready.

  • Save app/Dockerfile.
  • Confirm both files exist by running:
ls app/

You should see Dockerfile and main.py listed in the output.

Don't see both files?

Make sure you created Dockerfile inside the app folder, not in the project root. The filename is Dockerfile with a capital D and no file extension.

my Dockerfile isn't showing up in the app folder

Why a non-root user instead of running as root?

By default, containers run as root. If an attacker finds a vulnerability in your app, they get root access inside the container. With a non-root user, the damage they can do is severely limited. Combined with cap_drop: ALL in Docker Compose (which you will add in a later step), this follows the principle of least privilege.

✔️ Awesome, I've got everything!

Great. Double check you have saved app/Dockerfile and confirmed it appears alongside main.py in the app/ folder.

ⓧ I'd like to double check the full code

FROM python:3.12-slim AS runtime

RUN groupadd -r appuser && useradd --no-log-init -r -g appuser -u 1000 appuser

WORKDIR /app

COPY main.py .

RUN chown -R appuser:appuser /app

USER appuser

EXPOSE 3000

HEALTHCHECK --interval=10s --timeout=3s --start-period=5s --retries=3 \
    CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:3000/healthz')" || exit 1

ENTRYPOINT ["python", "main.py"]

Your API service is complete. You have a Python HTTP server with three endpoints, canary mode support, chaos engineering capabilities, and a security-hardened Dockerfile. Next up, you will create the Jinja2 templates that generate your Nginx and Docker Compose configs from the manifest.

Create the Jinja2 Templates

Your API service and Dockerfile are ready. Now you need the configuration files that tell Docker and Nginx how to run and route traffic to your app.

Instead of writing these configs by hand every time, you will create template files with placeholders that your CLI tool fills in automatically from the manifest. This is the core pattern that makes your deployment truly declarative.

In this step, get ready to:
  • Understand how Jinja2 templates bridge your manifest to real config files.
  • Write an Nginx reverse proxy template with custom logging, JSON error pages, and header injection.
  • Write a Docker Compose template with security hardening and health checks.
Understand Jinja2 templates

Jinja2 is a Python library that lets you write template files with placeholder variables like {{ port }}. When your CLI runs, it reads values from the manifest and fills in the placeholders to produce real config files.

This is how your single manifest controls everything. Change a value in manifest.yaml, re-run the CLI, and fresh configs are generated instantly.

How does templating work?

Think of a template as a form letter with blanks. The template file contains the structure (the Nginx config syntax, the Docker Compose format), and the {{ }} placeholders are the blanks.

Your CLI loads the manifest, extracts the values, and hands them to Jinja2. Jinja2 fills in every blank and produces a complete, valid config file.

Write the nginx.conf template

Nginx is a reverse proxy. It sits in front of your app and routes incoming traffic to it. Your template generates a production-grade Nginx config with custom logging, timeouts, header injection, and JSON error pages.

  • Create a new file called nginx.conf.j2 inside your templates/ folder, then paste in the following code:
worker_processes auto;
error_log /var/log/nginx/error.log warn;
pid /tmp/nginx.pid;

events {
    worker_connections 1024;
}

http {
    log_format swiftdeploy '$time_iso8601 | $status | ${request_time}s | $upstream_addr | $request';

    access_log /var/log/nginx/access.log swiftdeploy;

    upstream app_backend {
        server app:{{ service_port }};
    }

    server {
        listen {{ nginx_port }};
        server_name _;

        proxy_connect_timeout {{ proxy_timeout }}s;
        proxy_read_timeout {{ proxy_timeout }}s;
        proxy_send_timeout {{ proxy_timeout }}s;

        proxy_intercept_errors on;

        location / {
            proxy_pass http://app_backend;
            proxy_set_header Host $host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header X-Forwarded-Proto $scheme;

            proxy_pass_header X-Mode;

            add_header X-Deployed-By "swiftdeploy" always;

            error_page 502 = @error_502;
            error_page 503 = @error_503;
            error_page 504 = @error_504;
        }

        location @error_502 {
            default_type application/json;
            return 502 '{"error": "Bad Gateway", "code": 502, "service": "{{ service_name }}", "contact": "{{ error_contact }}"}';
        }

        location @error_503 {
            default_type application/json;
            return 503 '{"error": "Service Unavailable", "code": 503, "service": "{{ service_name }}", "contact": "{{ error_contact }}"}';
        }

        location @error_504 {
            default_type application/json;
            return 504 '{"error": "Gateway Timeout", "code": 504, "service": "{{ service_name }}", "contact": "{{ error_contact }}"}';
        }
    }
}

What does this template do?

  • log_format swiftdeploy defines a custom log format showing timestamp, HTTP status, response time, upstream address, and request details. This makes debugging much easier than Nginx's default format.
  • upstream app_backend tells Nginx where your app lives. Docker Compose lets containers find each other by service name, so app:{{ service_port }} resolves to your Python service.
  • proxy_pass_header X-Mode forwards the mode header from your API so you can verify canary/stable from the outside.
  • add_header X-Deployed-By "swiftdeploy" always stamps every response so you know traffic came through your proxy.
  • The @error_502, @error_503, and @error_504 named locations return JSON error bodies instead of Nginx's default HTML pages. This is essential for API services where clients expect JSON.
  • Save templates/nginx.conf.j2.
  • Confirm the file exists by running:
ls templates/

You should see nginx.conf.j2 listed in the output.

Don't see nginx.conf.j2?

  • Make sure you created the file inside the templates/ folder, not in the project root.
  • Check for typos in the filename. It must be nginx.conf.j2 (not .j2a or .jinja2).
  • help me find where my template file was saved
Write the docker-compose.yml template

Docker Compose lets you define and run multiple containers together. Your template generates a Compose file that orchestrates both the app and Nginx, connected by a shared network.

  • Create a new file called docker-compose.yml.j2 inside your templates/ folder, then paste in the following code:
services:
  app:
    build:
      context: ./app
      dockerfile: Dockerfile
    image: {{ service_image }}
    container_name: swiftdeploy-app
    environment:
      - MODE={{ mode }}
      - APP_VERSION={{ version }}
      - APP_PORT={{ service_port }}
    networks:
      - {{ network_name }}
    volumes:
      - app-logs:/app/logs
    restart: {{ restart_policy }}
    cap_drop:
      - ALL
    healthcheck:
      test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:{{ service_port }}/healthz')"]
      interval: 10s
      timeout: 3s
      start_period: 5s
      retries: 3

  nginx:
    image: {{ nginx_image }}
    container_name: swiftdeploy-nginx
    ports:
      - "{{ nginx_port }}:{{ nginx_port }}"
    volumes:
      - ./nginx.conf:/etc/nginx/nginx.conf:ro
      - nginx-logs:/var/log/nginx
    networks:
      - {{ network_name }}
    depends_on:
      app:
        condition: service_healthy
    restart: {{ restart_policy }}

networks:
  {{ network_name }}:
    driver: {{ network_driver }}

volumes:
  app-logs:
  nginx-logs:

What does this template do?

  • cap_drop: ALL removes every Linux capability from the app container. Combined with the non-root user in your Dockerfile, this follows the principle of least privilege. If the container is compromised, an attacker has almost no system access.
  • The healthcheck pings /healthz every 10 seconds. Docker marks the container as unhealthy if it fails 3 times in a row.
  • depends_on: app: condition: service_healthy means Nginx only starts after the app passes its health check. No more race conditions where Nginx boots before the app is ready.
  • The nginx.conf:/etc/nginx/nginx.conf:ro volume mount injects your generated config into the Nginx container as read-only. Nginx reads it on startup.
  • Named volumes app-logs and nginx-logs persist log data even if containers restart.
  • Save templates/docker-compose.yml.j2.
  • Confirm both template files exist by running:
ls templates/

You should see both nginx.conf.j2 and docker-compose.yml.j2 listed.

Only see one template file?

  • Double-check that both files are inside the templates/ directory, not the project root or app/ folder.
  • Verify the filenames end in .j2 (the standard Jinja2 extension).
  • help me find my missing template file

Why use templates instead of writing configs directly?

If you wrote docker-compose.yml and nginx.conf by hand, you would need to update multiple files every time a port number or mode changed. With templates, you change one value in manifest.yaml and the CLI regenerates everything consistently.

This is the same pattern that professional tools like Terraform, Helm, and Ansible use under the hood.

✔️ Awesome, I've got everything!

Great. Make sure both template files are saved in your templates/ folder.

ⓧ I'd like to double check the full code

Here is the complete templates/nginx.conf.j2 file:

worker_processes auto;
error_log /var/log/nginx/error.log warn;
pid /tmp/nginx.pid;

events {
    worker_connections 1024;
}

http {
    log_format swiftdeploy '$time_iso8601 | $status | ${request_time}s | $upstream_addr | $request';

    access_log /var/log/nginx/access.log swiftdeploy;

    upstream app_backend {
        server app:{{ service_port }};
    }

    server {
        listen {{ nginx_port }};
        server_name _;

        proxy_connect_timeout {{ proxy_timeout }}s;
        proxy_read_timeout {{ proxy_timeout }}s;
        proxy_send_timeout {{ proxy_timeout }}s;

        proxy_intercept_errors on;

        location / {
            proxy_pass http://app_backend;
            proxy_set_header Host $host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header X-Forwarded-Proto $scheme;

            proxy_pass_header X-Mode;

            add_header X-Deployed-By "swiftdeploy" always;

            error_page 502 = @error_502;
            error_page 503 = @error_503;
            error_page 504 = @error_504;
        }

        location @error_502 {
            default_type application/json;
            return 502 '{"error": "Bad Gateway", "code": 502, "service": "{{ service_name }}", "contact": "{{ error_contact }}"}';
        }

        location @error_503 {
            default_type application/json;
            return 503 '{"error": "Service Unavailable", "code": 503, "service": "{{ service_name }}", "contact": "{{ error_contact }}"}';
        }

        location @error_504 {
            default_type application/json;
            return 504 '{"error": "Gateway Timeout", "code": 504, "service": "{{ service_name }}", "contact": "{{ error_contact }}"}';
        }
    }
}

Here is the complete templates/docker-compose.yml.j2 file:

services:
  app:
    build:
      context: ./app
      dockerfile: Dockerfile
    image: {{ service_image }}
    container_name: swiftdeploy-app
    environment:
      - MODE={{ mode }}
      - APP_VERSION={{ version }}
      - APP_PORT={{ service_port }}
    networks:
      - {{ network_name }}
    volumes:
      - app-logs:/app/logs
    restart: {{ restart_policy }}
    cap_drop:
      - ALL
    healthcheck:
      test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:{{ service_port }}/healthz')"]
      interval: 10s
      timeout: 3s
      start_period: 5s
      retries: 3

  nginx:
    image: {{ nginx_image }}
    container_name: swiftdeploy-nginx
    ports:
      - "{{ nginx_port }}:{{ nginx_port }}"
    volumes:
      - ./nginx.conf:/etc/nginx/nginx.conf:ro
      - nginx-logs:/var/log/nginx
    networks:
      - {{ network_name }}
    depends_on:
      app:
        condition: service_healthy
    restart: {{ restart_policy }}

networks:
  {{ network_name }}:
    driver: {{ network_driver }}

volumes:
  app-logs:
  nginx-logs:

Your templates are ready. In the next step, you will build the CLI tool that reads the manifest, loads these templates, and renders them into real config files that Docker and Nginx can use.

Build the CLI - init and validate

You have a manifest, an API service, a Dockerfile, and two Jinja2 templates. Right now they are disconnected files sitting in folders.

In this step, you will build the CLI tool that ties everything together. Your swiftdeploy script reads the manifest, fills in the templates to produce real config files, and runs pre-flight checks before you deploy.

In this step, get ready to:
  • Create the CLI entrypoint with argparse subcommands.
  • Implement the init subcommand to generate configs from the manifest.
  • Implement the validate subcommand with 5 pre-flight checks.
Create the CLI entrypoint with argparse

Python's argparse module lets you define subcommands the same way tools like git use them (git commit, git push). Each subcommand maps to a Python function that does the work.

  • Create a new file called swiftdeploy (no file extension) in the root of your swiftdeploy project folder.
  • Add the following imports and constants to the top of the file:
#!/usr/bin/env python3
"""SwiftDeploy CLI - Declarative infrastructure management tool."""

import argparse
import os
import subprocess
import sys
import time
import socket
import urllib.request
import urllib.error

import yaml
from jinja2 import Environment, FileSystemLoader

# File paths used throughout the CLI
MANIFEST_FILE = "manifest.yaml"
TEMPLATES_DIR = "templates"
NGINX_CONF_OUTPUT = "nginx.conf"
COMPOSE_OUTPUT = "docker-compose.yml"

What does this code do?

The shebang line (#!/usr/bin/env python3) tells your system this file is a Python script.

The imports pull in argparse for CLI subcommands, yaml (PyYAML) for reading the manifest, and jinja2 for rendering templates. The four constants define the file paths your tool reads from and writes to.

  • Below the constants, add the main() function that wires up the CLI subcommands:
def main():
    parser = argparse.ArgumentParser(
        prog="swiftdeploy",
        description="Declarative infrastructure management CLI"
    )
    subparsers = parser.add_subparsers(dest="command", help="Available commands")

    subparsers.add_parser("init", help="Generate configs from manifest")
    subparsers.add_parser("validate", help="Run pre-flight checks")

    args = parser.parse_args()

    if args.command is None:
        parser.print_help()
        sys.exit(1)

    # Map subcommand names to handler functions
    commands = {
        "init": cmd_init,
        "validate": cmd_validate,
    }

    commands[args.command](args)


if __name__ == "__main__":
    main()

What does this code do?

  • argparse.ArgumentParser creates a top-level parser that handles python swiftdeploy --help output.
  • add_subparsers() registers named subcommands. When the learner types python swiftdeploy init, argparse routes to the matching handler function.
  • The commands dictionary maps each subcommand string to its function. You will define cmd_init and cmd_validate in the next substeps.
  • Save swiftdeploy.
  • You will not run the file yet because cmd_init and cmd_validate do not exist. The next substep defines them.
Implement the init subcommand

The init subcommand is the core of your tool. It reads manifest.yaml with PyYAML, loads the Jinja2 templates, fills in values from the manifest, and writes the rendered nginx.conf and docker-compose.yml to disk.

  • In swiftdeploy, add the following three functions between the constants and the main() function:
def load_manifest():
    """Load and return the manifest as a dictionary."""
    with open(MANIFEST_FILE, "r") as f:
        return yaml.safe_load(f)


def render_templates(manifest):
    """Render nginx.conf and docker-compose.yml from templates."""
    env = Environment(loader=FileSystemLoader(TEMPLATES_DIR))

    # Build a context dictionary with all values the templates need
    context = {
        "service_port": manifest["services"]["app"]["port"],
        "service_image": manifest["services"]["app"]["image"],
        "service_name": "swiftdeploy-app",
        "mode": manifest["services"]["app"]["mode"],
        "version": manifest["services"]["app"]["version"],
        "restart_policy": manifest["services"]["app"].get("restart", "unless-stopped"),
        "nginx_port": manifest["nginx"]["port"],
        "nginx_image": manifest["nginx"]["image"],
        "proxy_timeout": manifest["nginx"].get("proxy_timeout", 30),
        "error_contact": manifest["nginx"].get("error_contact", "admin@swiftdeploy.local"),
        "network_name": manifest["network"]["name"],
        "network_driver": manifest["network"]["driver_type"],
    }

    # Render nginx.conf from template
    nginx_template = env.get_template("nginx.conf.j2")
    with open(NGINX_CONF_OUTPUT, "w") as f:
        f.write(nginx_template.render(context))

    # Render docker-compose.yml from template
    compose_template = env.get_template("docker-compose.yml.j2")
    with open(COMPOSE_OUTPUT, "w") as f:
        f.write(compose_template.render(context))

    print("[init] Generated nginx.conf")
    print("[init] Generated docker-compose.yml")


def cmd_init(args):
    """Parse manifest and generate config files."""
    manifest = load_manifest()
    render_templates(manifest)
    print("[init] Done.")

What does this code do?

  • load_manifest() opens manifest.yaml and converts the YAML text into a Python dictionary using yaml.safe_load().
  • render_templates() builds a context dictionary that maps every placeholder in your Jinja2 templates (like {{ service_port }}) to its actual value from the manifest.
  • FileSystemLoader(TEMPLATES_DIR) tells Jinja2 to look in your templates/ folder for .j2 files.
  • cmd_init() is the handler that argparse calls. It loads the manifest, renders both templates to disk, and prints a confirmation.

Why is this powerful?

If someone deletes the generated nginx.conf or docker-compose.yml, running init again regenerates them perfectly from the manifest. The manifest is truly the single source of truth.

  • Save swiftdeploy.
  • Test the init subcommand by running:
python swiftdeploy init
  • You should see three lines printed:
[init] Generated nginx.conf
[init] Generated docker-compose.yml
[init] Done.
  • Confirm the generated files exist by running:
ls
  • You should see nginx.conf and docker-compose.yml alongside your existing files.

Seeing a ModuleNotFoundError?

  • If you see an error about yaml or jinja2 not found, make sure you installed both with pip install pyyaml jinja2 in Step 1.
  • If you see a NameError for cmd_validate, make sure you placed the three new functions ABOVE main() in the file. Python reads top to bottom.

Help me debug my swiftdeploy init command

Implement the validate subcommand

Before deploying, you want to catch problems early. The validate subcommand runs 5 pre-flight checks: Does the manifest exist and parse correctly? Are all required fields present? Does the Docker image exist? Is the nginx port available? Is the generated config syntactically valid?

  • In swiftdeploy, add the cmd_validate function between cmd_init and main():
def cmd_validate(args):
    """Run 5 pre-flight checks."""
    checks_passed = 0
    total_checks = 5

    # Check 1: manifest.yaml exists and is valid YAML
    print("[validate] 1/5 Checking manifest.yaml...", end=" ")
    try:
        manifest = load_manifest()
        print("PASS")
        checks_passed += 1
    except (FileNotFoundError, yaml.YAMLError) as e:
        print(f"FAIL - {e}")
        manifest = None

    # Check 2: All required fields present and non-empty
    print("[validate] 2/5 Checking required fields...", end=" ")
    if manifest:
        required_paths = [
            ("services", "app", "image"),
            ("services", "app", "port"),
            ("nginx", "image"),
            ("nginx", "port"),
            ("network", "name"),
            ("network", "driver_type"),
        ]
        all_present = True
        for path in required_paths:
            obj = manifest
            for key in path:
                if isinstance(obj, dict) and key in obj:
                    obj = obj[key]
                else:
                    all_present = False
                    break
            if obj is None or obj == "":
                all_present = False
        if all_present:
            print("PASS")
            checks_passed += 1
        else:
            print("FAIL - Missing or empty required fields")
    else:
        print("FAIL - Cannot check fields without valid manifest")

    # Check 3: Docker image exists locally
    print("[validate] 3/5 Checking Docker image...", end=" ")
    if manifest:
        image = manifest["services"]["app"]["image"]
        result = subprocess.run(
            ["docker", "image", "inspect", image],
            capture_output=True, text=True
        )
        if result.returncode == 0:
            print(f"PASS ({image})")
            checks_passed += 1
        else:
            print(f"FAIL - Image '{image}' not found locally (will be built on deploy)")
            checks_passed += 1  # Acceptable if image will be built
    else:
        print("FAIL - Cannot check image without valid manifest")

    # Check 4: Nginx port not already bound
    print("[validate] 4/5 Checking port availability...", end=" ")
    if manifest:
        port = manifest["nginx"]["port"]
        sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        try:
            sock.bind(("0.0.0.0", port))
            sock.close()
            print(f"PASS (port {port} available)")
            checks_passed += 1
        except OSError:
            print(f"FAIL - Port {port} is already in use")
    else:
        print("FAIL - Cannot check port without valid manifest")

    # Check 5: Generated nginx.conf is syntactically valid
    print("[validate] 5/5 Checking nginx.conf syntax...", end=" ")
    if os.path.exists(NGINX_CONF_OUTPUT):
        result = subprocess.run(
            ["docker", "run", "--rm",
             "-v", f"{os.path.abspath(NGINX_CONF_OUTPUT)}:/etc/nginx/nginx.conf:ro",
             "nginx:1.30", "nginx", "-t"],
            capture_output=True, text=True
        )
        if result.returncode == 0:
            print("PASS")
            checks_passed += 1
        else:
            print(f"FAIL - {result.stderr.strip()}")
    else:
        print("FAIL - nginx.conf not found (run 'swiftdeploy init' first)")

    print(f"\n[validate] Results: {checks_passed}/{total_checks} checks passed")
    if checks_passed < total_checks:
        sys.exit(1)

What do the 5 checks do?

  • Check 1 tries to load and parse the manifest. If the file is missing or contains invalid YAML, it fails immediately.
  • Check 2 walks through the manifest dictionary to confirm every required field (image, port, network name) exists and is not empty.
  • Check 3 uses docker image inspect to see if the Docker image already exists locally. It passes either way because the deploy step will build it.
  • Check 4 uses Python's socket.bind() to test whether the nginx port (8080) is free. If another program is using it, bind fails.
  • Check 5 spins up a temporary Nginx container with your generated config and runs nginx -t to validate the syntax.
  • Save swiftdeploy.
  • Make sure Docker Desktop is running, then test the validate subcommand by running:
python swiftdeploy validate
  • You should see output showing each check with PASS or FAIL. Most checks should pass. Check 3 (Docker image) may print a note about building on deploy, which is expected since you have not deployed yet.

Seeing FAIL on the nginx syntax check?

  • Make sure you ran python swiftdeploy init first. The validate command needs the generated nginx.conf to exist.
  • If Docker is not running, check 5 will fail because it needs to pull the nginx:1.30 image. Open Docker Desktop and wait for it to finish starting.
  • If port 8080 is in use (check 4 fails), another application is already listening on that port. Close it or change the port value in manifest.yaml under the nginx section.

Help me fix my swiftdeploy validate errors

✔️ Awesome, I've got everything!

Great. Double check you have saved your swiftdeploy file.

ⓧ I'd like to double check the full code

#!/usr/bin/env python3
"""SwiftDeploy CLI - Declarative infrastructure management tool."""

import argparse
import os
import subprocess
import sys
import time
import socket
import urllib.request
import urllib.error

import yaml
from jinja2 import Environment, FileSystemLoader

MANIFEST_FILE = "manifest.yaml"
TEMPLATES_DIR = "templates"
NGINX_CONF_OUTPUT = "nginx.conf"
COMPOSE_OUTPUT = "docker-compose.yml"


def load_manifest():
    """Load and return the manifest as a dictionary."""
    with open(MANIFEST_FILE, "r") as f:
        return yaml.safe_load(f)


def render_templates(manifest):
    """Render nginx.conf and docker-compose.yml from templates."""
    env = Environment(loader=FileSystemLoader(TEMPLATES_DIR))

    context = {
        "service_port": manifest["services"]["app"]["port"],
        "service_image": manifest["services"]["app"]["image"],
        "service_name": "swiftdeploy-app",
        "mode": manifest["services"]["app"]["mode"],
        "version": manifest["services"]["app"]["version"],
        "restart_policy": manifest["services"]["app"].get("restart", "unless-stopped"),
        "nginx_port": manifest["nginx"]["port"],
        "nginx_image": manifest["nginx"]["image"],
        "proxy_timeout": manifest["nginx"].get("proxy_timeout", 30),
        "error_contact": manifest["nginx"].get("error_contact", "admin@swiftdeploy.local"),
        "network_name": manifest["network"]["name"],
        "network_driver": manifest["network"]["driver_type"],
    }

    nginx_template = env.get_template("nginx.conf.j2")
    with open(NGINX_CONF_OUTPUT, "w") as f:
        f.write(nginx_template.render(context))

    compose_template = env.get_template("docker-compose.yml.j2")
    with open(COMPOSE_OUTPUT, "w") as f:
        f.write(compose_template.render(context))

    print("[init] Generated nginx.conf")
    print("[init] Generated docker-compose.yml")


def cmd_init(args):
    """Parse manifest and generate config files."""
    manifest = load_manifest()
    render_templates(manifest)
    print("[init] Done.")


def cmd_validate(args):
    """Run 5 pre-flight checks."""
    checks_passed = 0
    total_checks = 5

    print("[validate] 1/5 Checking manifest.yaml...", end=" ")
    try:
        manifest = load_manifest()
        print("PASS")
        checks_passed += 1
    except (FileNotFoundError, yaml.YAMLError) as e:
        print(f"FAIL - {e}")
        manifest = None

    print("[validate] 2/5 Checking required fields...", end=" ")
    if manifest:
        required_paths = [
            ("services", "app", "image"),
            ("services", "app", "port"),
            ("nginx", "image"),
            ("nginx", "port"),
            ("network", "name"),
            ("network", "driver_type"),
        ]
        all_present = True
        for path in required_paths:
            obj = manifest
            for key in path:
                if isinstance(obj, dict) and key in obj:
                    obj = obj[key]
                else:
                    all_present = False
                    break
            if obj is None or obj == "":
                all_present = False
        if all_present:
            print("PASS")
            checks_passed += 1
        else:
            print("FAIL - Missing or empty required fields")
    else:
        print("FAIL - Cannot check fields without valid manifest")

    print("[validate] 3/5 Checking Docker image...", end=" ")
    if manifest:
        image = manifest["services"]["app"]["image"]
        result = subprocess.run(
            ["docker", "image", "inspect", image],
            capture_output=True, text=True
        )
        if result.returncode == 0:
            print(f"PASS ({image})")
            checks_passed += 1
        else:
            print(f"FAIL - Image '{image}' not found locally (will be built on deploy)")
            checks_passed += 1
    else:
        print("FAIL - Cannot check image without valid manifest")

    print("[validate] 4/5 Checking port availability...", end=" ")
    if manifest:
        port = manifest["nginx"]["port"]
        sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        try:
            sock.bind(("0.0.0.0", port))
            sock.close()
            print(f"PASS (port {port} available)")
            checks_passed += 1
        except OSError:
            print(f"FAIL - Port {port} is already in use")
    else:
        print("FAIL - Cannot check port without valid manifest")

    print("[validate] 5/5 Checking nginx.conf syntax...", end=" ")
    if os.path.exists(NGINX_CONF_OUTPUT):
        result = subprocess.run(
            ["docker", "run", "--rm",
             "-v", f"{os.path.abspath(NGINX_CONF_OUTPUT)}:/etc/nginx/nginx.conf:ro",
             "nginx:1.30", "nginx", "-t"],
            capture_output=True, text=True
        )
        if result.returncode == 0:
            print("PASS")
            checks_passed += 1
        else:
            print(f"FAIL - {result.stderr.strip()}")
    else:
        print("FAIL - nginx.conf not found (run 'swiftdeploy init' first)")

    print(f"\n[validate] Results: {checks_passed}/{total_checks} checks passed")
    if checks_passed < total_checks:
        sys.exit(1)


def main():
    parser = argparse.ArgumentParser(
        prog="swiftdeploy",
        description="Declarative infrastructure management CLI"
    )
    subparsers = parser.add_subparsers(dest="command", help="Available commands")

    subparsers.add_parser("init", help="Generate configs from manifest")
    subparsers.add_parser("validate", help="Run pre-flight checks")

    args = parser.parse_args()

    if args.command is None:
        parser.print_help()
        sys.exit(1)

    commands = {
        "init": cmd_init,
        "validate": cmd_validate,
    }

    commands[args.command](args)


if __name__ == "__main__":
    main()

Your CLI can now generate configs and validate your setup. Next up, you will add the deploy, promote, and teardown subcommands to bring your containers to life and manage them.

Build the CLI - deploy, promote, and teardown

Your CLI can now generate configs and validate the environment. Those are preparation steps. The real power comes from actually managing the container lifecycle.

In this step, you will build the three subcommands that do the heavy lifting: deploying containers, promoting between canary and stable modes, and tearing everything down. Python's subprocess module lets you run terminal commands (like docker compose) directly from your code.

In this step, get ready to:
  • Implement the deploy subcommand that builds images, starts containers, and polls for health.
  • Implement the promote subcommand that switches between canary and stable deployment modes.
  • Implement the teardown subcommand that removes all containers and optionally cleans generated files.
Implement the deploy subcommand

The deploy subcommand is the heart of the CLI. It regenerates configs from the manifest, starts all containers, then continuously checks whether the service is healthy before declaring success.

  • In swiftdeploy, find the cmd_validate function. After it ends (below the sys.exit(1) line at the bottom of that function), add the following cmd_deploy function:
def cmd_deploy(args):
    # Regenerate configs from manifest before deploying
    manifest = load_manifest()
    render_templates(manifest)

    print("[deploy] Building and starting containers...")
    result = subprocess.run(["docker", "compose", "-f", COMPOSE_OUTPUT, "up", "-d", "--build"], capture_output=False)
    if result.returncode != 0:
        print("[deploy] FAIL - docker compose up failed")
        sys.exit(1)

    # Poll the health endpoint every 2 seconds until success or timeout
    nginx_port = manifest["nginx"]["port"]
    healthz_url = f"http://localhost:{nginx_port}/healthz"
    print(f"[deploy] Waiting for health check at {healthz_url}...")

    timeout = 60
    start = time.time()
    while time.time() - start < timeout:
        try:
            req = urllib.request.Request(healthz_url)
            resp = urllib.request.urlopen(req, timeout=5)
            if resp.status == 200:
                print("[deploy] Service is healthy!")
                print(f"[deploy] Stack is live at http://localhost:{nginx_port}")
                return
        except (urllib.error.URLError, ConnectionRefusedError, OSError):
            pass
        time.sleep(2)

    print(f"[deploy] FAIL - Health check timed out after {timeout}s")
    sys.exit(1)

What does this code do?

The deploy function follows a strict sequence: generate fresh configs, start containers, then verify health.

  • render_templates(manifest) ensures the generated files always reflect the latest manifest values before deploying.
  • docker compose up -d --build builds the Docker image from your Dockerfile and starts all containers in the background.
  • The while loop polls /healthz through nginx every 2 seconds. If it gets a 200 response within 60 seconds, the deploy succeeded. Otherwise, it exits with an error.
  • You won't see a change yet. The next two functions complete the CLI, and then you will update main() to wire everything together.
Implement the promote subcommand

Promoting between modes is a real-world deployment pattern. Instead of tearing down the whole stack, you swap the app's mode and restart only the app container. Nginx stays up the entire time, so there is zero downtime.

  • Directly below the cmd_deploy function you just added, add the following cmd_promote function:
def cmd_promote(args):
    new_mode = args.mode
    if new_mode not in ("canary", "stable"):
        print(f"[promote] Invalid mode: {new_mode}. Use 'canary' or 'stable'.")
        sys.exit(1)

    # Update the mode directly in manifest.yaml
    manifest = load_manifest()
    manifest["services"]["app"]["mode"] = new_mode
    with open(MANIFEST_FILE, "w") as f:
        yaml.dump(manifest, f, default_flow_style=False)
    print(f"[promote] Updated manifest mode to '{new_mode}'")

    # Regenerate compose file with new mode value
    render_templates(manifest)
    print("[promote] Regenerated docker-compose.yml")

    # Restart only the app container (nginx stays up)
    print("[promote] Restarting app service...")
    subprocess.run(["docker", "compose", "-f", COMPOSE_OUTPUT, "up", "-d", "--no-deps", "--build", "app"], capture_output=False)

    # Confirm the new mode is active
    nginx_port = manifest["nginx"]["port"]
    healthz_url = f"http://localhost:{nginx_port}/healthz"
    print(f"[promote] Confirming new mode at {healthz_url}...")
    time.sleep(5)
    try:
        req = urllib.request.Request(healthz_url)
        resp = urllib.request.urlopen(req, timeout=10)
        body = resp.read().decode("utf-8")
        print(f"[promote] Response: {body}")
        if new_mode == "canary":
            mode_header = resp.headers.get("X-Mode", "")
            print(f"[promote] X-Mode header: {mode_header}")
        print(f"[promote] Successfully promoted to '{new_mode}'")
    except (urllib.error.URLError, ConnectionRefusedError, OSError) as e:
        print(f"[promote] WARNING - Could not confirm mode: {e}")

What does this code do?

Promote modifies the manifest itself, making the mode change persistent. This keeps the manifest as the single source of truth.

  • yaml.dump(manifest, f, default_flow_style=False) writes the updated Python dictionary back to manifest.yaml as human-readable YAML.
  • The --no-deps flag tells Docker Compose to restart only the app container without touching nginx. This gives you zero-downtime promotion.
  • After restarting, the function confirms the mode switch by checking the /healthz response and the X-Mode header (which only appears in canary mode).

Why this approach for mode switching?

You might wonder why promote edits the manifest file instead of just passing a flag to Docker. By writing the new mode into manifest.yaml, the manifest always reflects reality. If someone runs deploy again later, the stack comes up in the promoted mode automatically.

This is how professional IaC tools work. The config file is never out of sync with the live system.

  • You won't see a change yet. The next chunk adds the final function and wires everything into main().
Implement the teardown subcommand and register all commands

Teardown is the cleanup command. It stops all containers, removes networks and volumes, and optionally deletes the generated config files too.

  • Directly below the cmd_promote function you just added, add the following cmd_teardown function:
def cmd_teardown(args):
    print("[teardown] Stopping and removing containers...")
    subprocess.run(["docker", "compose", "-f", COMPOSE_OUTPUT, "down", "-v"], capture_output=False)

    # Optionally remove generated config files
    if args.clean:
        print("[teardown] Removing generated configs...")
        for f in [NGINX_CONF_OUTPUT, COMPOSE_OUTPUT]:
            if os.path.exists(f):
                os.remove(f)
                print(f"  Deleted {f}")

    print("[teardown] Done.")

What does this code do?

  • docker compose down -v stops and removes all containers, the network, and any named volumes (like nginx-logs and app-logs).
  • The --clean flag controls whether generated files (nginx.conf and docker-compose.yml) are also deleted. Without it, the generated configs stay on disk.

Now you need to register all three new subcommands in main() so argparse knows about them.

  • In the same file, find the existing main() function. It currently looks like this:
def main():
    parser = argparse.ArgumentParser(prog="swiftdeploy", description="Declarative infrastructure management CLI")
    subparsers = parser.add_subparsers(dest="command", help="Available commands")

    subparsers.add_parser("init", help="Generate configs from manifest")
    subparsers.add_parser("validate", help="Run pre-flight checks")

    args = parser.parse_args()

    if args.command is None:
        parser.print_help()
        sys.exit(1)

    commands = {
        "init": cmd_init,
        "validate": cmd_validate,
    }

    commands[args.command](args)


if __name__ == "__main__":
    main()
  • Replace the entire main() function and the if __name__ block with the following updated version:
def main():
    parser = argparse.ArgumentParser(prog="swiftdeploy", description="Declarative infrastructure management CLI")
    subparsers = parser.add_subparsers(dest="command", help="Available commands")

    subparsers.add_parser("init", help="Generate configs from manifest")
    subparsers.add_parser("validate", help="Run pre-flight checks")
    subparsers.add_parser("deploy", help="Build and deploy the stack")

    # promote takes a positional argument: canary or stable
    promote_parser = subparsers.add_parser("promote", help="Switch deployment mode")
    promote_parser.add_argument("mode", choices=["canary", "stable"], help="Target mode")

    # teardown has an optional --clean flag
    teardown_parser = subparsers.add_parser("teardown", help="Remove all resources")
    teardown_parser.add_argument("--clean", action="store_true", help="Also delete generated configs")

    args = parser.parse_args()

    if args.command is None:
        parser.print_help()
        sys.exit(1)

    commands = {
        "init": cmd_init,
        "validate": cmd_validate,
        "deploy": cmd_deploy,
        "promote": cmd_promote,
        "teardown": cmd_teardown,
    }

    commands[args.command](args)


if __name__ == "__main__":
    main()

What changed in main()?

  • Three new subparsers register deploy, promote, and teardown as valid subcommands.
  • promote_parser.add_argument("mode", choices=["canary", "stable"]) makes the mode a required positional argument. Argparse rejects anything other than canary or stable automatically.
  • teardown_parser.add_argument("--clean", action="store_true") creates an optional flag that defaults to False. The learner passes --clean to also delete generated files.
  • The commands dictionary now maps all five subcommand names to their handler functions.
  • Save swiftdeploy.
  • Verify all subcommands are registered by running:
python swiftdeploy --help
  • You should see all five subcommands listed: init, validate, deploy, promote, and teardown.

Not seeing all five subcommands?

  • Make sure you replaced the entire old main() function. If both old and new versions exist, Python uses whichever appears last.
  • Check for indentation errors. Every line inside main() must be indented consistently with 4 spaces.
  • If you see a SyntaxError, check that the previous function (cmd_teardown) has no missing colons or unmatched brackets.

Help me debug why python swiftdeploy --help is not showing all five subcommands

The last piece of your project is a README that documents how to use the tool.

  • Create a file called README.md in your swiftdeploy root folder and paste the following content:
# SwiftDeploy

A declarative infrastructure CLI tool that generates all deployment configs from a single YAML manifest.

## Overview

SwiftDeploy treats `manifest.yaml` as the single source of truth. The CLI generates Nginx and Docker Compose configurations from Jinja2 templates, manages the container lifecycle, and supports canary/stable deployment promotion.

## Quick Start

```bash
# Generate configs from manifest
python swiftdeploy init

# Validate pre-flight checks
python swiftdeploy validate

# Build and deploy
python swiftdeploy deploy

# Promote to canary
python swiftdeploy promote canary

# Revert to stable
python swiftdeploy promote stable

# Tear down everything
python swiftdeploy teardown --clean
```

## Project Structure

```
swiftdeploy/
  manifest.yaml          # Single source of truth
  swiftdeploy            # CLI tool
  app/
    main.py              # API service
    Dockerfile           # Container image definition
  templates/
    nginx.conf.j2        # Nginx config template
    docker-compose.yml.j2 # Compose file template
  README.md
```

## API Endpoints

- `GET /` - Welcome message with mode, version, timestamp
- `GET /healthz` - Health check with status and uptime
- `POST /chaos` - Chaos engineering (canary mode only)

## Chaos Modes

- `{"mode": "slow", "duration": N}` - Sleep N seconds before responding
- `{"mode": "error", "rate": 0.5}` - Return 500 on ~50% of requests
- `{"mode": "recover"}` - Cancel active chaos
  • Save README.md.
  • Confirm the file exists by running:
ls
  • You should see README.md listed alongside swiftdeploy, manifest.yaml, app/, and templates/.

✔️ Awesome, I've got everything!

Double check you have saved both swiftdeploy and README.md.

ⓧ I'd like to double check the full code

Here is the complete swiftdeploy file as it should look at the end of this step:

#!/usr/bin/env python3
"""SwiftDeploy CLI - Declarative infrastructure management tool."""

import argparse
import os
import subprocess
import sys
import time
import socket
import urllib.request
import urllib.error

import yaml
from jinja2 import Environment, FileSystemLoader

MANIFEST_FILE = "manifest.yaml"
TEMPLATES_DIR = "templates"
NGINX_CONF_OUTPUT = "nginx.conf"
COMPOSE_OUTPUT = "docker-compose.yml"


def load_manifest():
    with open(MANIFEST_FILE, "r") as f:
        return yaml.safe_load(f)


def render_templates(manifest):
    env = Environment(loader=FileSystemLoader(TEMPLATES_DIR))
    context = {
        "service_port": manifest["services"]["app"]["port"],
        "service_image": manifest["services"]["app"]["image"],
        "service_name": "swiftdeploy-app",
        "mode": manifest["services"]["app"]["mode"],
        "version": manifest["services"]["app"]["version"],
        "restart_policy": manifest["services"]["app"].get("restart", "unless-stopped"),
        "nginx_port": manifest["nginx"]["port"],
        "nginx_image": manifest["nginx"]["image"],
        "proxy_timeout": manifest["nginx"].get("proxy_timeout", 30),
        "error_contact": manifest["nginx"].get("error_contact", "admin@swiftdeploy.local"),
        "network_name": manifest["network"]["name"],
        "network_driver": manifest["network"]["driver_type"],
    }
    nginx_template = env.get_template("nginx.conf.j2")
    with open(NGINX_CONF_OUTPUT, "w") as f:
        f.write(nginx_template.render(context))
    compose_template = env.get_template("docker-compose.yml.j2")
    with open(COMPOSE_OUTPUT, "w") as f:
        f.write(compose_template.render(context))
    print("[init] Generated nginx.conf")
    print("[init] Generated docker-compose.yml")


def cmd_init(args):
    manifest = load_manifest()
    render_templates(manifest)
    print("[init] Done.")


def cmd_validate(args):
    checks_passed = 0
    total_checks = 5
    print("[validate] 1/5 Checking manifest.yaml...", end=" ")
    try:
        manifest = load_manifest()
        print("PASS")
        checks_passed += 1
    except (FileNotFoundError, yaml.YAMLError) as e:
        print(f"FAIL - {e}")
        manifest = None
    print("[validate] 2/5 Checking required fields...", end=" ")
    if manifest:
        required_paths = [
            ("services", "app", "image"),
            ("services", "app", "port"),
            ("nginx", "image"),
            ("nginx", "port"),
            ("network", "name"),
            ("network", "driver_type"),
        ]
        all_present = True
        for path in required_paths:
            obj = manifest
            for key in path:
                if isinstance(obj, dict) and key in obj:
                    obj = obj[key]
                else:
                    all_present = False
                    break
            if obj is None or obj == "":
                all_present = False
        if all_present:
            print("PASS")
            checks_passed += 1
        else:
            print("FAIL - Missing or empty required fields")
    else:
        print("FAIL - Cannot check fields without valid manifest")
    print("[validate] 3/5 Checking Docker image...", end=" ")
    if manifest:
        image = manifest["services"]["app"]["image"]
        result = subprocess.run(["docker", "image", "inspect", image], capture_output=True, text=True)
        if result.returncode == 0:
            print(f"PASS ({image})")
            checks_passed += 1
        else:
            print(f"FAIL - Image '{image}' not found locally (will be built on deploy)")
            checks_passed += 1
    else:
        print("FAIL - Cannot check image without valid manifest")
    print("[validate] 4/5 Checking port availability...", end=" ")
    if manifest:
        port = manifest["nginx"]["port"]
        sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        try:
            sock.bind(("0.0.0.0", port))
            sock.close()
            print(f"PASS (port {port} available)")
            checks_passed += 1
        except OSError:
            print(f"FAIL - Port {port} is already in use")
    else:
        print("FAIL - Cannot check port without valid manifest")
    print("[validate] 5/5 Checking nginx.conf syntax...", end=" ")
    if os.path.exists(NGINX_CONF_OUTPUT):
        result = subprocess.run(
            ["docker", "run", "--rm", "-v", f"{os.path.abspath(NGINX_CONF_OUTPUT)}:/etc/nginx/nginx.conf:ro", "nginx:1.30", "nginx", "-t"],
            capture_output=True, text=True
        )
        if result.returncode == 0:
            print("PASS")
            checks_passed += 1
        else:
            print(f"FAIL - {result.stderr.strip()}")
    else:
        print("FAIL - nginx.conf not found (run 'swiftdeploy init' first)")
    print(f"\n[validate] Results: {checks_passed}/{total_checks} checks passed")
    if checks_passed < total_checks:
        sys.exit(1)


def cmd_deploy(args):
    manifest = load_manifest()
    render_templates(manifest)
    print("[deploy] Building and starting containers...")
    result = subprocess.run(["docker", "compose", "-f", COMPOSE_OUTPUT, "up", "-d", "--build"], capture_output=False)
    if result.returncode != 0:
        print("[deploy] FAIL - docker compose up failed")
        sys.exit(1)
    nginx_port = manifest["nginx"]["port"]
    healthz_url = f"http://localhost:{nginx_port}/healthz"
    print(f"[deploy] Waiting for health check at {healthz_url}...")
    timeout = 60
    start = time.time()
    while time.time() - start < timeout:
        try:
            req = urllib.request.Request(healthz_url)
            resp = urllib.request.urlopen(req, timeout=5)
            if resp.status == 200:
                print("[deploy] Service is healthy!")
                print(f"[deploy] Stack is live at http://localhost:{nginx_port}")
                return
        except (urllib.error.URLError, ConnectionRefusedError, OSError):
            pass
        time.sleep(2)
    print(f"[deploy] FAIL - Health check timed out after {timeout}s")
    sys.exit(1)


def cmd_promote(args):
    new_mode = args.mode
    if new_mode not in ("canary", "stable"):
        print(f"[promote] Invalid mode: {new_mode}. Use 'canary' or 'stable'.")
        sys.exit(1)
    manifest = load_manifest()
    manifest["services"]["app"]["mode"] = new_mode
    with open(MANIFEST_FILE, "w") as f:
        yaml.dump(manifest, f, default_flow_style=False)
    print(f"[promote] Updated manifest mode to '{new_mode}'")
    render_templates(manifest)
    print("[promote] Regenerated docker-compose.yml")
    print("[promote] Restarting app service...")
    subprocess.run(["docker", "compose", "-f", COMPOSE_OUTPUT, "up", "-d", "--no-deps", "--build", "app"], capture_output=False)
    nginx_port = manifest["nginx"]["port"]
    healthz_url = f"http://localhost:{nginx_port}/healthz"
    print(f"[promote] Confirming new mode at {healthz_url}...")
    time.sleep(5)
    try:
        req = urllib.request.Request(healthz_url)
        resp = urllib.request.urlopen(req, timeout=10)
        body = resp.read().decode("utf-8")
        print(f"[promote] Response: {body}")
        if new_mode == "canary":
            mode_header = resp.headers.get("X-Mode", "")
            print(f"[promote] X-Mode header: {mode_header}")
        print(f"[promote] Successfully promoted to '{new_mode}'")
    except (urllib.error.URLError, ConnectionRefusedError, OSError) as e:
        print(f"[promote] WARNING - Could not confirm mode: {e}")


def cmd_teardown(args):
    print("[teardown] Stopping and removing containers...")
    subprocess.run(["docker", "compose", "-f", COMPOSE_OUTPUT, "down", "-v"], capture_output=False)
    if args.clean:
        print("[teardown] Removing generated configs...")
        for f in [NGINX_CONF_OUTPUT, COMPOSE_OUTPUT]:
            if os.path.exists(f):
                os.remove(f)
                print(f"  Deleted {f}")
    print("[teardown] Done.")


def main():
    parser = argparse.ArgumentParser(prog="swiftdeploy", description="Declarative infrastructure management CLI")
    subparsers = parser.add_subparsers(dest="command", help="Available commands")
    subparsers.add_parser("init", help="Generate configs from manifest")
    subparsers.add_parser("validate", help="Run pre-flight checks")
    subparsers.add_parser("deploy", help="Build and deploy the stack")
    promote_parser = subparsers.add_parser("promote", help="Switch deployment mode")
    promote_parser.add_argument("mode", choices=["canary", "stable"], help="Target mode")
    teardown_parser = subparsers.add_parser("teardown", help="Remove all resources")
    teardown_parser.add_argument("--clean", action="store_true", help="Also delete generated configs")
    args = parser.parse_args()
    if args.command is None:
        parser.print_help()
        sys.exit(1)
    commands = {
        "init": cmd_init,
        "validate": cmd_validate,
        "deploy": cmd_deploy,
        "promote": cmd_promote,
        "teardown": cmd_teardown,
    }
    commands[args.command](args)


if __name__ == "__main__":
    main()

Your CLI is now feature-complete with all five subcommands. Next up, you will run the full deployment lifecycle end to end and verify everything works together.

Test the Full Lifecycle

Your CLI tool is built and every subcommand is ready to go. Now it is time to put the entire system through its paces and prove that everything works end-to-end.

You will run the full deployment lifecycle from validate through deploy, test canary promotion with chaos engineering, and then prove the manifest truly is the single source of truth by tearing everything down and rebuilding from scratch.

In this step, get ready to:
  • Deploy the stack and verify the service responds through Nginx.
  • Test canary mode promotion and chaos engineering.
  • Prove regeneration by tearing down and re-deploying from the manifest alone.
Run the complete workflow

The validate subcommand runs five pre-flight checks to make sure your environment is ready before you deploy.

  • Run the validation checks by running this command:
python swiftdeploy validate

What should I see?

You should see 5 checks printed, each showing PASS. Check 3 (Docker image) will say the image is not found locally but still passes because it will be built during deploy. Check 5 (nginx.conf syntax) may show FAIL because the generated config does not exist yet. That is expected at this point.

  • Deploy the full stack by running:
python swiftdeploy deploy

What does this command do?

The deploy subcommand first regenerates nginx.conf and docker-compose.yml from the manifest. Then it builds your Docker image and starts both containers. Finally, it polls the /healthz endpoint every 2 seconds until it gets a healthy response.

The first build takes 30-60 seconds because Docker needs to download the python:3.12-slim and nginx:1.30 base images.

  • Once you see [deploy] Service is healthy! and [deploy] Stack is live at http://localhost:8080, verify the service responds by running:
curl http://localhost:8080

You should see a JSON response with a welcome message, version 1.0.0, mode stable, and a timestamp.

Getting a strange error from curl?

If you are using Windows PowerShell, curl is an alias for Invoke-WebRequest. Use curl.exe instead (with the .exe extension) to call the real curl program.

If you see a connection refused error, make sure Docker Desktop is running and check that the deploy command finished successfully. Help me debug why curl cannot connect to my swiftdeploy service.

  • Check the Nginx access logs to verify your custom log format is working:
docker compose logs nginx

You should see log lines in the format: timestamp | status code | response time | upstream address | request details.

Why check the log format?

The custom swiftdeploy log format defined in your Nginx template includes response timing and upstream info. This proves your Jinja2 template rendered correctly and Nginx is using your generated config rather than its default.

Test promote and chaos

Canary deployment lets you test changes on a subset of traffic before fully rolling out. Your promote subcommand switches the app between stable and canary modes without restarting Nginx.

  • Promote the app to canary mode by running:
python swiftdeploy promote canary

Wait for the command to finish. It rebuilds the app container with the new MODE=canary environment variable and confirms the switch.

  • Verify the X-Mode: canary header appears in responses by running:
curl -v http://localhost:8080/healthz

Look for X-Mode: canary in the response headers (the lines starting with <). You should also see X-Deployed-By: swiftdeploy which your Nginx config injects on every response.

What is the -v flag?

The -v flag tells curl to show verbose output including request and response headers. Lines starting with > are sent to the server. Lines starting with < are received back from the server.

  • Test chaos engineering by activating slow mode. This adds a 3-second delay to every response:
curl -X POST -H "Content-Type: application/json" -d "{\"mode\": \"slow\", \"duration\": 3}" http://localhost:8080/chaos

Single quotes on macOS or Linux

If you are on macOS or Linux (or Git Bash on Windows), you can use single quotes around the JSON body instead of escaped double quotes:

curl -X POST -H "Content-Type: application/json" -d '{"mode": "slow", "duration": 3}' http://localhost:8080/chaos

You should see a JSON response confirming chaos is enabled with mode slow and duration 3.

  • Verify that responses are now delayed by running:
curl http://localhost:8080

This request should take about 3 seconds to complete instead of returning instantly.

  • Revert back to stable mode by running:
python swiftdeploy promote stable

The app restarts in stable mode. Chaos state is reset because the container is rebuilt fresh.

Promote command hanging or timing out?

Make sure Docker Desktop is still running. The promote command needs to rebuild the app container, which requires the Docker daemon. If it times out confirming the mode, try running curl http://localhost:8080/healthz manually to check if the service is responding.

Help me troubleshoot why my promote command is not working.

Verify regeneration

The ultimate test of a declarative system is regeneration. If you delete all generated files and containers, the manifest alone should be enough to recreate everything perfectly.

  • Tear down the entire stack and delete generated configs by running:
python swiftdeploy teardown --clean

You should see Docker stopping and removing the containers, network, and volumes. The --clean flag also deletes nginx.conf and docker-compose.yml from disk.

  • Confirm the generated files are gone by running:
ls

You should see manifest.yaml, swiftdeploy, app/, templates/, and README.md. No nginx.conf or docker-compose.yml should be listed.

  • Now redeploy from the manifest alone by running:
python swiftdeploy deploy

The deploy subcommand regenerates both config files from the manifest, rebuilds the image, and brings the stack back up. All from that single manifest.yaml file.

  • Confirm the service is back by running:
curl http://localhost:8080

You should see the same JSON welcome message as before, proving the manifest is truly the single source of truth for your deployment.

Why does this matter?

This is exactly how professional infrastructure-as-code tools work. Terraform state files, Helm charts, and Ansible playbooks all follow this pattern. You describe the desired state, and the tool figures out how to get there.

If a generated file gets corrupted or a container crashes, you never manually fix it. You just re-run the tool and it rebuilds everything from the manifest.

Deploy fails after teardown?

If the second deploy fails, check that Docker Desktop is still running and that port 8080 is not held by another process. Sometimes the port takes a few seconds to free up after teardown.

Try waiting 10 seconds and running the deploy command again. If it still fails, run docker ps -a to check for leftover containers.

Help me debug why my re-deploy after teardown is failing.

Your declarative infrastructure tool is fully operational. You have proven that the manifest controls everything and the system can be destroyed and rebuilt at will. Next, try extending your CLI with a real-time log viewer in the Secret Mission.

Secret mission

Add a swiftdeploy logs Subcommand

Your CLI tool can deploy, promote, and tear down containers. But what about watching what happens in real-time? Add a logs subcommand that tails nginx access logs with colour-coded status codes and filtering support.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally using Docker Desktop. No cloud resources or paid services were created, so there are no ongoing costs.

Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

Resources you used:

  • Docker containers (swiftdeploy-app and swiftdeploy-nginx).
  • Docker network (swiftdeploy-net).
  • Docker volumes (app-logs and nginx-logs).
  • Docker image (swift-deploy-1-node:latest).
  • Generated config files (nginx.conf and docker-compose.yml).
  • Local project folder (swiftdeploy/).
  • Python packages (pyyaml and jinja2).

Keep everything running

No action needed. Choose this if you are still actively building or experimenting with the deployment lifecycle.

  • Your Docker containers will continue running in the background, consuming minimal CPU and RAM.
  • The swiftdeploy-net network and volumes remain active.
  • You can interact with your deployment at any time using curl http://localhost:8080.

Pause - I'll come back to this later

Shut down running containers to free up memory, but keep everything intact so you can resume later.

  • Stop all containers by running:
docker compose stop
  • Your containers, network, volumes, and images remain on disk. Only the running processes stop.
  • When you are ready to resume, restart your containers by running:
docker compose start

Delete - I don't want to use this again

Remove all project resources and start fresh. This is permanent.

  • Remove all containers, networks, volumes, and generated config files by running:
python swiftdeploy teardown --clean
  • Remove the Docker image by running:
docker image rm swift-deploy-1-node:latest
  • Uninstall the Python packages by running:
pip uninstall pyyaml jinja2 -y
  • Delete the project folder. On macOS or Linux, run:
cd ~/Desktop
rm -rf swiftdeploy
  • On Windows, run:
cd ~\Desktop
rmdir /s /q swiftdeploy
  • After these steps, nothing from this project remains on your machine except Docker Desktop and Python themselves.

That's a Wrap!

That's a Wrap!

Nice work! You just built a complete infrastructure-as-code CLI tool from scratch.

You've learned how to:

  • Build a declarative YAML manifest as the single source of truth, and generate production-grade Nginx and Docker Compose configs programmatically from Jinja2 templates.
  • Create a Python CLI with 5 subcommands (init, validate, deploy, promote, teardown) managing the full container lifecycle including health checks and pre-flight validation.
  • Implement canary/stable deployment promotion with rolling restarts, and build an API service with chaos engineering support for resilience testing.
  • Secret Mission: Added a real-time log viewer with colour-coded output and status code filtering.

Ready to quiz yourself?