← tips

Tip #4 — Deploy from the cloud, not your laptop

·
  • deployment
  • ci-cd
  • docker
  • secrets
  • supply-chain

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 deploy flag was needed, the answer is the file. A laptop script drifts the moment you brew upgrade the CLI.
  • It's logged. Every deploy is an audit trail — who triggered it, on what commit, with what output. bash deploy.sh in your terminal is not.

What "cloud-first" looks like

A typical deploy workflow has three parts:

  1. Triggerpush to main (or a release branch), or workflow_dispatch so anyone with repo access can ship manually from the GitHub UI.
  2. 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.
  3. 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:

  1. Reproducibility. Your teammate gets the exact same Node version, the exact same wrangler version (since it's resolved from the lockfile), and the exact same shell behavior — because the container is the only thing that runs.
  2. 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 via op 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_target you've opened a hole. Don't, unless you really understand the threat model.
  • Skipping --frozen-lockfile in 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.sh shells 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

  1. The deploy step belongs in CI. Credentials live in the CI provider's secret store, not on your laptop.
  2. Use protected environments + required reviewers for production gates.
  3. If you genuinely need to deploy locally (new tool, no CI yet), do it from a pinned Docker image with env from a .env file — so the same recipe works on every machine and ports cleanly to CI later.
  4. Resolve secrets at runtime (op run --env-file=.env, GitHub secrets, Doppler, etc.). Plaintext on disk is the failure mode.
Community-driven supply-chain breach intel.A project from Aztecknology.