Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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;
Comment thread
justin808 marked this conversation as resolved.
Outdated
# in TF this is a -var or a workspace name.
variable "location" { type = string default = "aws-us-east-2" }
Comment thread
justin808 marked this conversation as resolved.
Outdated
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.
Comment thread
justin808 marked this conversation as resolved.
Outdated

# 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 = {
Comment thread
justin808 marked this conversation as resolved.
DATABASE_URL = "postgres://the_user:the_password@postgres.${var.app_name}.cpln.local:5432/${var.app_name}"
Comment thread
justin808 marked this conversation as resolved.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The the_user:the_password placeholder is likely to be copy-pasted by readers before they finish reading the comment above it. Since the doc already shows cpln://secret/ refs as the right pattern (two lines down for RENDERER_PASSWORD), consider making that the primary example here too:

Suggested change
DATABASE_URL = "postgres://the_user:the_password@postgres.${var.app_name}.cpln.local:5432/${var.app_name}"
DATABASE_URL = "cpln://secret/${var.app_name}-secrets.DATABASE_URL"

Or, if a connection-string form is needed to illustrate the URL shape, use sensitive variables:

variable "db_user"     { type = string; sensitive = true }
variable "db_password" { type = string; sensitive = true }
# ...
DATABASE_URL = "postgres://${var.db_user}:${var.db_password}@postgres.${var.app_name}.cpln.local:5432/${var.app_name}"

Either way, the tutorial teaches the safer pattern rather than asking readers to remember not to copy the placeholder.

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"
Comment thread
justin808 marked this conversation as resolved.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

SECRET_KEY_BASE is the highest-value secret in a Rails app — if a reader copies this line verbatim into a real deployment config the consequences are serious. Since cpln://secret/ refs are already demonstrated below, showing one here makes the example self-consistent and safer:

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"

If the intent is specifically to illustrate that TF can take a sensitive variable, a sensitive = true variable is the idiomatic TF equivalent:

variable "secret_key_base" { type = string; sensitive = true }
# ...
SECRET_KEY_BASE = var.secret_key_base

Either removes the literal placeholder from the primary code path while still making the mapping clear.

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"
Comment thread
justin808 marked this conversation as resolved.
Outdated
Comment thread
justin808 marked this conversation as resolved.
Outdated
}
}

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