# Treat Your Deploy Scripts Like the Code They Ship: Idempotent, Documented, and Runnable by Anyo

> Why treating deploy scripts as seriously as your code saves time, reduces frustration, and keeps your starter template alive.
> By Dave · 2026-07-19
> Source: https://otf-kit.dev/blog/the-deploy-script-as-a-deliverable

## Deploy scripts are where starter templates quietly die

Every starter template ships code. Most ship a README. The gap between the two is where Tuesday 2am deploys go to die — and where the next engineer quietly decides "I'll just rewrite this part" and the original template stops being a template.

The pattern is so common it's almost invisible. You clone the repo. You read the README. You run `cp .env.example .env`. You fill in a dozen secrets you don't fully understand. You push. You get a TLS error. You read the README again. You scroll past the Quick Start to a section called "Deployment" that's six paragraphs of prose and one link to a dashboard you'll need to log into. Three hours later you're debugging a CORS misconfiguration that, in a better world, the deploy script would have caught before it ever hit DNS.

That's the cost of treating the deploy as a manual procedure instead of code. It's also fixable. Here's what changes when you take the deploy script as seriously as the code it ships.

## The receipt: a dozen env vars, six dashboards, zero checks

The SaaS kit at OTF runs its deploy with one script — it wires a custom domain, DNS, TLS, and a mobile build. Before we wrote a preflight inside that script, every environment variable was a footgun. `STRIPE_WEBHOOK_SECRET` and `STRIPE_SECRET_KEY` both start with `sk_`. A `PUBLIC_` prefix mistake silently ships a secret to the client. Database URLs vary in shape between local Postgres, Neon, and Supabase, and a malformed one only fails when the first query runs — usually in production.

This isn't unique to us. It's the shape of every full-stack starter. The question isn't whether you have these variables; the question is whether you validate them before DNS or after.

A real preflight looks like this:

```ts
// scripts/preflight.ts
import { z from "zod"

const Env = z.object({
  DATABASE_URL: z.string().url().refine(
    u => u.startsWith("postgres"),
    "DATABASE_URL must be a postgres:// URL"
  ),
  STRIPE_SECRET_KEY: z.string().regex(
    /^sk_(live|test)_/,
    "must be sk_live_… or sk_test_…"
  ),
  STRIPE_WEBHOOK_SECRET: z.string().regex(/^whsec_/, "must start with whsec_"),
  PUBLIC_APP_URL: z.string().url(),
  AUTH_SECRET: z.string().min(32, "AUTH_SECRET must be at least 32 chars"),
  // …
})

const parsed = Env.safeParse(process.env)
if (!parsed.success) {
  console.error("env check failed:")
  for (const issue of parsed.error.issues) {
    console.error(`  - ${issue.path.join(".")}: ${issue.message}`)
  }
  process.exit(1)
}
```

The script runs before anything else. It exits non-zero on the first malformed value. It tells you which variable and why. A new dev runs it, fixes the four things they got wrong, and moves on. The 2am pages stop.



![README-only deploy vs scripted deploy](https://cdn.otf-kit.dev/blog/the-deploy-script-as-a-deliverable/inline-1.png)



## Idempotency: running it twice is the same as running it once

The single highest-use property of a good deploy script is idempotency. The second run is a no-op. The tenth run is still a no-op. The dev who hasn't seen the script before can run it three times in a row without breaking anything.

The reason this matters: deploys fail. Networks drop. DNS records take time to propagate. Cert issuance times out. If your script blows up halfway through and leaves half the resources in a half-configured state, you're now debugging infrastructure instead of shipping product. The stranger who runs your script needs to know that if anything goes wrong, they can just run it again.

Concretely:

```bash
#!/usr/bin/env bash
set -euo pipefail

deploy_acme_challenge() {
  local domain="$1"
  local token="$2"

  if grep -q "acme-challenge" /etc/caddy/Caddyfile 2>/dev/null; then
    echo "→ ACME challenge already wired, skipping"
    return 0
  fi

  echo "→ provisioning ACME challenge for ${domain}"
  cat >> /etc/caddy/Caddyfile <<EOF
${domain} {
  tls {
    dns cloudflare ${CF_API_TOKEN}
  }
}
EOF
}

issue_cert() {
  local domain="$1"
  if caddy list-certificates 2>/dev/null | grep -q "${domain}"; then
    echo "→ cert for ${domain} already issued"
    return 0
  fi
  echo "→ requesting cert for ${domain}"
  caddy reload --config /etc/caddy/Caddyfile
}

deploy_acme_challenge "$DOMAIN" "$TOKEN"
issue_cert "$DOMAIN"
```

Every step checks first, acts second. The script is safe to re-run on a Friday afternoon. It is also safe to re-run after a partial failure. That property is worth more than any amount of clever automation — because the alternative is a human being the rollback mechanism, and humans don't roll back at 2am.



![the deploy pipeline — preflight → infra (DNS, TLS) → app image → verify](https://cdn.otf-kit.dev/blog/the-deploy-script-as-a-deliverable/inline-2.png)



## Documented in the script, not the wiki

The README is a snapshot. The script is the truth. If the deploy procedure lives in a Notion page that was last edited six months ago, you have two sources of authority and they will disagree.

The script is documentation. The argument names are documentation. The `--help` output is documentation. The order of operations is documentation. When someone asks "how does deploy work?", the answer is "read the script." When a coding agent asks the same question, the answer is the same — and that matters more than people realize, because the same agent that's writing your components is also the one your new hire will lean on to deploy.

This is the reason OTF ships deploy scripts alongside the AI-tool configs. A `CLAUDE.md` that says "use scripts/deploy.ts" is useful. A `CLAUDE.md` that says "deploy runs scripts/deploy.ts which does preflight → DNS → TLS → app image → verify; don't skip preflight" is the difference between an agent that helps and an agent that improvises. The 20+ tested prompts in the kit only work because the deploy is also a script the agent can call, not a procedure it has to guess at.

## Runnable by someone who didn't write it — the stranger test

There's a single test for a deploy script that catches most of what's wrong with it. Hand it to a competent engineer who has never seen the codebase. Give them five minutes. Watch them run it.

If they succeed, the script is good. If they get stuck on a missing secret, a step that requires tribal knowledge, or an error message that doesn't tell them what to do next, the script has a hole — and that hole is going to cost you the next three hours of someone's Tuesday.

The stranger test forces a few disciplines:

- **Every required input is asked for, defaulted, or sourced from a single config.** No "ask Dave for the staging token."
- **Every error has an actionable message.** "Connection refused" is not actionable. "Can't reach db.upstash.io on 5432 — check UPSTASH_REGION matches your project region" is.
- **Every step prints what it's about to do before it does it.** Quiet deploys are terrifying deploys.
- **The script can be run in dry-run mode.** `--dry-run` should print the plan and exit without changing anything.

```bash
$ ./scripts/deploy.ts --dry-run --env production
→ preflight: 14/14 env vars valid
→ DNS: would create CNAME app.example.com → otf-prod.edge.app
→ TLS: cert for app.example.com already issued (expires in 67 days)
→ app: would build image otf-saas:abc1234 and push to registry
→ verify: would hit 
no changes made (dry-run)
```

A dry-run output is the single most useful artifact a deploy script can produce. It's also documentation, a smoke test, and a teaching tool, all in one.

## What this enables — and what stays the same

A scripted deploy is the difference between a kit you adopt and a kit you abandon. When the deploy is real, every dev who picks up the template ships their first version in an afternoon instead of a weekend. When it's a README, the first deploy is a tax that compounds — every new contributor pays it, every quarter it rots a little more, and eventually someone forks the project to "fix the deploy" and the original template stops being the template.

The thing about a deploy script, once it's written, is that it doesn't churn. Models change, components change, even auth providers change. The DNS records, the certs, the env validation, the build pipeline — those are stable. The starter template's job is to ship that stable layer so the variable layer (the features you're actually building) is the only thing you have to think about.

This is the same bet OTF's full-stack kits make. One script wires a custom domain, DNS, TLS, and a mobile build. The kit ships a 24-item design checklist enforced by a script before any release — because the deploy is part of the product, not a separate thing that happens after the product. Whether you copy-paste it from the CLI or pull it from npm, that script is the part that doesn't change when your auth provider does. The parts that change are yours to write.

The worst-kept secret in starter templates is that most of them stop at the code. The code is the easy part. The deploy is where the template earns its keep.