Skip to content

Commit 2c9f76c

Browse files
Kastier1claude
andauthored
feat(hosting-cli): add reflex cloud gcp deploy for Cloud Run (#6450)
* feat(hosting-cli): add `reflex cloud gcp deploy` for Cloud Run Fetches a Dockerfile and bash deploy script from flexgen (`GET /api/v1/cli/gcp-cloud-run-manifest`), writes the Dockerfile into the user's source directory, prints the script, and runs it via bash after the user confirms. Pre-flights `bash`/`gcloud`/`docker` on PATH and an active gcloud account, and surfaces a clear message on 403 (Enterprise tier required). Deploy parameters (project, region, service name, AR repo, version) are passed via env vars to the script. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(hosting-cli): restrict gcp deploy env, add --no-interactive, extract constants Address PR feedback: - Restrict the deploy script's environment to an allowlist of host vars (PATH, HOME, gcloud/docker config, proxy/TLS) plus the explicit deploy overrides. Prevents a tampered or compromised flexgen manifest from exfiltrating unrelated host secrets like AWS_*/GITHUB_TOKEN. - Add --interactive/--no-interactive (default true) so the command works in CI. In non-interactive mode the run prompt is skipped, and an existing Dockerfile errors out unless --overwrite-dockerfile is set. - Extract env-var keys and manifest field names into module-level constants per project convention. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * refactor(hosting-cli): rename gcp deploy to `reflex cloud deploy --gcp`, add docs Flatten the `gcp` group into a single `deploy` command with a `--gcp` target flag so the surface can grow to other targets without nesting. `--gcp-project` becomes optional at the Click level and is validated in-function so the missing-target error fires first. Add a hosting doc (with Enterprise-only callout) covering prerequisites, options, what gets created in the GCP project, the env-allowlist security model, CI usage, and troubleshooting. Wire it into the sidebar's Self Hosting section and add `Gcp` -> `GCP` to the sidebar acronym map. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor(hosting-cli): use cloudbuild.yaml instead of symlinks for gcp deploy The Dockerfile no longer touches disk anywhere near the user's source — it's embedded (base64) as an inline `docker build` step inside a Cloud Build config written to a tempfile, and the flexgen script's `gcloud builds submit --tag X .` invocation is rewritten in-memory to `--config="${REFLEX_CLOUDBUILD_YAML}" --substitutions=_IMAGE="${IMAGE}"`. The script runs with cwd = the user's source dir, so the user's tree is the Cloud Build upload context. No temp dir of symlinks, no source-tree mutation. The cloudbuild.yaml tempfile is removed after the deploy. If `gcloud builds submit` can't be located in the manifest's script (format drift on flexgen's side), the rewrite errors out clearly so the breakage surfaces immediately rather than half-running. Verified end-to-end against a real GCP project: 1m53s Cloud Build, new Cloud Run revision deployed and serving traffic, source Dockerfile timestamp unchanged, tempfile cleaned up. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * update to not overwrite users dockerfile * update docs * pre commit --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
1 parent bc434f7 commit 2c9f76c

7 files changed

Lines changed: 1289 additions & 1 deletion

File tree

docs/app/reflex_docs/pages/docs/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,7 @@ def get_previews_from_frontmatter(filepath: str) -> dict[str, str]:
149149
"docs/events/special_events.md": "Special Events Docs",
150150
"docs/library/graphing/general/tooltip.md": "Graphing Tooltip",
151151
"docs/recipes/content/grid.md": "Grid Recipe",
152+
"docs/hosting/deploy-to-gcp.md": "Deploy to GCP",
152153
}
153154

154155

docs/app/reflex_docs/templates/docpage/sidebar/sidebar_items/item.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,14 @@ def create_item(route: Route, children=None):
1313
# For "Overview", we want to keep the qualifier prefix ("Components overview")
1414
alt_name_for_next_prev = name if name.endswith("Overview") else ""
1515
# Capitalize acronyms
16-
acronyms = {"Api": "API", "Cli": "CLI", "Ide": "IDE", "Mcp": "MCP", "Ai": "AI"}
16+
acronyms = {
17+
"Api": "API",
18+
"Cli": "CLI",
19+
"Ide": "IDE",
20+
"Mcp": "MCP",
21+
"Ai": "AI",
22+
"Gcp": "GCP",
23+
}
1724
name = re.sub(
1825
r"\b(" + "|".join(acronyms.keys()) + r")\b",
1926
lambda m: acronyms[m.group(0)],

docs/app/reflex_docs/templates/docpage/sidebar/sidebar_items/learn.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -250,6 +250,7 @@ def get_sidebar_items_hosting():
250250
children=[
251251
hosting.self_hosting,
252252
hosting.databricks,
253+
hosting.deploy_to_gcp,
253254
],
254255
),
255256
]

docs/hosting/deploy-to-gcp.md

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
```python exec
2+
import reflex as rx
3+
```
4+
5+
# Deploy to GCP Cloud Run
6+
7+
The `reflex cloud deploy --gcp` command deploys a Reflex app to your own [Google Cloud Run](https://cloud.google.com/run) service. Reflex Cloud fetches a Cloud Run-ready Dockerfile and a `gcloud` deploy script, wraps the Dockerfile inside a [Cloud Build config (`cloudbuild.yaml`)](https://cloud.google.com/build/docs/build-config-file-schema), and runs the script against the Google Cloud project you specify. The image is built on Cloud Build (so it works from any host OS, including Apple Silicon) and pushed to Artifact Registry. Your project tree is never modified — the Dockerfile lives only inside the build config that's submitted to Cloud Build.
8+
9+
```md alert info
10+
# Enterprise tier only.
11+
12+
Self-deploying to GCP Cloud Run is part of the **Enterprise tier** of Reflex Cloud. The control plane will return `403` to non-Enterprise tokens, and the CLI surfaces a clear error pointing at this. Contact [sales@reflex.dev](mailto:sales@reflex.dev) to upgrade.
13+
```
14+
15+
## Prerequisites
16+
17+
Before running the command, install and authenticate the local tools the deploy script invokes:
18+
19+
- `gcloud` — install from the [Google Cloud SDK docs](https://cloud.google.com/sdk/docs/install), then run:
20+
- `gcloud auth login`
21+
- `gcloud auth application-default login`
22+
- `docker` — required by `gcloud builds submit` for source upload.
23+
- `bash` — used to run the deploy script.
24+
25+
You also need:
26+
27+
- A GCP project with **billing enabled**. Without it, `gcloud services enable` fails with `UREQ_PROJECT_BILLING_NOT_FOUND`.
28+
- An Enterprise-tier Reflex Cloud subscription and a logged-in Reflex CLI (`reflex login`).
29+
30+
## Quick start
31+
32+
From the root of your Reflex app:
33+
34+
```bash
35+
reflex cloud deploy --gcp \
36+
--gcp-project my-gcp-project-id \
37+
--service-name my-reflex-app
38+
```
39+
40+
The CLI will:
41+
42+
1. Authenticate against Reflex Cloud and fetch the deploy manifest (Dockerfile + `gcloud` script).
43+
2. Generate a `cloudbuild.yaml` that embeds the Dockerfile as a build step, write it to a tempfile, and rewrite the script's `gcloud builds submit` invocation to use `--config="$REFLEX_CLOUDBUILD_YAML"`.
44+
3. Print the (rewritten) script so you can review it.
45+
4. Ask for confirmation, then run the script with `cwd=` your source directory: enable the required APIs, create the Artifact Registry repository, build the image on Cloud Build (which materializes the Dockerfile inside the build step from the `cloudbuild.yaml`), and deploy a public Cloud Run service.
46+
5. Delete the tempfile after the script finishes.
47+
48+
Your source tree is never written to — if you have an existing `Dockerfile` in `--source`, it's left in place and ignored. The Reflex-provided Dockerfile only exists inside the `cloudbuild.yaml` tempfile (and inside the Cloud Build job).
49+
50+
When it's done, you'll get a service URL like `https://my-reflex-app-<project-number>.us-central1.run.app`.
51+
52+
## Options
53+
54+
| Option | Default | Description |
55+
| --- | --- | --- |
56+
| `--gcp` | _(required)_ | Selects the GCP Cloud Run target. |
57+
| `--gcp-project` | _(required)_ | The GCP **project ID** to deploy into. Project numbers are **not** accepted by `gcloud artifacts repositories`; use the project ID. |
58+
| `--region` | `us-central1` | Cloud Run region. |
59+
| `--service-name` | `reflex-app` | Cloud Run service name. |
60+
| `--ar-repo` | `reflex` | Artifact Registry repository name (created on first deploy). |
61+
| `--version` | UTC timestamp (`YYYYMMDD-HHMMSS`) | Image version tag. |
62+
| `--source` | `.` | Directory containing the Reflex app. Uploaded to Cloud Build as the build context; the source tree itself is not modified. |
63+
| `--token` | _from `~/.reflex` config_ | Reflex authentication token. |
64+
| `--interactive / --no-interactive` | `--interactive` | Whether to prompt before running the deploy script. |
65+
| `--dry-run` | _off_ | Print the manifest, the generated `cloudbuild.yaml`, and the rewritten script without writing the tempfile or running the script. |
66+
| `--loglevel` | `info` | Log verbosity. |
67+
68+
## What gets created in your GCP project
69+
70+
The deploy script enables these APIs (if not already enabled):
71+
72+
- `cloudbuild.googleapis.com`
73+
- `run.googleapis.com`
74+
- `artifactregistry.googleapis.com`
75+
76+
It then creates (idempotently) and uses:
77+
78+
- An Artifact Registry Docker repository at `${REGION}-docker.pkg.dev/${GCP_PROJECT}/${AR_REPO}`.
79+
- A Cloud Build job that builds and pushes the image.
80+
- A Cloud Run service named `${SERVICE_NAME}`, deployed with `--allow-unauthenticated`, port 8080, 1 vCPU, 1 GiB memory, `--min-instances 1`, and `--session-affinity`.
81+
82+
Re-running the command pushes a new image tag and rolls the Cloud Run service forward.
83+
84+
## How the build runs
85+
86+
The generated `cloudbuild.yaml` is a single Cloud Build step that:
87+
88+
1. Writes the Dockerfile into the build workspace via a single-quoted heredoc:
89+
```yaml
90+
- |
91+
cat > Dockerfile <<'REFLEX_DOCKERFILE_EOF'
92+
FROM python:3.13-slim
93+
...
94+
REFLEX_DOCKERFILE_EOF
95+
docker build -t "$_IMAGE" .
96+
docker push "$_IMAGE"
97+
```
98+
2. Builds and pushes the image, tagging it with `_IMAGE` (passed to `gcloud builds submit` as `--substitutions=_IMAGE=...`).
99+
100+
Because Cloud Build runs its own substitution pass over `args`, every literal `$` in the Dockerfile is doubled to `$$` before embedding (e.g. `ENV PATH="${UV_PROJECT_ENVIRONMENT}/bin:$PATH"` becomes `ENV PATH="$${UV_PROJECT_ENVIRONMENT}/bin:$$PATH"` in the YAML). Cloud Build's parser converts `$$` back to `$` before bash runs, so the Dockerfile written into the workspace contains the original characters.
101+
102+
## Security model
103+
104+
The CLI runs the deploy script under a **restricted environment**. Only an explicit allowlist of host variables is forwarded to `bash` — things like `PATH`, `HOME`, `CLOUDSDK_*`, `DOCKER_*`, and proxy/TLS variables. Unrelated host secrets such as `AWS_*`, `GITHUB_TOKEN`, or arbitrary user variables are **not** forwarded, so a tampered or compromised manifest cannot exfiltrate them.
105+
106+
You can preview the rewritten script, generated `cloudbuild.yaml`, and Dockerfile before anything runs by using `--dry-run`:
107+
108+
```bash
109+
reflex cloud deploy --gcp \
110+
--gcp-project my-gcp-project-id \
111+
--dry-run
112+
```
113+
114+
## Non-interactive use (CI)
115+
116+
For automated pipelines, pass `--no-interactive` and an explicit `--token`:
117+
118+
```bash
119+
reflex cloud deploy --gcp \
120+
--gcp-project "$GCP_PROJECT_ID" \
121+
--service-name my-reflex-app \
122+
--token "$REFLEX_TOKEN" \
123+
--no-interactive
124+
```
125+
126+
In non-interactive mode the CLI will not prompt, and it will exit non-zero if a token cannot be resolved.
127+
128+
## Troubleshooting
129+
130+
**`Reflex denied the request (403). GCP Cloud Run deploys require an Enterprise tier subscription.`**
131+
Your account is not on the Enterprise tier. Contact [sales@reflex.dev](mailto:sales@reflex.dev).
132+
133+
**`Billing must be enabled for activation of service(s) ...` (`UREQ_PROJECT_BILLING_NOT_FOUND`)**
134+
Attach a billing account to the GCP project, or use a different `--gcp-project`.
135+
136+
**`The value of '--project' flag was set to Project number. To use this command, set it to PROJECT ID instead.`**
137+
Pass the project ID (e.g. `my-app-123456`), not the numeric project number.
138+
139+
**`No active GCP account found.`**
140+
Run `gcloud auth login` and `gcloud auth application-default login`.
141+
142+
**`The 'gcloud' / 'docker' / 'bash' CLI was not found on PATH.`**
143+
Install the missing tool and ensure it's on `PATH` for the shell you're invoking the CLI from.
144+
145+
**`Dockerfile content contains the reserved heredoc marker 'REFLEX_DOCKERFILE_EOF'.`**
146+
Vanishingly unlikely — the Dockerfile from Reflex Cloud happens to contain a line that exactly matches the heredoc terminator the CLI uses to embed it. Re-run after the next CLI release, or open an issue.
147+
148+
**`Couldn't find 'gcloud builds submit' in the deploy script.`**
149+
The CLI rewrites the `gcloud builds submit` block in the Reflex-supplied deploy script to use `--config=`. If Reflex Cloud changes the shape of that script before the CLI is updated to match, you'll see this error — upgrade `reflex-hosting-cli` (`uv tool upgrade reflex-hosting-cli` or `pip install -U reflex-hosting-cli`).

packages/reflex-hosting-cli/src/reflex_cli/v2/deployments.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from reflex_cli import constants
1313
from reflex_cli.utils import console
1414
from reflex_cli.v2.apps import apps_cli
15+
from reflex_cli.v2.gcp import deploy_command as gcp_deploy_command
1516
from reflex_cli.v2.project import project_cli
1617
from reflex_cli.v2.secrets import secrets_cli
1718
from reflex_cli.v2.vmtypes_regions import vm_types_regions_cli
@@ -64,6 +65,10 @@ def hosting_cli(ctx: click.Context) -> None:
6465
secrets_cli,
6566
name="secrets",
6667
)
68+
hosting_cli.add_command(
69+
gcp_deploy_command,
70+
name="deploy",
71+
)
6772
for name, command in vm_types_regions_cli.commands.items():
6873
# Add the command to the hosting CLI
6974
hosting_cli.add_command(command, name=name)

0 commit comments

Comments
 (0)