Design AWS architectures on a canvas, compile them to Terraform, and run the result on your laptop with IAM enforced.
Odin is a local cloud you draw. Drag S3, RDS, Lambda, EC2 and the rest onto a
canvas and wire them together. Odin compiles that drawing into OpenTofu
configuration and applies it against its own AWS-compatible gateway, which
provisions each resource as a running service: Postgres for RDS, RustFS for S3,
a Lima VM for EC2, a RIE container for Lambda. The permission edges you draw
become policy the gateway checks on every API call, so an unauthorised request
comes back AccessDenied.
The translation is deterministic. The same canvas always produces byte-identical HCL, Terraform reads back into canvas nodes the same way every time, and no model call sits anywhere in that path.
Install · What you can do · Apply · What an edge means · Diagrams · Terminal · AI · How it works · Known limits · Security · Contributing · Where it's going
Draw it, press Apply, watch it come up. Three resources placed on the canvas, applied, and healthy, with a RustFS bucket, a dynalite table and a Postgres container behind those badges. The clip runs at roughly 4×; the apply it records took 103 seconds.
IAM permissions are edges you draw, and the canvas updates live when anything
changes it, including the CLI. This clip authors the edge with odin canvas set,
and the already-open browser converges it with no reload.
The { } button shows the Terraform the canvas compiles to — the same HCL
that Apply runs.
macOS with Homebrew. This installs colima, the docker CLI,
opentofu, uv and lima, starts colima, installs odin, and runs odin doctor:
curl -fsSL https://raw.githubusercontent.com/kessler-frost/odin/main/install.sh | sh
odin start # serves http://localhost:4200 and backgrounds itselfDraw something from the sidebar and press Apply. The Events tab streams the
tofu apply output, badges go healthy, and the { } button shows the
generated Terraform.
To remove everything the installer put on the machine, including the Homebrew packages it added:
curl -fsSL https://raw.githubusercontent.com/kessler-frost/odin/main/uninstall.sh | shManual install
brew install colima docker opentofu uv lima
colima start
git clone https://github.com/kessler-frost/odin && cd odin
uv sync --extra dev
uv run odin doctor # every prerequisite, with a fix line for eachodin doctor reports on colima, docker, opentofu, lima and the claude CLI, and
exits non-zero when something needed is missing, so it works as a CI preflight
too.
Drag any of these onto the canvas, wire them up, and press Apply:
| Kind | What it becomes |
|---|---|
| S3 | A RustFS bucket, addressable with boto3 |
| RDS | A Postgres container, with a live DATABASE_URL on the node |
| SQS / SNS | goaws queues and topics, subscriptions included |
| DynamoDB | A dynalite table |
| ElastiCache | A Redis container |
| Lambda | A container running the AWS Lambda Runtime Interface Emulator |
| ECS | Containers scheduled per task, with placement and rolling updates |
| EC2 | A Lima VM, joined to the environment's Nebula mesh |
| VPC / Subnet / SG | Nebula networks and compiled firewall rules |
| IAM role, ECR, Secrets, SSM, CloudWatch Logs, ALB | Gateway-backed AWS APIs |
| Event triggers | A schedule, a queue, or a bucket write really invokes a Lambda |
Geometry carries meaning. A node drawn inside a VPC or Subnet box belongs to it,
which compiles to vpc_id and subnet_id, and an ECS box drawn inside an EC2 box
runs its tasks in that VM. One node can expand to several Terraform resources: an
ALB becomes a load balancer plus a target group plus a listener, and a Lambda
drawn without a role gets one generated for it.
Environments are independent. --env staging and --env prod keep separate
canvases, containers and state. odin envs lists them, and odin env rm <name>
decommissions one — teardown, then the environment itself.
Apply commits the canvas as the desired state, generates Terraform, runs tofu apply against the gateway, and waits for what it built to answer before
reporting success. A resource that never becomes healthy fails the apply, and the
output names which one and why.
odin apply --env prod
odin world --env prod # one line per resource, with its live facts
odin destroy --env prod # tear the resources down, keep the environment
odin env rm prod # ...and remove the environment itselfodin destroy and odin env rm are different verbs. Destroy keeps the
environment on purpose: its desired state is what makes a retry possible and its
reconciler is what converges the next apply. env rm is the decommission — the
same teardown, then the .odin/prod/ directory, the gateway credentials it
issued, its synthesized control-plane records, its reconciler, and its entry in
odin envs. It is not undoable (odin export --env prod first if you might want
it back), and it exits non-zero, having deleted nothing, if the environment is
not actually gone.
If a container is killed or removed out of band, the next apply rebuilds it and tells you what that cost:
note: desired state applied; rds app-db was re-created because container
odin-rds-prod-app-db is not running (exit 137) (its data survived — the
container is new, the volume holding the database is not)
Each RDS instance keeps its data on a named Docker volume, so a repair replaces the container and not the database. Odin checks that volume before it says so, and says the opposite when it is gone.
An edge from a workload to a resource is usually a permission. Draw one, pick the actions,
and the gateway compiles them into policy it checks on every request. A call with
no matching grant gets AccessDenied.
The edge type comes from what you connected: lambda → dynamodb is an IAM grant
with sensible defaults, sg → ec2 is group membership, ec2 → subnet is
containment.
Eight of the lines you can draw mean two things at once: rds or elasticache
against ecs or lambda, in either direction. The workload needs the producer's
address, and it may also need a grant on it — in AWS both readings are true at the
same time. Odin does not choose between them for you: the picker lists both and
you tick the ones you mean, so one line can carry both. Ticking the connection on
rds → ecs writes DATABASE_URL into the service's environment, resolved to the
real endpoint when the container starts.
Two more lines decide something besides access. A Log Group drawn to a workload becomes the group its output really lands in, rather than a second empty group beside the one the runtime writes to. An ECR repository drawn to an ECS service becomes that service's image, resolved to the registry address odin published. In both cases the permission alone used to be the whole of it, which meant the line looked like it wired the thing it granted access to and did not.
A drawn permission is emitted as a real aws_iam_role_policy on the workload's
role, naming a real ARN — arn:aws:s3:::uploads and arn:aws:s3:::uploads/* for
a bucket grant — so the file means the same thing on Amazon as it does here. The
gateway matches either form: it reports odin's node label for a request and
reduces the ARN back to it, which is what keeps a permission enforced locally and
portable at the same time.
The UI is one client and the CLI is another, both going through the same HTTP API.
odin canvas set architecture.json --env prod # author a canvas from a file
odin translate --env prod # print the Terraform it compiles to
odin import-tf ./terraform-project # read an existing project back in
odin chat "give the worker read access to uploads" --env prod
odin export prod backup.tar.gz # and `odin import` to restoredocs/cli.md has the full surface, the JSON shapes, and the exit codes and output contracts a CI job can depend on.
Two features call a model, and the translation is never one of them.
odin chat turns plain English into canvas edits and applies them directly.
The canvas is where you review them: the change appears in your open tab, and
Cmd-Z undoes it. It never presses Apply, so building from the drawing stays yours.
$ odin chat "give the thumbnailer lambda read access to the uploads bucket"
Granted thumbnailer read access to the uploads bucket.
- draw a iam edge from 'thumbnailer' to 'uploads' granting s3:GetObject, s3:ListBucket
canvas saved (4d18a5091659) — Cmd-Z in the UI undoes it
It works in named operations, so every change is one reviewable line and anything you did not ask for is impossible to reach. It declines to write what odin derives from geometry or from the live world, and it treats renaming as its own explicit act, because a label is the resource's name and a rename destroys and recreates. It sees field names and never their values, which keeps an RDS password out of the prompt.
"What's wrong here?" selects nodes in the UI and asks why they are broken. Odin gathers each node's config, observed phase, crash verdict, recent events and a tail of its logs, then answers in plain English with per-node suspects. It reads state and returns prose, and it can change nothing.
Both are off until you turn them on. The top bar carries an AI OFF switch,
and odin makes no model call of any kind while it reads that way. Flip it and
both features come alive; flip it back and they say so and carry on, with the
debug panel answering with the reason and odin chat reporting
agent unavailable while changing nothing.
Setting ODIN_AI in the environment overrides the switch in both directions,
which is what a CI job or a headless run wants. The switch renders disabled and
names the variable when that happens, so it never looks like a control that does
nothing.
A canvas is the desired state. spec/ stores it as append-only,
content-addressed revisions, agent/hcl.py compiles it to HCL, simulate/ runs
tofu apply against gateway/, and reconcile/ observes what exists and
projects it back as node status.
How an apply works — diagram
+-----------------------------------+
| |
| Canvas | <----+
| | |
+-----------------------------------+ |
| |
compile |
| |
| |
v |
+-----------------------------------+ |
| | |
| Terraform | |
| | |
+-----------------------------------+ |
| |
tofu apply |
| |
| |
v |
+-----------------------------------+ |
| | |
| odin gateway | |
| | |
+-----------------------------------+ |
| |
provision |
| |
| |
v |
+-----------------------------------+ |
| | |
| Postgres, RustFS, goaws, Lima VMs | observe
| |
+-----------------------------------+
Terraform never talks to Amazon. tofu apply is pointed at odin's gateway, which
speaks enough of S3, RDS, SQS, SNS, DynamoDB, Lambda, ECS, EC2, IAM, ECR, Secrets
Manager, SSM, CloudWatch Logs and ELBv2 for the AWS provider to apply against it.
Status flows the other way on its own loop, so a container that dies turns its
node red without anyone pressing anything.
How a permission is enforced — diagram
+-----------------------------------+
| |
| boto3 call from your workload |
| |
+-----------------------------------+
|
|
v
+-----------------------------------+
| |
| verify SigV4 signature |
| |
+-----------------------------------+
|
|
v
+-----------------------------------+
| |
| classify: s3:GetObject on uploads |
| |
+-----------------------------------+
|
|
v
<----------------------------------->
| |
| does the APPLIED IAM allow it? |---------------+
| | |
<-----------------------------------> no
| |
yes |
| |
v v
+-----------------------------------+ +------------------+
| | | |
| run it against the substitute | | 403 AccessDenied |
| | | |
+-----------------------------------+ +------------------+
The gateway holds a per-environment key for each workload, so it knows which node is calling. The edges you drew compile to policy statements, and a call is matched against them by action and resource before it reaches any substitute.
docs/architecture.md is the picture version — a diagram per service showing what really runs underneath, and one for the whole system. GitHub renders those inline; docs/architecture.html is the same thing styled, self-contained, and offline — open it in a browser. docs/internals.md has the architecture in full, and how each claim in this README is verified.
The ones most likely to matter:
- An emitted IAM policy names resources by label, not ARN. It round-trips
through odin perfectly; taken to Amazon each policy needs its
Resourcerewritten as an ARN. - An RDS instance has no snapshots and no backups. Its data survives a
container replacement (a named volume), and
odin destroydeletes it along with the volume — there is nothing to restore from afterwards.odin volumesshows which volumes odin is holding and which belong to no live environment;odin env rm <env>reclaims that env's. - Import is narrower in
--livemode, and a live-imported RDS arrives with odin's default password, because no AWS API returns one. - Lambda dependencies are vendored only. A function can be a whole directory
(
sourceDir), so it can import its own modules and carry whatever you have installed into that directory — but odin never runs a package manager and fetches nothing at apply time. One version, no S3-deployed packages. - Nebula is single-host. The mesh, firewall and per-VM daemons work; a second machine joining the same environment is still to come.
docs/limits.md has the complete list, including every argument odin re-emits with its own value.
Odin has no authentication of its own. The control app binds 127.0.0.1 by
default, and applying a canvas runs what is on it: container images, EC2 user-data
as root, Lambda code. That is what the tool is for, and it means a canvas from
someone else deserves the same caution as a shell script you are about to run.
Canvas secrets — an RDS password, a secret or ssm value — are stored and
used in cleartext across the canvas, Stack revisions, world.json,
events.jsonl, and the generated Terraform and state. Those files are 0600 and
.odin/ is 0700. SECURITY.md has the full model.
Issues and pull requests are welcome. uv run pytest runs the suite, and
uv run pytest -m integration runs the slow tests that need Colima and OpenTofu.
CONTRIBUTING.md has the rest.
NORTHSTAR.md has the direction and ROADMAP.md has what comes next, including closing the known limits above.
Odin stands on OpenTofu, the Terraform AWS provider, RustFS, goaws, dynalite, Nebula, Lima, Colima, FastAPI and React Flow.
Apache License 2.0 — see LICENSE.



