DEV Community

Okeoghene Akwerigbe
Okeoghene Akwerigbe

Posted on

SwiftDeploy: Building a Declarative Deployment CLI with Observability and OPA Policy Gates

The idea of SwiftDeploy is really simple: What if I could describe my deployment once, then let a tool generate the infrastructure files for me?

Most beginner DevOps projects teach you to write a docker-compose.yml, configure Nginx, wire up containers, add health checks, and then remember to keep all of those files in sync. That is useful practice, but in real projects it can become messy very quickly.

You change a port in one file and forget another. You switch a service from stable to canary in Docker Compose but forget the value in your notes. You edit Nginx by hand and now nobody knows whether the generated config still matches the intended deployment.

SwiftDeploy is an attempt to solve that problem in a small, understandable way.

It is a Python CLI tool that reads one file, manifest.yaml, then generates the infrastructure files, deploys the stack, checks policies, exposes metrics, and keeps an audit trail.

In this post, I will walk through how SwiftDeploy works and how you can rebuild the same idea yourself.

Repository: https://github.com/AkwerigbeO/swiftdeploy

What SwiftDeploy Does
SwiftDeploy is a small deployment tool with five main responsibilities:

  1. Read a deployment manifest
  2. Generate Docker Compose and Nginx config files
  3. Deploy a FastAPI app behind Nginx
  4. Use OPA policies as safety gates before deploys and promotions
  5. Expose metrics, show status, and generate an audit report

The project structure looks like this:

.
|-- app/
|   |-- main.py
|   `-- requirements.txt
|-- policies/
|   |-- infra.rego
|   `-- canary.rego
|-- templates/
|   |-- docker-compose.yml.tpl
|   `-- nginx.conf.tpl
|-- Dockerfile
|-- manifest.yaml
|-- swiftdeploy
`-- README.md

Enter fullscreen mode Exit fullscreen mode

The important thing is that manifest.yaml is the source of truth. The generated files are not meant to be edited directly.

The Core Idea: One Manifest Controls Everything
Here is an example manifest:

services:
  image: swift-odysia:latest
  port: 3000
  mode: canary
  version: v1
  restart_policy: unless-stopped

nginx:
  image: nginx:latest
  port: 8844
  proxy_timeout: 10s
  contact: you@example.com

network:
  name: swiftdeploy-net
  driver_type: bridge

policy:
  opa_url: http://127.0.0.1:8181
  infra_min_disk_gb: 10
  infra_max_cpu_load: 2.0
  canary_max_error_rate: 0.01
  canary_max_p99_latency_seconds: 0.5
  canary_window_seconds: 30

Enter fullscreen mode Exit fullscreen mode

This file answers the basic deployment questions:

  • What image should run?
  • What port does the app use?
  • Should the app run as stable or canary?
  • What port should Nginx expose?
  • What Docker network should the containers use?
  • What policy limits should be enforced?

Once the manifest exists, SwiftDeploy can generate the rest.

The Design: A Tool That Writes Its Own Infrastructure Files
The first command is:

./swiftdeploy init
Enter fullscreen mode Exit fullscreen mode

On Windows PowerShell, you can run:

python swiftdeploy init

Enter fullscreen mode Exit fullscreen mode

This command reads manifest.yaml, then renders two template files:

templates/docker-compose.yml.tpl -> docker-compose.yml
templates/nginx.conf.tpl         -> nginx.conf

Enter fullscreen mode Exit fullscreen mode

The template contains placeholders like this:

image: {{SERVICE_IMAGE}}
Enter fullscreen mode Exit fullscreen mode

The CLI replaces that with the value given in the manifest:

image: swift-odysia:latest
Enter fullscreen mode Exit fullscreen mode

That is the whole trick. SwiftDeploy is not magically inventing infrastructure. It is using a manifest plus templates to generate consistent config files.

The generated Docker Compose file creates three main containers:

app: the FastAPI service
nginx: the public reverse proxy
opa: the policy engine
The app container does not publish its port directly. It only uses expose, so traffic must go through Nginx.

Nginx is the public entry point. It listens on the port from the manifest, forwards traffic to the app, adds useful headers, and returns JSON error bodies for gateway failures.

OPA runs as a sidecar. The CLI talks to OPA when it needs policy decisions.

The architecture looks like this:

Building the App
The application is a small FastAPI service. It supports two modes:

stable
canary
The mode comes from an environment variable:
MODE=stable
or:

MODE=canary
The app exposes:

GET /
GET /healthz
GET /metrics
POST /chaos
The root endpoint returns a welcome response with the mode, version, and timestamp.

The health endpoint returns:

{
  "status": "ok",
  "uptime": 42,
  "mode": "canary"
}
Enter fullscreen mode Exit fullscreen mode

When the app is running in canary mode, it adds this header to responses:
X-Mode: canary

That makes it easy to confirm which version of the service is responding.

Adding Observability with /metrics
Deploying a service is only half the story. You also need to see what it is doing.

SwiftDeploy exposes a Prometheus-compatible /metrics endpoint. The app tracks:

http_requests_total
http_request_duration_seconds
app_uptime_seconds
app_mode
chaos_active
Enter fullscreen mode Exit fullscreen mode

The request counter uses labels:

method
path
status_code
Enter fullscreen mode Exit fullscreen mode

That means you can see how many requests went to /healthz, how many hit /, and how many returned 500.

The latency histogram lets the CLI estimate P99 latency. That becomes important later when deciding whether a canary is healthy enough to promote.

You can check metrics with:

curl http://localhost:8844/metrics
Enter fullscreen mode Exit fullscreen mode

or on Powershell:

Invoke-WebRequest -UseBasicParsing http://localhost:8844/metrics |
  Select-Object -ExpandProperty Content
Enter fullscreen mode Exit fullscreen mode

The Guardrails (OPA)
A deployment tool should not just run commands blindly. It should ask: β€œIs this safe?”

That is where OPA, Open Policy Agent, comes in.

OPA lets you write policy rules in Rego. Instead of hardcoding all safety checks inside the CLI, SwiftDeploy sends facts to OPA and lets OPA decide whether an action is allowed.

This is an important design choice:

The CLI gathers information. OPA makes the policy decision.

SwiftDeploy has two policy files:

policies/infra.rego
policies/canary.rego
They answer different questions.

Infrastructure Policy
The infrastructure policy answers:

Is the host safe enough for deployment?

It checks:

  • free disk space
  • CPU load
    The policy denies deployment if:

  • disk free is less than 10GB

  • CPU load is greater than 2.0

Those limits come from manifest.yaml, not from the Rego file.

That matters because policy logic and environment configuration are different things. The Rego file defines the rule. The manifest defines the threshold.

A simplified version of the logic is:

package infra

default allow := true

allow := false if {
    input.disk_free < input.min_disk
}

allow := false if {
    input.cpu_load > input.max_cpu
}
Enter fullscreen mode Exit fullscreen mode

When you run:

./swiftdeploy deploy
Enter fullscreen mode Exit fullscreen mode

SwiftDeploy:

Starts OPA

  1. Collects host disk and CPU data
  2. Sends that data to OPA
  3. Blocks the deploy if OPA denies it

Example Failure is
DEPLOY BLOCKED by infra policy:

  • Disk space below minimum threshold FAIL: Deployment aborted due to policy violations.

That is the hard gate. If the environment is unsafe, deploy stops.
**
Canary Safety Policy**
The canary policy answers:

Is the canary healthy enough to promote?

It checks:

  • error rate
  • P99 latency
    The policy denies promotion if:

  • error rate is greater than 1%

  • P99 latency is greater than 500ms
    Before promoting, SwiftDeploy scrapes /metrics, samples a configured window, calculates the error rate and P99 latency, then sends those facts to OPA.

The CLI does not decide whether 1% is good or bad. OPA does.

That keeps the deployment flow flexible. If I want to make the policy stricter later, I can change the policy threshold in the manifest without rewriting the deployment logic.

Why OPA Isolation Matters
OPA is powerful because it answers policy questions. That also means it should not be exposed publicly.

In SwiftDeploy, Nginx is the public ingress. OPA is bound to:

127.0.0.1:8181
The CLI can reach it from the host machine, but public traffic through Nginx cannot reach the OPA API.

That separation matters because users should access the application, not the policy engine.

You can test that OPA is not leaking through Nginx:

Invoke-WebRequest -UseBasicParsing http://localhost:8844/v1/data/infra
Enter fullscreen mode Exit fullscreen mode

That should not return an OPA policy response through the public app port.

Deploying the Stack
To replicate the project locally, start by building the app image:

docker build -t swift-odysia:latest .
Enter fullscreen mode Exit fullscreen mode

Generate infrastructure files:

./swiftdeploy init
Enter fullscreen mode Exit fullscreen mode

Validate the setup:

./swiftdeploy validate
Enter fullscreen mode Exit fullscreen mode

Deploy:

./swiftdeploy deploy
Enter fullscreen mode Exit fullscreen mode

Check health

curl http://localhost:8844/healthz
Enter fullscreen mode Exit fullscreen mode

You should see something like:

{
  "status": "ok",
  "uptime": 10,
  "mode": "canary"
}
Enter fullscreen mode Exit fullscreen mode

The Status View
SwiftDeploy includes a status command:

./swiftdeploy status

Enter fullscreen mode Exit fullscreen mode

For a single snapshot:

./swiftdeploy status --count 1
Enter fullscreen mode Exit fullscreen mode

The status view shows:

  • mode
  • uptime
  • chaos state
  • throughput
  • error rate
  • P99 latency
  • policy compliance

Example:

========================================================================
SWIFTDEPLOY STATUS
========================================================================
Time          : 2026-05-06T17:39:30.794723+00:00
Mode          : canary
Uptime        : 10s
Chaos         : 0 (0=none, 1=slow, 2=error)
Throughput    : 0.00 req/s
Error rate    : 0.00%
P99 latency   : 0.100s

Policy Compliance
- infra: PASS - policy allowed
- canary: PASS - policy allowed
========================================================================
Enter fullscreen mode Exit fullscreen mode

Every status scrape is appended to history.jsonl. That file becomes the raw audit trail.

The Chaos: Injecting Slow Responses
The app has a /chaos endpoint that only works in canary mode.

To inject slow responses:

curl -X POST http://localhost:8844/chaos \
  -H "Content-Type: application/json" \
  -d '{"mode":"slow","duration":2}'
Enter fullscreen mode Exit fullscreen mode

Then generate traffic:

for i in {1..10}; do curl -s http://localhost:8844/ > /dev/null; done
Enter fullscreen mode Exit fullscreen mode

On PowerShell:

1..10 | ForEach-Object {
  Invoke-WebRequest -UseBasicParsing http://localhost:8844/ | Out-Null
}
Enter fullscreen mode Exit fullscreen mode

After that run:

./swiftdeploy status --count 1
Enter fullscreen mode Exit fullscreen mode

The Chaos: Injecting Errors
To inject errors:

curl -X POST http://localhost:8844/chaos \
  -H "Content-Type: application/json" \
  -d '{"mode":"error","rate":0.5}'

Enter fullscreen mode Exit fullscreen mode

Generate Traffic:

for i in {1..20}; do curl -s http://localhost:8844/ > /dev/null; done
Enter fullscreen mode Exit fullscreen mode

On Powershell:
1..20 | ForEach-Object {
try {
Invoke-WebRequest -UseBasicParsing http://localhost:8844/ | Out-Null
} catch {}
}

Now the metrics endpoint records more 500 responses. The status view should show the error rate climbing.

Lessons Learned
The first lesson is that a deployment tool is more than a wrapper around docker compose up.

A good deployment tool needs to know:

  • what should exist
  • whether the environment is safe
  • whether the app is healthy
  • what changed over time The second lesson is that generated files are powerful when there is a clear source of truth. By making manifest.yaml the only file operators need to edit, the system becomes easier to reason about.

The third lesson is that policy belongs in its own layer. The CLI should collect facts, but OPA should make the allow/deny decision. That separation makes the system easier to test and safer to extend.

The fourth lesson is that canary deployments need metrics. A container can be running and still be a bad candidate for promotion. Error rate and latency tell a better story than health checks alone.

Final Thoughts
SwiftDeploy is not a replacement for Kubernetes, Terraform, or a production deployment platform. It is a learning project that shows the core ideas behind those tools in a smaller package:

  • declare desired state
  • generate infrastructure
  • observe runtime behavior
  • enforce safety policies
  • keep an audit trail If you are learning DevOps, this kind of project is a good way to understand how deployment automation, observability, policy, and reliability fit together.

The best part is that the idea is simple enough to rebuild:

  1. Start with a manifest
  2. Add templates
  3. Write a CLI to render them
  4. Add health checks
  5. Add metrics
  6. Add OPA policy gates
  7. Add history and audit reporting
  8. That is SwiftDeploy in one sentence:

A small deployment CLI that turns one manifest into a running, observable, policy-protected stack.

Top comments (0)