If you need a free demo account for Control Plane (no CC required), you can contact Justin Gordon, CEO of ShakaCode.
Check how the cpflow gem is used in the generated GitHub Actions flow.
Here is a brief video overview.
This simple example shows how to deploy a simple app on Control Plane using the cpflow gem.
To maximize simplicity, this example creates Postgres and Redis as workloads in the same GVC as the app. In a real app, you would likely use persistent, external resources, such as AWS RDS and AWS ElastiCache.
You can see the definition of Postgres and Redis in the .controlplane/templates directory.
This repo uses the generated cpflow-* GitHub Actions wrappers. Keep the
generic behavior documented upstream in the
control-plane-flow CI automation guide;
this section only lists the values that are specific to this app.
For review apps, GitHub needs one repository secret:
| Name | Value |
|---|---|
CPLN_TOKEN_STAGING |
Service-account token for shakacode-open-source-examples-staging. |
Use a staging/review token that cannot access production Control Plane resources. In public repositories, generated review-app deploys skip fork PR heads because Docker builds use repository secrets; if a forked change needs a review app, first move the reviewed change to a trusted branch in this repository.
No review-app repository variables are required for the standard path. The
workflow infers qa-react-webpack-rails-tutorial and
shakacode-open-source-examples-staging from .controlplane/controlplane.yml,
because that file has one app with match_if_app_name_starts_with: true.
PRIMARY_WORKLOAD also stays unset because the public workload is rails.
For staging auto-deploys, also set these repository variables:
| Name | Value |
|---|---|
CPLN_ORG_STAGING |
shakacode-open-source-examples-staging |
STAGING_APP_NAME |
react-webpack-rails-tutorial-staging |
STAGING_APP_BRANCH |
master |
The matching Control Plane resources are:
| Resource | Name |
|---|---|
| Review app prefix | qa-react-webpack-rails-tutorial |
| Review app secret dictionary | qa-react-webpack-rails-tutorial-secrets |
| Staging app | react-webpack-rails-tutorial-staging |
| Staging app secret dictionary | react-webpack-rails-tutorial-staging-secrets |
Bootstrap the persistent staging app once before the first merge-to-master deploy:
cpflow setup-app -a react-webpack-rails-tutorial-staging --org shakacode-open-source-examples-staging --skip-post-creation-hooksetup-app reads setup_app_templates from .controlplane/controlplane.yml
and creates the app identity, app secret dictionary, app secret policy, policy
binding, and template resources. Use --skip-post-creation-hook so first-time
bootstrap does not try to run database setup before a Docker image exists. For
later template updates on an existing persistent app, use
cpflow apply-template and make sure the app identity still has reveal
permission on the app secret policy.
Production promotion is part of the default demo flow, but the production token
must be gated by a protected GitHub Environment named production:
| Name | Where | Value |
|---|---|---|
CPLN_TOKEN_PRODUCTION |
production Environment secret |
Production Control Plane service-account token. |
CPLN_ORG_PRODUCTION |
production Environment variable |
shakacode-open-source-examples-production |
PRODUCTION_APP_NAME |
production Environment variable |
react-webpack-rails-tutorial-production |
Protect the production environment with required reviewers, prevent
self-review, and consider disabling administrator bypass. Do not store
CPLN_TOKEN_PRODUCTION as a repository or organization secret. The production
promotion workflow intentionally runs as a normal caller-repo job with
environment: production, then checks out the pinned control-plane-flow
release for shared actions. GitHub exposes the production token only after the
environment approval gate passes.
Keep CPLN_TOKEN_PRODUCTION absent from repository and organization secrets so
a broader secret cannot mask a missing environment secret.
If promotion fails with
CPLN_TOKEN_PRODUCTION is not set. Add it as a secret on the 'production' GitHub Environment.,
the token is missing from the environment scope or the workflow job is no longer
declaring environment: production. Create or verify the environment secret
and confirm there is no same-named repository or organization secret:
You need permission to manage repository environments and secrets to run these
commands.
gh secret set CPLN_TOKEN_PRODUCTION --repo shakacode/react-webpack-rails-tutorial --env production
gh secret list --repo shakacode/react-webpack-rails-tutorial --env production
gh secret list --repo shakacode/react-webpack-rails-tutorial
gh secret list --org shakacode | grep '^CPLN_TOKEN_PRODUCTION[[:space:]]' || trueThe matching Control Plane resources are:
| Resource | Name |
|---|---|
| Production app | react-webpack-rails-tutorial-production |
| Production app secret dictionary | react-webpack-rails-tutorial-production-secrets |
Bootstrap production the same way before the first promotion, using the
production org and production-only secret values. After bootstrap or any
template change, re-apply the persistent production templates so the rails
and daily-task workloads keep the same secret-backed env names as staging:
cpflow apply-template app postgres redis daily-task rails \
-a react-webpack-rails-tutorial-production \
--org shakacode-open-source-examples-production \
--yes --add-app-identityAll review, staging, and production secret dictionaries need these app runtime secrets:
SECRET_KEY_BASERENDERER_PASSWORDREACT_ON_RAILS_PRO_LICENSE
Generate SECRET_KEY_BASE with openssl rand -hex 64 and
RENDERER_PASSWORD with openssl rand -hex 32. For real production, prefer
managed Postgres and Redis services and update DATABASE_URL and REDIS_URL
accordingly.
Review apps run pull request code, so anything mounted through
cpln://secret/... can be read by that code after it starts. Keep the
qa-react-webpack-rails-tutorial-secrets dictionary limited to review-safe
values: disposable databases, review-only renderer credentials, and a Pro
license value that is acceptable for review-app exposure. Do not reuse
production or long-lived staging secret dictionaries for review apps.
Most repos should leave these unset. They exist so forks and clones can test against their own Control Plane org, prefix, workload, or toolchain:
CPLN_ORG_STAGINGREVIEW_APP_PREFIXPRIMARY_WORKLOADREVIEW_APP_DEPLOYING_ICON_URLCPLN_CLI_VERSIONCPFLOW_VERSION
-
Ensure your Control Plane account is set up. You should have an
organization<your-org>for testing in that account. Set ENV variableCPLN_ORGto<your-org>. Alternatively, you may modify the value foraliases.common.cpln_orgin.controlplane/controlplane.yml. If you need an organization, please contact Shakacode. -
Install Control Plane CLI (and configure access) using
npm install -g @controlplane/cli. You can update thecplncommand line withnpm update -g @controlplane/cli. Then runcpln loginto ensure access. For more informatation check out the docs here. -
Run
cpln image docker-login --org <your-org>to ensure that you have access to the Control Plane Docker registry. -
Install the latest version of
cpflowgem on your project's Gemfile or globally. For more information check out Heroku to Control Plane. -
This project has a
Dockerfilefor Control Plane in.controlplanedirectory. You can use it as an example for your project. Ensure that you have Docker running.
Do not confuse the cpflow CLI with the cpln CLI.
The cpflow CLI is the Heroku to Control Plane playbook CLI.
The cpln CLI is the Control Plane CLI.
See the filese in the ./controlplane directory.
/templates: defines the objects created with thecpflow setupcommand. These YAML files are the same as used by thecpln applycommand./controlplane.yml: defines your application, including the organization, location, and app name.Dockerfile: defines the Docker image used to run the app on Control Plane.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 for the trade-offs and a concrete HCL comparison.
Check if the Control Plane organization and location are correct in .controlplane/controlplane.yml.
Alternatively, you can use CPLN_ORG environment variable to set the organization name.
You should be able to see this information in the Control Plane UI.
Note: The below commands use cpflow which is the Heroku to Control Plane playbook gem,
and not cpln which is the Control Plane CLI.
# Use environment variable to prevent repetition
export APP_NAME=react-webpack-rails-tutorial
# Provision all infrastructure on Control Plane.
# app react-webpack-rails-tutorial will be created per definition in .controlplane/controlplane.yml
cpflow setup-app -a $APP_NAME
# Build and push docker image to Control Plane repository
# Note, may take many minutes. Be patient.
# Check for error messages, such as forgetting to run `cpln image docker-login --org <your-org>`
cpflow build-image -a $APP_NAME
# Promote image to app after running `cpflow build-image command`
# Note, the UX of images may not show the image for up to 5 minutes.
# However, it's ready.
cpflow deploy-image -a $APP_NAME
# See how app is starting up
cpflow logs -a $APP_NAME
# Open app in browser (once it has started up)
cpflow open -a $APP_NAMEAfter committing code, you will update your deployment of react-webpack-rails-tutorial with the following commands:
# Assuming you have already set APP_NAME env variable to react-webpack-rails-tutorial
# Build and push new image with sequential image tagging, e.g. 'react-webpack-rails-tutorial:1', then 'react-webpack-rails-tutorial:2', etc.
cpflow build-image -a $APP_NAME
# Run database migrations (or other release tasks) with latest image,
# while app is still running on previous image.
# This is analogous to the release phase.
cpflow run -a $APP_NAME --image latest -- rails db:migrate
# Pomote latest image to app after migrations run
cpflow deploy-image -a $APP_NAMEIf you needed to push a new image with a specific commit SHA, you can run the following command:
# Build and push with sequential image tagging and commit SHA, e.g. 'react-webpack-rails-tutorial:123_ABCD'
cpflow build-image -a $APP_NAME --commit ABCDThis application uses Thruster, a zero-config HTTP/2 proxy from Basecamp, for optimized performance on Control Plane.
Thruster is a small, fast HTTP/2 proxy designed for Ruby web applications. It provides:
- HTTP/2 Support: Automatic HTTP/2 with multiplexing for faster asset loading
- Asset Caching: Intelligent caching of static assets
- Compression: Automatic gzip/Brotli compression
- TLS Termination: Built-in Let's Encrypt support (not needed on Control Plane)
To enable Thruster with HTTP/2 on Control Plane, two configuration changes are required:
The Dockerfile must use Thruster to start the Rails server:
# Use Thruster HTTP/2 proxy for optimized performance
CMD ["bundle", "exec", "thrust", "bin/rails", "server"]Note: Do NOT use --early-hints flag as Thruster handles this automatically.
The workload port should remain as HTTP/1.1:
ports:
- number: 3000
protocol: http # Keep as http, not http2Important: This may seem counter-intuitive, but here's why:
- Thruster handles HTTP/2 on the public-facing TLS connection
- Control Plane's load balancer communicates with the container via HTTP/1.1
- Setting
protocol: http2causes a protocol mismatch and 502 errors - Thruster automatically provides HTTP/2 to end users through its TLS termination
On Heroku: The Procfile defines how dynos start:
web: bundle exec thrust bin/rails server
On Control Plane/Kubernetes: The Dockerfile CMD defines how containers start. The Procfile is ignored.
This is a common source of confusion when migrating from Heroku. Always ensure your Dockerfile CMD matches your intended startup command.
After deployment, verify HTTP/2 is working:
-
Check workload logs:
cpflow logs -a react-webpack-rails-tutorial-staging
You should see Thruster startup messages:
[thrust] Starting Thruster HTTP/2 proxy [thrust] Proxying to http://localhost:3000 [thrust] Serving from ./public -
Test HTTP/2 in browser:
- Open DevTools → Network tab
- Load the site
- Check the Protocol column (should show "h2" for HTTP/2)
-
Check response headers:
curl -I https://your-app.cpln.app
Look for HTTP/2 indicators in the response.
Symptom: Workload shows as unhealthy or crashing
Solution: Check logs with cpflow logs -a <app-name>. Common issues:
- Missing
thrustergem in Gemfile - Incorrect CMD syntax in Dockerfile
- Port mismatch (ensure Rails listens on 3000)
Symptom: Workload returns 502 Bad Gateway with "protocol error"
Root Cause: Setting protocol: http2 in rails.yml causes a protocol mismatch
Solution:
- Change
protocol: http2back toprotocol: httpin.controlplane/templates/rails.yml - Apply the template:
cpflow apply-template rails -a <app-name> - The workload will immediately update (no redeploy needed)
Why: Thruster provides HTTP/2 to end users, but Control Plane's load balancer communicates with containers via HTTP/1.1. Setting the port protocol to http2 tells the load balancer to expect HTTP/2 from the container, which Thruster doesn't provide on the backend.
Symptom: Static assets return 404 or fail to load
Solution:
- Ensure
bin/rails assets:precompileruns in Dockerfile - Verify
public/packs/directory exists in container - Check Thruster is serving from correct directory
With Thruster and HTTP/2 enabled on Control Plane, you should see:
- 20-30% faster initial page loads due to HTTP/2 multiplexing
- 40-60% reduction in transfer size with Brotli compression
- Improved caching of static assets
- Lower server load due to efficient asset serving
For detailed Thruster documentation, see docs/thruster.md.
This section documents important insights gained from deploying Thruster with HTTP/2 on Control Plane.
Common Mistake: Setting protocol: http2 in the workload port configuration
Result: 502 Bad Gateway with "protocol error"
Correct Configuration: Use protocol: http
Control Plane's architecture differs from standalone Thruster deployments:
Standalone Thruster (e.g., VPS):
User → HTTPS/HTTP2 → Thruster → HTTP/1.1 → Rails
(Thruster handles TLS + HTTP/2)
Control Plane + Thruster:
User → HTTPS/HTTP2 → Control Plane LB → HTTP/1.1 → Thruster → HTTP/1.1 → Rails
(LB handles TLS) (protocol: http) (HTTP/2 features)
Even with protocol: http, Thruster still provides:
- ✅ Asset caching and compression
- ✅ Efficient static file serving
- ✅ Early hints support
- ✅ HTTP/2 multiplexing features (via Control Plane LB)
The HTTP/2 protocol is terminated at Control Plane's load balancer, which then communicates with Thruster via HTTP/1.1. Thruster's caching, compression, and early hints features work regardless of the protocol between the LB and container.
If you encounter 502 errors:
- Verify Thruster is running:
cpln workload exec ... -- cat /proc/1/cmdline - Test internal connectivity:
cpln workload exec ... -- curl localhost:3000 - Check protocol setting: Should be
protocol: httpnothttp2 - Review workload logs:
cpln workload eventlog <workload> --gvc <gvc> --org <org>
For detailed debugging guidance, see docs/debugging-deployment-failures.md.
The Control Plane MCP (Model Context Protocol) provides AI-assisted debugging tools that can diagnose deployment issues quickly. If you're using Claude Code or another MCP-compatible AI assistant, you can use these tools:
# Set context for your organization
mcp__cpln__set_context(org="your-org", defaultGvc="your-gvc")
# Check workload deployment status
mcp__cpln__get_workload_deployments(gvc="your-gvc", name="rails")
# View events (shows Capacity AI changes, errors)
mcp__cpln__get_workload_events(gvc="your-gvc", name="rails")
# Update workload settings (e.g., disable Capacity AI)
mcp__cpln__update_workload(gvc="your-gvc", name="rails", capacityAI=false, memory="512Mi")Symptoms: High restart count, 503 errors, "MinimumReplicasUnavailable"
Debugging steps:
- Check container logs:
cpflow logs -a <app-name> - Look for application errors (missing env vars, database issues)
- Check if Capacity AI reduced resources too low
Common causes:
- Missing
SECRET_KEY_BASE(required for Rails 8.1+) - Database connection failures
- Capacity AI memory starvation
Symptoms: Memory reduced to <100Mi, crash loop
Fix:
# Via cpflow
cpflow run -a <app-name> -- echo "test" # Verify access
# Via MCP tools
mcp__cpln__update_workload(
gvc="your-gvc",
name="rails",
capacityAI=false,
memory="512Mi",
cpu="300m"
)Rails 8.1+ requires SECRET_KEY_BASE at runtime. Add it to your GVC environment:
# Generate a secret
openssl rand -hex 64
# Add to GVC via Control Plane UI or MCP tools- waits for Postgres and Redis to be available
- runs
rails db:prepareto create/seed or migrate the database
Note, some of the URL references are internal for the ShakaCode team.
Review Apps (deployment of apps based on a PR) are done via the generated
cpflow-* GitHub Actions flow.
The review apps work by creating isolated deployments for pull requests through
this automated process. When an approved collaborator comments exactly
+review-app-deploy on a PR, the action:
- Sets up the necessary environment and tools
- Creates a unique review app if it doesn't exist
- Builds a Docker image tagged with the PR commit SHA
- Deploys this image to Control Plane with its own isolated environment
After the review app exists, new pushes to the PR redeploy it automatically.
Use +review-app-delete to delete it manually; closing the PR deletes it
automatically. Use +review-app-help for the review-app command reference.
Fork PR heads are skipped for deploys because the workflow builds Docker images
with repository secrets. A trusted comment on a fork PR still should not deploy
the fork head; move the reviewed change to a branch in this repository when a
review app is needed.
Pushes to the staging branch deploy staging, and production promotion is manual
from the cpflow-promote-staging-to-production workflow.
If staging moves off master, update both the STAGING_APP_BRANCH repository
variable and the branches: filter in .github/workflows/cpflow-deploy-staging.yml;
GitHub does not allow repository variables in trigger branch filters.
The production promotion workflow checks that production has all environment
variable names present in staging at both the GVC level and each configured app
workload's container level. It does not compare secret values. The health check
waits for Control Plane to report both status.ready and status.readyLatest
before probing the public endpoint.
The GitHub settings and Control Plane resources must match the app names in
.controlplane/controlplane.yml. For the standard review-app path, leave
REVIEW_APP_PREFIX unset and let the workflow infer
qa-react-webpack-rails-tutorial; generated review apps are named
qa-react-webpack-rails-tutorial-<PR number>.
If you have older review apps from the previous
qa-react-webpack-rails-tutorial-pr-<PR number> naming, delete them manually
after this flow lands; cleanup targets the current prefix convention. To
inventory old apps, run:
cpln gvc query --org shakacode-open-source-examples-staging -o yaml --prop name~qa-react-webpack-rails-tutorial-pr-This allows teams to:
- Preview changes in a production-like environment
- Test features independently
- Share working versions with stakeholders
- Validate changes before merging to main branches
The system uses Control Plane's infrastructure to manage these deployments, with each review app getting its own resources as defined in the controlplane.yml configuration.
Keep the reusable-workflow mechanics in the upstream
control-plane-flow CI automation guide.
For this repo, the update loop is:
- Update the bundled
cpflowgem to the desired release. - Refresh generated wrappers from that release with
--staging-branch master. - Keep generated refs on the same release tag as the bundled
cpflowgem. This branch pins refs tov5.1.1, which includes upstream promotion hardening and the release-runner timeout fix. Use a full commit SHA only for short-lived upstream testing and leaveCPFLOW_VERSIONunset in that case. - Keep app names and GitHub settings aligned with
.controlplane/controlplane.yml. - Validate locally:
bin/conductor-exec bundle update cpflow
bin/conductor-exec bundle exec cpflow update-github-actions --staging-branch master
bin/conductor-exec bin/test-cpflow-github-flow bundle exec cpflowThen open a normal PR, wait for GitHub Actions, and test a real review-app
deploy. Comment-triggered workflows run from master; for PR-branch workflow
edits, dispatch the workflow explicitly:
gh workflow run cpflow-deploy-review-app.yml --ref <branch> -f pr_number=<pr-number>This loads the workflow file from <branch>, but trusted local composite
actions still come from the default branch before secrets are used. Treat it as
a partial smoke test, then verify a real deploy after the workflow changes land
on master. See the short
testing checklist for the canary steps.