# 0011 · OCI containers, Podman first, three deployment strategies

**Status:** Accepted · 2026-09-22

## Context

CoDNA is self-hosted software. How it is installed is a feature, not an
afterthought — the people evaluating it are infrastructure engineers deciding
whether it is worth their afternoon.

Three constraints shaped this:

1. Companies running Docker Desktop at any scale need a paid license. An
   open-source project should not implicitly require one.
2. Teams already run Kubernetes, or they run a VM, or they want one host with
   Compose. All three are legitimate and none is the "real" way.
3. During development, Docker Desktop on this machine could not reach Docker Hub
   at all: DNS returned AAAA records and the host had no IPv6 default route.
   Podman's VM had no such problem. A stack that only works on one runtime is a
   stack that sometimes does not work.

## Decision

**Target OCI, not a vendor.** The build file is `Containerfile`, which podman,
docker, buildah, and kaniko all consume unchanged. The compose file is
`compose.yaml` following the Compose Specification, so `podman compose` and
`docker compose` both run it. No runtime-specific features are used anywhere.

**Podman is the documented default**, with docker shown alongside everywhere.
`make` takes `CONTAINER=docker` to switch.

**Three supported deployment strategies**, all running the same binary:

| Strategy | Artifacts |
|---|---|
| Compose | `compose.yaml`, with CoDNA itself behind an `app` profile |
| Kubernetes | `deploy/kubernetes/` raw manifests plus kustomization, and `deploy/helm/codna/` |
| VM / bare metal | `deploy/systemd/` unit, sandboxed, plus an env template |

**Migrations are embedded in the binary.** `codna migrate up` runs the schema
from a copy compiled in at build time.

## Consequences

- No deployment ever ships migration files or installs a migration tool, and the
  schema can never disagree with the binary running against it.
- The image is ~15 MB: a static binary on distroless, running as uid 65532 with
  a read-only root filesystem and no shell. There is nothing to exec into.
- Migrations run as a **Job** or `ExecStartPre`, never an initContainer on a
  multi-replica Deployment. goose takes no lock by default, so two replicas
  starting together could apply the same migration twice.
- Liveness and readiness are genuinely different endpoints. `/healthz` checks
  only that the process is up; making it check Postgres would turn a database
  blip into a restart of every pod, converting a recoverable dependency outage
  into an outage of CoDNA as well. `/readyz` checks dependencies so an unhealthy
  pod leaves the Service instead of serving errors.
- **The `local` storage driver pins a deployment to one replica.** Ingested
  originals need a writable volume, and ReadWriteOnce cannot be shared — hence
  `strategy: Recreate`. CoDNA is otherwise replica-safe: the outbox worker and
  scheduler both claim work with `FOR UPDATE SKIP LOCKED`. Scaling out needs
  ReadWriteMany or an object-store driver.
- The IANA timezone database is compiled into the binary (`_ "time/tzdata"`),
  because scheduled automations carry timezones and a distroless or scratch base
  may ship no zoneinfo.
- Three deployment paths are three things to keep working. Accepted: `make
  deploy-check` validates the manifests and the chart.

## Alternatives rejected

**Docker only.** Simpler documentation and the widest familiarity. Rejected on
the licensing question for commercial users, and because it failed outright on
the first machine this project was built on.

**Helm only, no raw manifests.** Fewer artifacts. Rejected because it forces
Helm on people who use kustomize, ArgoCD with plain manifests, or just
`kubectl apply`.

**Shipping Postgres and Typesense in the default Kubernetes install.** Friendlier
first run. Rejected as the default because a single-pod Postgres with no backups
or failover is the wrong place for a company's entire knowledge base; it is
available as an opt-in file for evaluation.

**Distributing only a container image.** Rejected because a VM with systemd is
still how a great many companies run internal tools, and a static Go binary
makes supporting that nearly free.
