diff --git a/.github/assets/diagrams/ecs-topology.svg b/.github/assets/diagrams/ecs-topology.svg
new file mode 100644
index 000000000..833c2035c
--- /dev/null
+++ b/.github/assets/diagrams/ecs-topology.svg
@@ -0,0 +1,102 @@
+
diff --git a/.github/assets/diagrams/eks-topology.svg b/.github/assets/diagrams/eks-topology.svg
new file mode 100644
index 000000000..4c254be1e
--- /dev/null
+++ b/.github/assets/diagrams/eks-topology.svg
@@ -0,0 +1,96 @@
+
diff --git a/docs/containers/ecs/README.md b/docs/containers/ecs/README.md
new file mode 100644
index 000000000..faf28f3ba
--- /dev/null
+++ b/docs/containers/ecs/README.md
@@ -0,0 +1,270 @@
+---
+title: "ECS (Elastic Container Service)"
+description: "Run AWS-compatible ECS workloads on Spinifex — create a cluster, register a task definition, boot container instances from the ECS node image, run tasks, and front a long-running service with an Application Load Balancer, all via the AWS CLI, the Spinifex console, or Terraform."
+category: "Containers"
+tags:
+ - ecs
+ - containers
+ - tasks
+ - alb
+ - iam
+sections:
+ - overview
+ - prerequisites
+ - instructions
+ - troubleshooting
+resources:
+ - title: "ECS Quickstart Workbook"
+ url: "https://github.com/mulgadc/spinifex/tree/main/docs/terraform-workbooks/ecs-quickstart"
+ - title: "Terraform AWS Provider"
+ url: "https://registry.terraform.io/providers/hashicorp/aws/latest"
+ - title: "AWS ECS Task IAM Roles"
+ url: "https://docs.aws.amazon.com/AmazonECS/latest/developerguide/task-iam-roles.html"
+---
+
+# ECS (Elastic Container Service)
+
+> Spinifex's ECS service gives you an AWS-compatible container orchestrator: you register task definitions and run them — one-off as tasks, or kept-running as a service behind a load balancer — on container instances you launch from the Spinifex ECS node image. Standard tools — `aws ecs`, the Terraform AWS provider — work unchanged.
+
+## Overview
+
+ECS on Spinifex follows the AWS **EC2 launch type**: you supply the compute. A cluster is a logical grouping; the capacity behind it is **container instances** — ordinary EC2 instances booted from Spinifex's `spinifex-ecs-node` image, each running the Spinifex ECS agent. The agent registers the instance with the cluster, reports its CPU/memory, and runs the containers the scheduler places on it.
+
+There is **no ECS-specific launch API** — this is AWS-faithful. You add capacity by launching EC2 instances from the ECS node image (with the `ecsInstanceRole` instance profile), exactly as on AWS. The Spinifex console wraps this in a one-click **Provision capacity** action, but underneath it is just `RunInstances`.
+
+A **task definition** describes one or more containers (image, CPU/memory, ports, environment, and an optional task IAM role). A **task** is a running instantiation of a task definition; the scheduler bin-packs tasks onto instances with free capacity. A **service** keeps a desired number of tasks running, replaces failed ones, and — when configured — registers each task's IP with an Application Load Balancer target group.
+
+
+
+
+
+**What you'll create:**
+
+| Resource | Purpose |
+|---|---|
+| VPC + subnets | The network the cluster and tasks run in |
+| Internet Gateway | Egress so instances can pull container images |
+| `ecsInstanceRole` | Instance profile letting the agent reach the control plane |
+| ECS cluster | The logical grouping tasks and instances join |
+| Task definition | The container spec (image, CPU/memory, ports, task role) |
+| Container instance(s) | EC2 VMs from the ECS node image that run tasks |
+| Service + ALB target group | Keeps N tasks running and load-balances them |
+
+**Spinifex specifics**
+
+- **EC2 launch type only.** There is no Fargate-equivalent serverless capacity; you run and pay for the container instances. `RequiresCompatibilities` of `FARGATE` is not honoured.
+- **`awsvpc` network mode.** Each task gets its own ENI and private IP in your subnet. Target groups must use `target_type = "ip"`; the service's `network_configuration` selects the subnets and security groups.
+- **The container-instance image is fixed.** Instances must boot Spinifex's `spinifex-ecs-node` image (resolve it by the `spinifex:managed-by=ecs` tag) and carry the `ecsInstanceRole` instance profile.
+- **Task IAM roles via the credential endpoint.** A task with a `taskRoleArn` gets `AWS_CONTAINER_CREDENTIALS_RELATIVE_URI` injected; the agent serves short-lived credentials for that role at `169.254.170.2`, AWS-faithful — your container's SDK picks them up with no static keys.
+
+### Limitations
+
+ECS v1 is deliberately minimal. Keep these in mind:
+
+- **No service discovery.** `serviceRegistries` / Cloud Map integration is not implemented — reach a service through its load balancer, not a DNS name.
+- **`awslogs` log driver is discarded.** Container stdout/stderr is not shipped to CloudWatch Logs; the log configuration is accepted but dropped.
+- **No rolling deployments.** The reconciler keeps the desired count running but ignores `deploymentConfiguration` (`minimumHealthyPercent` / `maximumPercent`); an `UpdateService` to a new revision replaces tasks without a health-gated rollout or circuit-breaker.
+- **No capacity providers or managed scaling.** Capacity is the static total of your registered instances; there is no scale-out/in or ASG binding — you manage the instance count.
+- **No distinct execution role.** ECR pulls use the instance role directly; `executionRoleArn` is not honoured separately.
+- **`secrets[]` are silently dropped**, and tags set via `TagResource` are not persisted. Health-check settings beyond the target group default are not forwarded.
+
+## Prerequisites
+
+> [!IMPORTANT]
+> **Prerequisite — `spinifex-ecs-node` image required.**
+>
+> Container instances **must** boot Spinifex's **`spinifex-ecs-node`** image — it carries the ECS agent. Import it before provisioning capacity (during `spx admin init` or via the image catalogue); the console's provision-capacity action and the Terraform workbook both resolve it by tag.
+>
+> **Verify before continuing:**
+>
+> ```bash
+> aws ec2 describe-images \
+> --filters 'Name=tag:spinifex:managed-by,Values=ecs' \
+> --query 'Images[].[ImageId,Name]' --output text
+> ```
+>
+> No rows means the image is not imported — register it before continuing.
+
+The Terraform workbook builds all of this for you. For the CLI or console paths, have it in place first.
+
+- **Spinifex running**, with the AWS CLI configured for the `spinifex` profile (see [Installing Spinifex](/docs/install)).
+- **The `spinifex-ecs-node` image** imported. Confirm it resolves:
+
+ ```bash
+ aws ec2 describe-images \
+ --filters 'Name=tag:spinifex:managed-by,Values=ecs' \
+ --query 'Images[].[ImageId,Name]' --output text
+ ```
+
+- **A VPC with at least one subnet** and an **Internet Gateway** with a default route (`0.0.0.0/0 → IGW`), so instances can pull container images.
+- **The `ecsInstanceRole` instance profile.** It is account-global: a role trusted by `ec2.amazonaws.com` with an `ecs:*` policy, exposed through an instance profile of the same name. The console's provision-capacity action creates it on first use; the Terraform workbook creates it unless you opt out.
+- **An optional task IAM role** trusted by `ecs-tasks.amazonaws.com`, if your containers call AWS APIs.
+
+## Instructions
+
+The same workload can be created three ways. Pick your tool — each path reaches the same running service.
+
+:::tabs
+@tab AWS CLI
+
+### 1. Create the cluster
+
+```bash
+export AWS_PROFILE=spinifex
+
+aws ecs create-cluster --cluster-name demo
+```
+
+### 2. Register a task definition
+
+`awsvpc` network mode, EC2 launch type, one nginx container on port 80:
+
+```bash
+aws ecs register-task-definition \
+ --family web \
+ --network-mode awsvpc \
+ --requires-compatibilities EC2 \
+ --cpu 256 --memory 512 \
+ --container-definitions '[{
+ "name":"web",
+ "image":"docker.io/library/nginx:1.27-alpine",
+ "portMappings":[{"containerPort":80,"protocol":"tcp"}],
+ "essential":true
+ }]'
+```
+
+### 3. Add capacity
+
+Launch one or more EC2 instances from the ECS node image with the `ecsInstanceRole`
+instance profile and a cloud-init that points the agent at the cluster. The
+[ECS Quickstart workbook](https://github.com/mulgadc/spinifex/tree/main/docs/terraform-workbooks/ecs-quickstart)
+produces this user-data for you; the console **Provision capacity** action is the
+one-click equivalent. Once an instance boots and its agent registers, it appears here:
+
+```bash
+aws ecs list-container-instances --cluster demo
+```
+
+### 4. Run a task or create a service
+
+Run a one-off task:
+
+```bash
+aws ecs run-task \
+ --cluster demo --task-definition web --count 1 \
+ --network-configuration 'awsvpcConfiguration={subnets=[subnet-aaaa]}'
+```
+
+Or keep it running behind an ALB target group (`target_type = ip`):
+
+```bash
+aws ecs create-service \
+ --cluster demo --service-name web --task-definition web \
+ --desired-count 2 \
+ --network-configuration 'awsvpcConfiguration={subnets=[subnet-aaaa]}' \
+ --load-balancers 'targetGroupArn=,containerName=web,containerPort=80'
+
+aws ecs describe-services --cluster demo --services web \
+ --query 'services[0].[runningCount,desiredCount]'
+```
+
+@tab Spinifex UI
+
+The Spinifex console drives the same workflow. From the left navigation open **ECS → Clusters**.
+
+### 1. Create the cluster
+
+Click **Create Cluster**, give it a name, and submit. It appears in the **Clusters** list.
+
+### 2. Provision capacity
+
+Open the cluster and use **Provision capacity** on the **Infrastructure** (container instances) tab. The console launches instances from the `spinifex-ecs-node` image, attaches the `ecsInstanceRole` instance profile (creating it if needed), and injects the gateway URL and CA into the instance's cloud-init — so the agent registers without any manual key handling. The instances appear under **Infrastructure** as they register.
+
+### 3. Register a task definition and run it
+
+- The **Task definitions** view lists families and revisions; register a new revision with the container image, CPU/memory, and ports.
+- From a task definition, **Run task** for a one-off, or create a **Service** to keep a desired count running.
+- A cluster's **Services** and **Tasks** tabs show what is running; open a task or service for its detail page (containers, networking, and public/private endpoints).
+
+### 4. Attach a load balancer
+
+When creating a service, select an ALB target group (`target_type = ip`) so each task's ENI is registered and traffic is balanced across tasks.
+
+@tab Terraform
+
+The [ECS Quickstart workbook](https://github.com/mulgadc/spinifex/tree/main/docs/terraform-workbooks/ecs-quickstart) builds the whole stack in one `apply`: VPC, IAM, cluster, task definition, container instances, and an ALB-fronted service. It is the Terraform-native equivalent of the console's provision-capacity action.
+
+```bash
+git clone --depth 1 --filter=blob:none --sparse https://github.com/mulgadc/spinifex.git spinifex-tf
+cd spinifex-tf
+git sparse-checkout set docs/terraform-workbooks
+cd docs/terraform-workbooks/ecs-quickstart
+```
+
+The core resources point the AWS provider at Spinifex's `ecs`, `ec2`, `iam`, `sts`, and `elasticloadbalancingv2` endpoints, then create the cluster, an `awsvpc` task definition with a task role, and a service wired to a target group:
+
+```hcl
+resource "aws_ecs_cluster" "main" {
+ name = "ecs-quickstart"
+}
+
+resource "aws_ecs_task_definition" "web" {
+ family = "ecs-quickstart-web"
+ network_mode = "awsvpc"
+ requires_compatibilities = ["EC2"]
+ cpu = "256"
+ memory = "512"
+ task_role_arn = aws_iam_role.task.arn
+
+ container_definitions = jsonencode([{
+ name = "web"
+ image = "docker.io/library/nginx:1.27-alpine"
+ essential = true
+ portMappings = [{ containerPort = 80, protocol = "tcp" }]
+ }])
+}
+
+resource "aws_ecs_service" "web" {
+ name = "ecs-quickstart-web"
+ cluster = aws_ecs_cluster.main.id
+ task_definition = aws_ecs_task_definition.web.arn
+ desired_count = 1
+
+ network_configuration {
+ subnets = [aws_subnet.a.id, aws_subnet.b.id]
+ security_groups = [aws_security_group.node.id]
+ }
+
+ load_balancer {
+ target_group_arn = aws_lb_target_group.web.arn
+ container_name = "web"
+ container_port = 80
+ }
+}
+```
+
+Because a container instance reaches the control plane over the gateway (TLS + SigV4) rather than a managed AWS endpoint, the workbook makes the two things the console injects explicit: a **LAN-reachable** `gateway_url` (the host's bridge IP, not `127.0.0.1`) and the gateway CA, both baked into the instance's cloud-init. Apply it:
+
+```bash
+export AWS_PROFILE=spinifex
+tofu init
+tofu apply -var 'gateway_url=https://:9999'
+```
+
+`ecsInstanceRole` is account-global — if it already exists (from the console), pass `-var 'create_instance_role=false'`. See the workbook README for the full variable list and teardown.
+
+:::
+
+## Troubleshooting
+
+**No container instances appear after launching them.** The agent registers over the gateway, not a managed endpoint, so check its cloud-init injected a correct **LAN-reachable** gateway URL (not `127.0.0.1` — a guest VM cannot reach the host loopback) and the gateway CA. `cloud-init write_files` runs once per instance, so fixing the user-data needs an instance **replacement**, not an in-place modify. Confirm the instance carries the `ecsInstanceRole` instance profile.
+
+**Tasks stay `PENDING`.** No instance has free capacity for the task's CPU/memory reservation. Add capacity, or lower the task definition's reservations. `aws ecs list-container-instances` should show at least one `ACTIVE` instance.
+
+**Service `runningCount` below `desiredCount`.** Either there is not enough capacity (see above), or tasks are failing to start — check the task's containers can pull their image (instances need an egress route) and that the image reference is valid.
+
+**Load balancer target unhealthy / app unreachable.** The target group must be `target_type = ip` (tasks register their ENI IP, not an instance ID). The ALB DNS name ends in `.elb.spinifex.local` and does not resolve from outside; fetch its public IP with `aws elbv2 describe-load-balancers` and curl that. Note health-check settings beyond the target group default are not forwarded.
+
+**Container cannot assume its task role.** Confirm the task definition sets `taskRoleArn` and the role is trusted by `ecs-tasks.amazonaws.com`. The agent injects `AWS_CONTAINER_CREDENTIALS_RELATIVE_URI`; a container that overrides this variable, or an SDK too old to honour it, will not pick up the credentials.
+
+**Expected a feature that is missing.** ECS v1 omits service discovery, `awslogs` shipping, rolling deployments, capacity providers, and a distinct execution role. See the Limitations in the Overview.
diff --git a/docs/containers/eks/README.md b/docs/containers/eks/README.md
index 82f2ad8ce..5436f4a43 100644
--- a/docs/containers/eks/README.md
+++ b/docs/containers/eks/README.md
@@ -36,6 +36,10 @@ An EKS cluster on Spinifex has two parts: a **control plane** (the managed Kuber
Authentication uses **EKS access entries** (the API authentication mode) — IAM principals are mapped to Kubernetes RBAC groups, and `kubectl` authenticates with `aws eks get-token` exactly as it does on AWS.
+
+
+
+
**What you'll create:**
| Resource | Purpose |
@@ -57,6 +61,21 @@ Authentication uses **EKS access entries** (the API authentication mode) — IAM
## Prerequisites
+> [!IMPORTANT]
+> **Prerequisite — `eks-node` image required.**
+>
+> Node groups always boot Spinifex's **`eks-node`** image, and it **must** be registered before you create a cluster — the console blocks cluster creation until it is present. Import it during `spx admin init` (or via the image catalogue) ahead of time.
+>
+> **Verify before continuing:**
+>
+> ```bash
+> aws ec2 describe-images \
+> --filters 'Name=tag:spinifex:managed-by,Values=eks' \
+> --query 'Images[].[ImageId,Name]' --output text
+> ```
+>
+> No rows means the image is not imported — register it before continuing.
+
Before creating a cluster you need the supporting infrastructure in place. The Terraform workbooks build all of this for you; if you are using the CLI or console, create it first.
- **Spinifex running**, with the AWS CLI configured for the `spinifex` profile (see [Installing Spinifex](/docs/install)) and `kubectl` installed.
diff --git a/docs/terraform-workbooks/ecs-quickstart/README.md b/docs/terraform-workbooks/ecs-quickstart/README.md
index 8acc73ec1..8aeb75019 100644
--- a/docs/terraform-workbooks/ecs-quickstart/README.md
+++ b/docs/terraform-workbooks/ecs-quickstart/README.md
@@ -1,48 +1,76 @@
-# ECS Quickstart
+---
+title: "ECS Quickstart"
+description: "Stand up a complete AWS-compatible ECS stack on Spinifex with Terraform — a VPC, IAM roles, a cluster, a task definition with a task role, container instances launched from the ECS node image, and an awsvpc service fronted by an Application Load Balancer."
+category: "Terraform Workbooks"
+tags:
+ - terraform
+ - ecs
+ - containers
+ - alb
+ - iam
+ - vpc
+ - workbook
+sections:
+ - overview
+ - prerequisites
+ - instructions
+ - troubleshooting
+resources:
+ - title: "ECS Service Documentation"
+ url: "/docs/ecs"
+ - title: "Terraform AWS Provider"
+ url: "https://registry.terraform.io/providers/hashicorp/aws/latest"
+ - title: "Spinifex Repository"
+ url: "https://github.com/mulgadc/spinifex"
+ - title: "OpenTofu"
+ url: "https://opentofu.org/"
+---
-An end-to-end ECS stack on Spinifex, provisioned with OpenTofu: a cluster, a
-task definition with a task role, container instances launched from the
-`spinifex-ecs-node` AMI, and an awsvpc service fronted by an Application Load
-Balancer target group.
+# Terraform: ECS Quickstart
-This is the Terraform-native equivalent of the console's "provision capacity"
-action. Because a Spinifex container instance reaches the control plane over the
-gateway (TLS + SigV4) rather than a managed AWS endpoint, the workbook makes the
-two things the console injects for you explicit: a LAN-reachable gateway URL and
-the gateway CA, both baked into the instance's cloud-init user-data. The agent
-draws its credentials from IMDS via the `ecsInstanceRole` instance profile, so
-no static keys are written.
+> The smallest end-to-end ECS example on Spinifex: a VPC, the IAM roles ECS needs, a cluster, a task definition, container instances booted from the ECS node image, and an `awsvpc` service load-balanced behind an Application Load Balancer.
+
+## Overview
+
+This workbook is the Terraform-native equivalent of the console's **Provision capacity** action. It provisions a full ECS stack in one `apply`: a VPC with two public subnets, the `ecsInstanceRole` instance profile, a task IAM role, an ECS cluster, an `awsvpc` task definition running nginx, one or more container instances launched from the `spinifex-ecs-node` AMI, and an Application Load Balancer with a target group the service registers into.
+
+Because a Spinifex container instance reaches the control plane over the **gateway** (TLS + SigV4) rather than a managed AWS endpoint, the workbook makes the two things the console injects for you explicit: a **LAN-reachable** gateway URL and the gateway CA, both baked into the instance's cloud-init user-data. The agent draws its credentials from IMDS via the `ecsInstanceRole` instance profile, so no static keys are written.
## Prerequisites
-- The `spinifex-ecs-node` AMI is imported (resolved here by the
- `tag:spinifex:managed-by=ecs` filter):
+- **Spinifex running**, with the AWS CLI configured for the `spinifex` profile (see [Installing Spinifex](/docs/install)) and OpenTofu (or Terraform) installed.
+- **The `spinifex-ecs-node` AMI imported** — resolved here by the `tag:spinifex:managed-by=ecs` filter:
- ```
+ ```bash
aws ec2 describe-images \
--filters 'Name=tag:spinifex:managed-by,Values=ecs' \
--query 'Images[].[ImageId,Name]' --output text
```
-- A **LAN-reachable** gateway URL — the host's WAN/bridge IP, not `127.0.0.1`
- (a guest VM cannot reach the host loopback). For example
- `https://192.168.1.33:9999`.
+- **A LAN-reachable gateway URL** — the host's WAN/bridge IP, not `127.0.0.1` (a guest VM cannot reach the host loopback). For example `https://192.168.1.33:9999`.
+- **The gateway CA PEM** readable at `gateway_ca_cert_path` (default `/etc/spinifex/ca.pem`).
-- The gateway CA PEM is readable at `gateway_ca_cert_path` (default
- `/etc/spinifex/ca.pem`).
+## Instructions
-## Usage
+### 1. Fetch the workbook
+
+```bash
+git clone --depth 1 --filter=blob:none --sparse https://github.com/mulgadc/spinifex.git spinifex-tf
+cd spinifex-tf
+git sparse-checkout set docs/terraform-workbooks
+cd docs/terraform-workbooks/ecs-quickstart
+```
+
+### 2. Apply
```bash
-cd spinifex/docs/terraform-workbooks/ecs-quickstart
export AWS_PROFILE=spinifex
tofu init
tofu apply -var 'gateway_url=https://:9999'
```
-`ecsInstanceRole` is account-global. If you have already used the console's
-provision-capacity action it exists already, so skip re-creating it:
+`ecsInstanceRole` is account-global. If you have already used the console's provision-capacity action it exists already, so skip re-creating it:
```bash
tofu apply \
@@ -50,7 +78,7 @@ tofu apply \
-var 'create_instance_role=false'
```
-## Verify
+### 3. Verify
Container instances take ~30-60s to boot and register:
@@ -60,8 +88,7 @@ aws ecs describe-services --cluster ecs-quickstart --services ecs-quickstart-web
--query 'services[0].[runningCount,desiredCount]'
```
-The ALB DNS name ends in `.elb.spinifex.local` and does not resolve from your
-host. Fetch its public IP and curl it:
+The ALB DNS name ends in `.elb.spinifex.local` and does not resolve from your host. Fetch its public IP and curl it:
```bash
aws elbv2 describe-load-balancers --names ecs-quickstart-alb \
@@ -70,7 +97,7 @@ aws elbv2 describe-load-balancers --names ecs-quickstart-alb \
curl http://
```
-## Variables
+### 4. Variables
| Variable | Default | Purpose |
|---|---|---|
@@ -84,12 +111,22 @@ curl http://
| `gateway_ca_cert_path` | `/etc/spinifex/ca.pem` | Host-readable gateway CA PEM. |
| `create_instance_role` | `true` | Create `ecsInstanceRole`; set `false` if it already exists. |
-## Teardown
+### 5. Teardown
```bash
tofu destroy -var 'gateway_url=https://:9999'
```
-`DeleteCluster` cascades through the service, its tasks, and the container
-instance registrations, so the destroy round-trips cleanly.
-```
+`DeleteCluster` cascades through the service, its tasks, and the container instance registrations, so the destroy round-trips cleanly.
+
+## Troubleshooting
+
+**Container instances never register.** The agent registers over the gateway, not a managed endpoint. Confirm `gateway_url` is the host's **LAN-reachable** IP (not `127.0.0.1`) and that `gateway_ca_cert_path` points at the real gateway CA. Because `cloud-init write_files` runs once per instance, a corrected user-data needs an instance **replacement** — `tofu apply -replace='aws_instance.node[0]'` — not an in-place modify.
+
+**`apply` fails resolving the AMI.** The `spinifex-ecs-node` image is not imported, or its `spinifex:managed-by=ecs` tag is missing. Re-run the `describe-images` check in Prerequisites.
+
+**`MissingParameter` on the instance.** The gateway's `RunInstances` requires a `KeyName`; the workbook generates a key pair for you, so this only appears if you have stripped that out.
+
+**Service `runningCount` stays below `desiredCount`.** No instance has free capacity, or tasks cannot pull the image. Confirm at least one `ACTIVE` container instance and that the subnets have an Internet Gateway route.
+
+**`create_instance_role` conflict.** If `ecsInstanceRole` already exists (from the console), pass `-var 'create_instance_role=false'` so Terraform does not try to recreate it.