The rule
The deploy step should run somewhere that isn't your laptop. Either a CI runner in the cloud or, at worst, a Docker container you can rebuild identically anywhere. Anything else is a footgun waiting for a bad commit or a stolen MacBook.
Why cloud-first beats a script on disk
Deployments are infrequent — most projects ship once a week, some once a quarter. That makes them precisely the wrong thing to optimize for "fast iteration on the laptop":
- Credentials never sit on dev machines. Your Cloudflare API token, AWS keys, Vercel deploy hooks live as masked secrets in the CI provider. If someone exfiltrates your laptop, they can't push to production from it.
- It's bus-factor insurance. When the deploy lives as a GitHub Actions
workflow, anyone with merge rights can ship. When it lives in your
~/scripts/deploy.sh, it ships when you are at your desk. - It's frozen. The workflow YAML is in git. Six months from now, when
nobody remembers exactly which
wrangler deployflag was needed, the answer is the file. A laptop script drifts the moment youbrew upgradethe CLI. - It's logged. Every deploy is an audit trail — who triggered it, on
what commit, with what output.
bash deploy.shin your terminal is not.
What "cloud-first" looks like
A typical deploy workflow has three parts:
- Trigger —
pushtomain(or a release branch), orworkflow_dispatchso anyone with repo access can ship manually from the GitHub UI. - Secrets — stored as repository or environment secrets, referenced by name. Use GitHub Environments with required reviewers if you want a "click to approve" gate before production.
- The actual deploy — one command, scripted in the workflow, the same command the docs would tell you to run locally.
# .github/workflows/deploy.yml
name: deploy
on:
push:
branches: [main]
workflow_dispatch:
jobs:
deploy:
runs-on: ubuntu-latest
environment: production # gate behind reviewers if you want
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm build
- name: deploy
run: pnpm dlx wrangler deploy --env production
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
That's the whole pattern. The token only exists inside the runner's process. Nothing leaks to a developer machine. The repo is the source of truth for how deploys work, not anyone's shell history.
When you really do need to deploy locally
There are legitimate cases — a brand-new tool with no CI integration yet, a provider whose deploy step requires interactive auth, a one-off migration script you don't want to commit. In those cases, deploy from a Docker container, not from your shell directly:
# .env in the project root, gitignored — values OR vault references
CLOUDFLARE_API_TOKEN=op://Deploy/Cloudflare/api-token
CLOUDFLARE_ACCOUNT_ID=op://Deploy/Cloudflare/account-id
# Run the deploy in a pinned image with the env file injected
op run --env-file=.env -- docker run --rm -it \
-v "$PWD":/workspace -w /workspace \
--env-file <(env | grep -E 'CLOUDFLARE_') \
node:22-bookworm \
bash -c "pnpm install --frozen-lockfile && pnpm build && pnpm dlx wrangler deploy"
Or wrap it in a docker-compose.yml so the deploy is docker compose run deploy and the environment is identical on every laptop and every CI
runner:
# docker-compose.deploy.yml
services:
deploy:
image: node:22-bookworm
working_dir: /workspace
volumes:
- .:/workspace
env_file:
- .env # vault references, resolved via `op run`
command: bash -c "pnpm install --frozen-lockfile && pnpm build && pnpm dlx wrangler deploy"
op run --env-file=.env -- docker compose -f docker-compose.deploy.yml run --rm deploy
Two properties this buys you:
- Reproducibility. Your teammate gets the exact same Node version, the
exact same
wranglerversion (since it's resolved from the lockfile), and the exact same shell behavior — because the container is the only thing that runs. - A clean migration path to CI. When the tool's GitHub Action lands or you finally have time to write the workflow, you already have the deploy reduced to "one command in a container." The CI workflow is the same command, in the same image.
Things people get wrong
- Storing the production token in
~/.config"for convenience." Convenience for you is convenience for malware. Either it's in a CI secret store or in 1Password (resolved viaop run), never on disk. - Letting the CI workflow run on PRs from forks. Forked PRs get
read-only secrets by default — good — but if you add
pull_request_targetyou've opened a hole. Don't, unless you really understand the threat model. - Skipping
--frozen-lockfilein CI. Without it, your "frozen" workflow installs whatever the latest tag resolves to. Pin everything. - A separate deploy script that nobody can read. If
deploy.shshells out through twelve tools and three sourced env files, it's still a laptop script in disguise. The CI workflow should be readable top-to-bottom.
TL;DR
- The deploy step belongs in CI. Credentials live in the CI provider's secret store, not on your laptop.
- Use protected environments + required reviewers for production gates.
- If you genuinely need to deploy locally (new tool, no CI yet), do it
from a pinned Docker image with env from a
.envfile — so the same recipe works on every machine and ports cleanly to CI later. - Resolve secrets at runtime (
op run --env-file=.env, GitHub secrets, Doppler, etc.). Plaintext on disk is the failure mode.