Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
142 changes: 142 additions & 0 deletions .controlplane/docs/cpflow-vs-terraform.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
# cpflow vs. Terraform: which manages what?

Control Plane ships an official [Terraform provider](https://registry.terraform.io/providers/controlplane-com/cpln/latest),
so it is reasonable to ask whether the `.controlplane/templates/*.yml` files
should be Terraform instead. The short answer for this app: **no**, because the
YAML here covers two concerns and Terraform only addresses one of them.

## Two kinds of YAML in `.controlplane/`

1. **`controlplane.yml`** — cpflow's own config: app/org aliases,
`app_workloads`, `release_script`, `upstream` (staging→production
promotion), `match_if_app_name_starts_with`, image retention. **This has no
Terraform equivalent.** It drives a Heroku-style deploy workflow, not
infrastructure state.
2. **`templates/*.yml`** — native `cpln apply` resource manifests (GVC,
workloads, secrets, policies, identities, volume sets). These *could* be
rewritten as `cpln_*` Terraform resources.

## What Terraform does not replace

The app lifecycle that is cpflow's whole reason to exist:

- `build-image` / `deploy-image` with sequential image tags
- release-phase migrations (`cpflow run --image latest -- rails db:migrate`)
- staging → production promotion
- **ephemeral per-PR review apps** (the `+review-app-deploy` flow + prefix
matching)

Modeling per-PR environments in Terraform means a workspace and state file per
PR — far heavier than `cpflow setup-app -a qa-react-webpack-rails-tutorial-1234`.

## Decision rule

| Concern | Use |
| --- | --- |
| App build / deploy / release-phase migrations | **cpflow** |
| Ephemeral per-PR review apps | **cpflow** |
| Staging → production promotion | **cpflow** |
| Durable shared infra: orgs, custom domains, mk8s, agents, IAM/policies, external RDS/ElastiCache | **Terraform** (`cpln` provider) |

For a real production system, the two are complementary — Terraform for durable
shared infrastructure, cpflow for the app deploy loop (Control Plane even
publishes a
[GitHub Actions Terraform example](https://github.com/controlplane-com/github-actions-example-terraform)).
This tutorial app has essentially no durable shared infra and exists to
demonstrate the cpflow deploy loop, so it stays entirely on cpflow.

## Appendix: what the templates would look like as HCL

A side-by-side mapping of the current cpflow templates (`templates/app.yml` +
`templates/rails.yml`) to the [`controlplane-com/cpln`](https://registry.terraform.io/providers/controlplane-com/cpln/latest)
provider. The interesting parts are in the comments — they are where the two
models actually diverge.

```hcl
# variables.tf
variable "app_name" { type = string } # cpflow injects {{APP_NAME}} per review app;
# in TF this is a -var or a workspace name.
variable "location" { type = string default = "aws-us-east-2" }
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 HCL2 requires multiple attributes inside a block body to be separated by newlines (or semicolons); placing type and default on the same line without either separator is a parse error that terraform validate will reject. A reader who copies this snippet verbatim will get an immediate failure.

Suggested change
variable "location" { type = string default = "aws-us-east-2" }
variable "location" {
type = string
default = "aws-us-east-2"
}

variable "image_link" { type = string } # cpflow's {{APP_IMAGE_LINK}} is set at *deploy*
# time by `deploy-image`. In TF the image is a
# plain argument, so every deploy is a full apply.

# gvc.tf — was templates/app.yml (kind: gvc + kind: identity)
resource "cpln_gvc" "app" {
name = var.app_name
locations = [var.location] # was staticPlacement.locationLinks: [{{APP_LOCATION_LINK}}]

# was spec.env: (a list of {name,value}); in TF it is a flat map.
env = {
DATABASE_URL = "postgres://the_user:the_password@postgres.${var.app_name}.cpln.local:5432/${var.app_name}"
REDIS_URL = "redis://redis.${var.app_name}.cpln.local:6379"
RAILS_ENV = "production"
NODE_ENV = "production"
RAILS_SERVE_STATIC_FILES = "true"
SECRET_KEY_BASE = "placeholder_secret_key_base_for_test_apps_only"
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the one spot in the appendix that could teach a bad pattern. Hardcoding a secret value in a GVC env map is exactly what readers should not do when they adapt this to a real app — but the example doesn't signal that.

Two lines below, RENDERER_PASSWORD and REACT_ON_RAILS_PRO_LICENSE correctly use cpln://secret/... references. Suggest matching that style, or at minimum adding an inline comment:

Suggested change
SECRET_KEY_BASE = "placeholder_secret_key_base_for_test_apps_only"
SECRET_KEY_BASE = "cpln://secret/${var.app_name}-secrets.SECRET_KEY_BASE" # use a secret ref or var.secret_key_base (sensitive = true)

Even for a tutorial placeholder, showing the cpln://secret/ pattern is consistent with the two lines below and avoids the "copy the example into prod" failure mode.

RENDERER_PORT = "3800"
RENDERER_LOG_LEVEL = "info"
RENDERER_WORKERS_COUNT = "2"
RENDERER_URL = "http://localhost:3800"
RSC_SUSPENSE_DEMO_DELAY = "true"
# cpln:// secret references are just strings, so they port over verbatim:
RENDERER_PASSWORD = "cpln://secret/${var.app_name}-secrets.RENDERER_PASSWORD"
REACT_ON_RAILS_PRO_LICENSE = "cpln://secret/${var.app_name}-secrets.REACT_ON_RAILS_PRO_LICENSE"
}
}

resource "cpln_identity" "app" { # was the second doc in app.yml (kind: identity)
gvc = cpln_gvc.app.name
name = "${var.app_name}-identity"
}

# rails.tf — was templates/rails.yml (kind: workload)
resource "cpln_workload" "rails" {
gvc = cpln_gvc.app.name
name = "rails"
type = "standard"
identity_link = cpln_identity.app.self_link # was identityLink: {{APP_IDENTITY_LINK}}

container {
name = "rails"
image = var.image_link # was {{APP_IMAGE_LINK}}
cpu = "300m"
memory = "1Gi"
inherit_env = true # pulls the GVC env above
env = { LOG_LEVEL = "debug" }

ports {
protocol = "http" # keep http — Thruster does HTTP/2 on the TLS frontend
number = "3000"
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 The cpln_workload provider schema defines the ports.number attribute as a number type, not a string. Passing "3000" (string) instead of 3000 (integer) will produce a type-mismatch error at terraform plan time if the provider enforces strict typing, and at minimum creates misleading documentation for readers.

Suggested change
number = "3000"
number = 3000

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

HCL port numbers are integers, not strings — the cpln provider schema expects a number type here. This should be:

Suggested change
number = "3000"
number = 3000

Passing a string may cause a type-mismatch error on terraform apply depending on how strictly the provider validates the attribute.

}
}

options {
capacity_ai = true
autoscaling {
max_scale = 1 # maxScale 1 ≈ a single Heroku dyno (other fields default)
}
}

firewall_spec {
external {
inbound_allow_cidr = ["0.0.0.0/0"]
outbound_allow_cidr = ["0.0.0.0/0"]
}
}
}
```

`templates/postgres.yml`, `templates/redis.yml`, and `templates/daily-task.yml`
follow the same pattern (`cpln_workload` + `cpln_secret` + `cpln_policy` +
`cpln_volumeset`). The mechanical translation is straightforward — the field
names line up almost 1:1.

What the comments are really showing: the three things cpflow gives you for free
(`{{APP_NAME}}` per-PR interpolation, deploy-time `{{APP_IMAGE_LINK}}`
injection, and the implicit "provision ≠ deploy" separation) all become *your*
problem in Terraform. The image being a plain argument is the big one: in
cpflow, `deploy-image` bumps the running tag without touching infra; in
Terraform, a new image is a `terraform apply` that diffs the whole workload. And
none of `controlplane.yml` (release script, upstream promotion, review-app
prefix matching) has any representation above at all.
4 changes: 4 additions & 0 deletions .controlplane/readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,10 @@ These YAML files are the same as used by the `cpln apply` command.
3. `Dockerfile`: defines the Docker image used to run the app on Control Plane.
4. `entrypoint.sh`: defines the entrypoint script used to run the app on Control Plane.

Wondering whether to manage these YAML templates with Terraform instead? See
[docs/cpflow-vs-terraform.md](docs/cpflow-vs-terraform.md) for the trade-offs
and a concrete HCL comparison.

## Setup and run

Check if the Control Plane organization and location are correct in `.controlplane/controlplane.yml`.
Expand Down
Loading