Declare AWS resources in typed, validated CUE templates. Praxis provisions them in dependency order, then keeps checking. Drift is detected and corrected every five minutes, and every change lands in an audit stream. All from a Docker Compose stack.
applyA security group opened "temporarily" in the console, a tag removed by a
script, a queue policy deleted by hand. None of it surfaces until the next time someone
runs plan. The tools that do watch continuously, like Crossplane, require
operating a Kubernetes cluster just to manage cloud resources. Praxis does both halves
without the cluster.
# Declare a bucket in a CUE template
# (typed, validated, with defaults)
resources: archive: {
apiVersion: "praxis.io/v1"
kind: "S3Bucket"
metadata: name: "orders-archive"
spec: {
region: "us-east-1"
versioning: true
tags: team: "payments"
}
}
# See exactly what would change
praxis plan bucket.cue --account prod
# Provision, in dependency order
praxis deploy bucket.cue --account prod \
--key orders --wait
# From now on the bucket is reconciled
# every five minutes. Disable versioning
# in the console and it's re-enabled,
# with a drift event logged.
praxis observe Deployment/orders
terraform init, plan, applyplan into a CI cron to even see driftDrift detection is homework you assign yourself. Correction is always manual.
helm install crossplanekubectl applyReconciliation is included, as long as you keep operating the cluster underneath it.
.env (static keys, an IAM role, or the default SDK chain)just up (one Docker Compose stack)praxis deployDone. Reconciliation and drift correction are already running. No cluster, no state backend, no controllers to babysit.
| Terraform | Crossplane | Praxis | |
|---|---|---|---|
| Execution model | Plan → Apply (manual) | Continuous reconciliation | Continuous reconciliation |
| Runtime requirement | CLI + state backend | Kubernetes cluster | Restate server (single binary) |
| Drift detection | Manual (terraform plan) | Automatic | Automatic |
| Execution guarantee | None (can leave partial state) | At-least-once | Exactly-once (journaled) |
| Crash recovery | Manual intervention | Controller restart | Automatic journal replay |
| Template language | HCL | YAML + Compositions | CUE |
| Extension model | Go providers | Go controllers | Any language, no fork |
Praxis is a thin, opinionated layer on top of Restate, a durable execution engine that runs as a single binary. The CLI submits work to Praxis Core, which evaluates your CUE templates, builds a dependency graph, and dispatches each resource to a driver pack over Restate RPC. Driver containers are stateless. Restate holds all state, journals every step, and replays the journal after a crash.
Each managed resource is its own Restate Virtual Object, keyed by a natural
identifier like S3Bucket/my-bucket. Mutations (Provision, Delete,
Reconcile) run as exclusive handlers, so two concurrent operations on the same
resource are serialized automatically. No distributed locks, no state file, no
optimistic-concurrency conflicts.
The resource's spec, observed state, outputs, and status live in the object's own key-value store. Query the object and you see everything about that resource.
# Every driver implements the same six handlers
Exclusive (single-writer):
Provision(spec) → outputs # create or update
Import(ref) → outputs # adopt existing
Delete() # remove
Reconcile() → result # check drift, fix it
Shared (concurrent-read):
GetStatus() → status
GetOutputs() → outputs # ARN, ID, etc.
After provisioning, each resource schedules its own durable timer. When it fires, the driver reads the actual state from AWS and compares it field by field against the desired spec. Managed resources get corrected; Observed resources only get reported. Either way the next timer is scheduled and an event lands in the audit stream. Durable timers survive crashes: if a driver restarts, Restate re-fires the timer.
Everything below is real, working syntax from the examples directory and the shipped CLI.
Platform teams define validated templates; users fill in variables. Types, regex constraints, and enums are checked at evaluation time, so bad configs fail before they ever reach AWS.
Deploy with --var key=value or a variables file.
// examples/s3/app-buckets.cue
variables: {
name: string & =~"^[a-z][a-z0-9-]{2,40}$"
environment: "dev" | "staging" | "prod"
}
resources: assets: {
apiVersion: "praxis.io/v1"
kind: "S3Bucket"
metadata: name:
"\(variables.name)-\(variables.environment)-assets"
spec: {
region: "us-east-1"
versioning: true
acl: "private"
tags: { app: variables.name, env: variables.environment }
}
}
// app-buckets.vars.json
{ "name": "myapp", "environment": "prod" }
Output expressions wire resources together. The orchestrator builds a DAG from
them and dispatches with maximum parallelism as dependencies complete. Here that means
vpc → webSG → server, with no ordering written anywhere.
// examples/stacks/ec2-web-stack.cue (trimmed)
resources: {
vpc: {
kind: "VPC"
spec: { region: "us-east-1", cidrBlock: "10.0.0.0/16" }
}
webSG: {
kind: "SecurityGroup"
spec: {
vpcId: "${resources.vpc.outputs.vpcId}"
ingressRules: [{
protocol: "tcp"
fromPort: 443
toPort: 443
cidrBlock: "0.0.0.0/0"
}]
}
}
server: {
kind: "EC2Instance"
spec: securityGroupIds: ["${resources.webSG.outputs.groupId}"]
}
}
Per-field diffs for every resource before anything changes. Resources that reference other resources' outputs are resolved at plan time against live driver state, so what you see is what will happen.
$ praxis plan stack.cue --account prod
Praxis will perform the following actions:
# S3Bucket "my-bucket" will be created
+ resource "S3Bucket" "my-bucket" {
+ bucketName = "my-bucket"
+ region = "us-east-1"
+ versioning = true
}
# SecurityGroup "web-sg" will be updated in-place
~ resource "SecurityGroup" "web-sg" {
~ description = "old desc" => "new desc"
- sslPolicy = "ELBSecurityPolicy-2016-08"
}
Plan: 1 to create, 1 to update, 0 to delete, 2 unchanged.
Every resource is re-compared against AWS reality on a five-minute interval, powered by Restate's durable timers. There is no external cron and no polling infrastructure.
Managed resources get drift corrected automatically. Observed resources get drift reported to the event stream but are never touched, which is ideal for adopting infrastructure you're not ready to hand over.
# Template resources are Managed:
# drift is detected and corrected.
praxis deploy stack.cue --account prod --key web --wait
# Imported resources can be Observed instead:
# drift is reported, never modified.
praxis import S3Bucket --id legacy-assets \
--region us-east-1 --observe
Every AWS API call runs inside a Restate journal entry. If a driver crashes mid-provision, replay resumes from the journal. Completed calls are never re-executed, so there are no duplicate resources and no half-applied state.
Transient AWS errors (throttling, network) are retried by Restate automatically; only terminal errors surface.
// internal/drivers/s3/driver.go (trimmed)
// Idempotent create: the existence check is journaled.
exists, err := restate.Run(ctx,
func(rc restate.RunContext) (bool, error) {
headErr := api.HeadBucket(rc, spec.BucketName)
if headErr == nil {
return true, nil
}
if IsNotFound(headErr) {
return false, nil
}
// transient (throttling/network): Restate retries
return false, headErr
})
preventDestroy blocks deletion of resources you can't afford to lose.
ignoreChanges excludes specific fields from drift detection, so external
systems like cost tooling and audit scripts can co-manage them without Praxis fighting
back.
// examples/lifecycle/protected-db.cue
resources: database: {
kind: "RDSInstance"
spec: { engine: "postgres", instanceClass: "db.t3.small" }
lifecycle: preventDestroy: true
}
// examples/lifecycle/external-managed.cue
resources: bucket: {
kind: "S3Bucket"
spec: { region: "us-east-1", versioning: true }
lifecycle: ignoreChanges: [
"tags.CostCenter",
"tags.LastAudit",
"tags.ManagedBy",
]
}
Organizational standards are plain CUE unification. No separate policy engine, no admission webhooks. Constraints apply at template evaluation, and violations stop the deployment before it starts.
// examples/policies/prod-guardrails.cue
// Production resources must be protected from deletion.
resources: [=~"-prod"]: lifecycle: preventDestroy: true
// Production S3 buckets must be private and versioned.
resources: [=~"-prod"]: {
kind: string
if kind == "S3Bucket" {
spec: {
acl: "private"
versioning: true
}
}
}
Reference infrastructure that already exists without managing it. A
data block performs a read-only lookup and injects outputs like VPC IDs,
ARNs, and CIDR blocks into your managed resources. No state stored, no lifecycle
tracked.
Supported today for VPC, Subnet, Security Group, S3 Bucket, IAM Role, and Route 53 Hosted Zone.
// examples/stacks/data-source-vpc.cue
variables: vpcName: string
data: existingVpc: {
kind: "VPC"
region: "us-east-1"
filter: name: variables.vpcName
}
resources: webSG: {
kind: "SecurityGroup"
spec: {
vpcId: "${data.existingVpc.outputs.vpcId}"
ingressRules: [{
protocol: "tcp"
fromPort: 443
toPort: 443
cidrBlock: "0.0.0.0/0"
}]
}
}
Adopt what's already running in your account. Praxis captures the resource's
current state as a baseline, then manages it. With --observe it just
watches and reports drift instead.
praxis import S3Bucket --id my-existing-bucket \
--region us-east-1 --account prod
praxis import SecurityGroup --id sg-0abc123 --region us-east-1
praxis import EBSVolume --id vol-0abc123 --region us-east-1
# Read-only: track and report, never modify
praxis import ElasticIP --id eipalloc-0abc123 \
--region us-east-1 --observe
Every provision, drift detection, correction, and deletion is a CloudEvents v1.0
event. Follow a deployment live with praxis observe, or consume the
NDJSON stream with your own tooling.
Drift gets its own event family: drift.detected,
drift.corrected, and drift.external_delete for resources
deleted behind Praxis's back.
$ praxis observe Deployment/web
[14:30:00] Ready S3Bucket/assets: provisioned successfully
[14:30:05] Ready EC2Instance/server: launched in vpc-0abc123
// One event from the NDJSON stream (trimmed)
{
"type": "dev.praxis.drift.corrected",
"subject": "webSG",
"category": "drift",
"severity": "info",
"resourcekind": "SecurityGroup",
"deployment": "web",
"data": { "message": "ingress rules restored to match spec" }
}
Restate doesn't distinguish built-in from external services. Write a driver in Python, TypeScript, Go, Java, Kotlin, or Rust in your own repository, register it with the same Restate instance, and it participates in DAG orchestration, output expressions, state tracking, and the event stream alongside built-in drivers.
No plugin SDK. No fork. No changes to Praxis.
# docs/EXTENDING.md: a Cloudflare DNS driver (trimmed)
import restate
cf_driver = restate.VirtualObject("CloudflareDNSRecord")
@cf_driver.handler()
async def Provision(ctx, spec):
record = await ctx.run_typed("create DNS record",
api.create_record, zone_id=spec.zone_id,
record={"type": spec.type, "name": spec.name})
return DNSRecordOutputs(record_id=record["id"],
fqdn=record["name"])
The Docker Compose stack brings up Moto (mock AWS), Restate, Praxis Core, and all driver packs, so you can try everything locally without an AWS account.
git clone https://github.com/shirvan/praxis.git
cd praxis
cp .env.example .env
just up # Moto + Restate + Core + drivers
just build-cli # build the praxis CLI
praxis deploy examples/s3/app-buckets.cue --account local \
-f examples/s3/app-buckets.vars.json --key demo --wait
Running for a team? Deploy on Kubernetes with the Helm chart, or point it at Restate Cloud.