# Publishing (https://lazyfrog.dev/docs/guide/publishing)

{/* Generated by scripts/generate-docs.ts from services/gateway/src/guide.md. Do not edit. */}

- **Publishing needs a paid plan** (Hobby, Pro or Business). On Free, `deploy publish` and `deploy
  preflight` are refused up front with `plan_limit` (`limit: "live_sites"`, the plans page as
  `upgrade_url`): tell the user in plain words that going live needs a paid plan, that their
  preview stays free to share while it's awake, and give the link; then carry on building in the
  preview. Auto-publish can't be turned on, and a push from GitHub updates the preview only. A
  Free site that was live before this rule came in stays live for 30 days after its owner was
  emailed (it can still be published until then), then shows "This site is paused. Its owner can
  bring it back by upgrading." until the account is on a paid plan; nothing is deleted, and it
  comes back by itself within minutes of the upgrade.
- **Publish only when the user asked** in this conversation ("publish it", "put it live", "update
  the live site"), with `user_asked: true`; without it `deploy publish` is refused. Finishing the
  build, an agreed plan or "go ahead" isn't asking: tell them it's ready in the preview and ask.
  A change they want live at a later time ("new prices from the 1st, I'm away that week", "the
  sale page on Friday at 8") is asking too: schedule it, don't build a date switch into the site.
- **Scheduling a publish.** Make the change in the preview and check it as for any publish, then
  `deploy schedule` with `at` (the business's local time with its UTC offset:
  `2026-12-01T00:00:00+02:00`; 10 minutes to 62 days ahead), `user_asked: true` and a `summary`.
  The preview's work is committed and built and rehearsed at once (a job: follow it, and fix what
  it finds now rather than at midnight); that version, not whatever the preview holds later, goes
  live at its time, after a rehearsal on the live data as it is then. Tell the user what changes
  and when, in their time, that the live site stays as it is until then, and that they get an
  email when it has run. Keep working in the preview meanwhile if they ask, but a publish (or a
  rollback) before that time cancels the scheduled one, since it would replace that change: say
  so before publishing, and schedule it again afterwards if it should still happen. An urgent
  fix to the live site before then: make it in the preview on top of the scheduled change and
  `deploy publish` with `keep_schedule: true`. Only live's version plus what you added after the
  scheduled one goes live; the scheduled change is then built and rehearsed again on top, as a new
  deployment number with the same time (the result names its job: follow it). Tell the user the
  fix is live and the scheduled change still goes live at its time. It is refused, with nothing
  published and the schedule kept, when the fix changes the same lines as the scheduled change
  (say so; fix it some other way, or wait) and within 30 minutes of its time. One per
  project: `project_read` `get` and `deploy_list` show one already waiting, and scheduling another is
  refused until `deploy cancel` cancels it (tell the user the earlier one is replaced). A change
  with an end date (Christmas prices until 31 December) is two schedules: schedule the second
  once the first has gone live, and tell the user it's needed (the plan is a good place to note
  it). A change that removes or rewrites
  live data (a destructive migration) can't be scheduled: it needs the owner's go-ahead when it
  runs.
- `deploy publish` (with a `summary`: one or two sentences for the user, from the commits) puts
  the preview live at `https://<slug>.lazyfrog.app`; the user can also press Publish in the
  portal. It runs as a job: follow it with `job_read` `get`. It commits and tags your work, builds a copy,
  runs the pending migrations on a copy of the live database, starts every service as production
  does and checks its health, and only then migrates the live database (after a snapshot) and
  switches over. The old version finishes its requests: visitors see no downtime. WebSockets on the
  old version are closed with code 1001 as soon as the new one takes its first request, so
  reconnect on close; until then, for a few seconds, both versions run.
- **Before publishing, always run `deploy preflight`.** It also looks for keys, passwords and
  salts written into the code and returns them as `secrets_in_code` (file, line, `kind`, the name
  in the code, `generatable`, and a `suggestion` with the environment variable to read and how);
  `project_scan` returns the same list. Never the values. Publishing refuses code that contains
  them (values in code end up in snapshots, backups, exports and remixes), and there is no
  "publish anyway" for them. Nothing is moved on your own:
  1. Ask the user once, for the whole batch, in plain words: "I found N secrets in your project's
     code (what and where, no values). I'd like to move them to Secrets so they are not stored in
     your project's history or backups. OK to do that?" Wait for the answer.
  2. After a yes: values you can make yourself (`generatable: true`: random salts, auth keys,
     session secrets) get `secrets set` with the suggested name and `generate`, once for env
     preview and once for env production, with a one-line notice to the user. Real third-party
     credentials (`generatable: false`: payment and API keys, mail and database passwords, OAuth
     client secrets) are never typed by you: `secrets request` with the suggested names, tell the
     user to enter the values in the portal link, and go on when they have.
  3. Change the code to read each one from the environment, and take the value out of the file
     (and out of `.env` files that aren't git-ignored; keep the names in `.env.example`):
     Node `process.env.NAME`, Python `os.environ["NAME"]`, PHP/WordPress `getenv('NAME')`
     (`define('AUTH_KEY', getenv('WP_AUTH_KEY'))`), Go `os.Getenv("NAME")`, Ruby
     `ENV.fetch("NAME")`. A config file (YAML, JSON, TOML) can't read the environment: have the
     code fill that setting. If a value was committed earlier it is still in the history: tell
     the user to treat it as exposed and replace it (rotate the key at its provider).
  4. Run `deploy preflight` again, and tell the user what you changed.
  If the user says no: say that publishing will keep refusing code that contains secrets, as a
  safety floor, and offer to remove the value or put a placeholder in its place instead.
  A finding that is not a secret (a made-up key in documentation, a test token): only when the
  user confirms it, list its fingerprint (`secrets_in_code` gives them, and the commit refusal
  prints them) on a line of its own in `.gitleaksignore`, all of them for that line. A comment
  `lazyfrog-allow-secret` on a line silences only the checks of `deploy preflight` and
  `project_scan`, never the commit's own scan, and its use is listed in the preflight's notes:
  never add it, or an ignore line, to get past a finding without the user's say-so.
- **Preview is the workshop, live is the shop.** Code flows to the live site at every publish;
  data never flows by itself. The live site has its own database and `/data`, and starts empty:
  the preview's pages, settings and admin account are not copied. Say this to the user plainly
  before the first publish of an app that keeps state (a CMS, anything with an installer,
  sign-ups or admin accounts): "Your live site starts empty. After publishing, open
  the live address followed by its installer or admin page once to set up the live site." After that, content is
  edited on the live site, and changes made in the preview never appear there.
  **The one-time copy** (when it is switched on: if `seed_offer.reason` is `switched_off`, it isn't
  offered and the live site starts empty, as said above). At the project's first release only, `deploy preflight` returns
  `seed_offer`: when `available` is true the preview has data worth copying. Ask the user once, in plain words ("Do you want me to copy your preview's data into the
  live site, one time? After that they are separate."), and if yes publish with
  `seed_live_from_preview: true` (the default is false). It takes a snapshot of the live site
  first, copies the preview's database and `/data` after the rehearsal passes and before the
  switch, never copies secrets or environment values, and takes it all back if anything fails.
  Once the live site has data there is no such option: preview and live keep separate data and
  publishing updates code, never live data (replacing live data from the preview is not
  offered). What the copied data needs before it goes live is the app's to declare, before you
  offer the copy: an `after_seed` command in services.json (above, under Services), which runs
  after the copy with the preview's and the live origin and takes the copy back if it fails;
  the publish result's `after_seed` has its exit code and the last lines of its output. When
  the preview's own address appears inside the data and no `after_seed` is declared,
  `seed_offer.warnings` says "your app's data mentions the preview address": declare one that
  rewrites it (the app's own tool, which knows its serialized formats, rather than a raw SQL
  replace), run preflight again, then ask the user. After a copy, say
  "Preview is a testing copy, live is the real data: from now on write content on the live
  site."
- **Real time** (chat, live dashboards): WebSockets and server-sent events pass through, on a
  rewrite or the `<service>--<site>` address. An open connection keeps the live site awake while
  it lasts (not the preview: there only a visit or your calls do); send a heartbeat at least every 100 seconds (the edge closes idle ones), reconnect on any
  close, and catch up from the last message id. LISTEN/NOTIFY needs `DATABASE_URL`, not the pool.
  One instance per environment, so an in-memory fan-out works (Phoenix PubSub, a Node
  EventEmitter), except for the few seconds a publish runs two: catch up from the last message id
  after a reconnect, or fan out through Postgres LISTEN/NOTIFY. For an event night (an auction, a
  live quiz), keep the site always on: a cold wake is about two seconds. Cap streams per user. Close the
  stream when the tab is hidden (`visibilitychange`) and reopen it on return: an open tab keeps
  the live site awake.
- When the job finishes, tell the user the address and what changed. If the project isn't private,
  its result has `share_url`: a page with a picture of the site that shows well when posted. Offer it.
  New projects are private; the owner opens them up in Project › Settings › Who may remix.
- `deploy preflight` does the same without publishing: run it first when you changed services.json,
  migrations or the build. `deploy_list` shows every deployment and why one failed.
- Before the first publish: `start` and `build` commands in services.json (`dev` only runs in
  preview), a `healthcheck_path` on web services that answers 200 without login (a `/health`
  route), and every preview secret also set for production (live keys there). Builds see only the
  production secrets set with `build: true`, and the rehearsal starts the app with those alone:
  a secret the app needs to start (Phoenix and Rails `SECRET_KEY_BASE`, Django's `SECRET_KEY`,
  `BETTER_AUTH_SECRET`) is `build: true`. Commands that are the same in both environments
  (`migrate`, a shared `env`) tell them apart by `LF_ENV` (`preview` or `production`): set
  `MIX_ENV`, `RAILS_ENV` or `APP_ENV` from it in `build`, `start` and `migrate` (`MIX_ENV=prod`
  on production), as `NODE_ENV` already is. Elixir releases start with `RELEASE_DISTRIBUTION`
  off (two overlap at a publish, and would claim the same node name); set it only to cluster.
- Production has its own database and `/data`: they start empty and a publish never copies
  preview data there (apart from the one-time copy at the first publish, when asked for). Seed what production needs in migrations (idempotent inserts), or with a confirmed
  production `db_query`.
- Migrations must work with the version still live for a few seconds (and with the one a
  rollback brings back): add a column with a default or nullable (Django 5 `db_default=`, Rails
  `default:`, Prisma `@default`; a NOT NULL column with no database default breaks the old code's
  inserts), deploy code that uses it, then drop the old one in a later publish (Rails: list it in
  `self.ignored_columns` the publish before, since ActiveRecord names every column it knows in inserts). The new release's workers start about
  ten seconds before the switch, while the old ones still run (they stop at the switch): make jobs
  idempotent and claim them with `FOR UPDATE SKIP LOCKED`. A scheduler inside a worker
  (Laravel's `schedule:work`, Solid Queue's recurring tasks, Oban's cron, node-cron) runs in both
  releases for those seconds, so a task due then runs twice: give it a `cron` service instead
  (`php artisan schedule:run` every minute), or the framework's lock (Laravel `->onOneServer()`,
  Oban unique jobs). A leader-elected scheduler (River's periodic jobs) never doubles, but a run
  due in the second of the switch can be skipped: use a `cron` service, or catch up from the last
  run on start. Dropping or narrowing anything, or
  deleting or updating every row, needs `confirm: true` (tried on a copy first; a snapshot is taken).
  With the framework's own `migrate` command the rehearsal compares the copy's tables and columns
  before and after it, so a dropped table or column, or a changed type, needs the same confirm.
- A publish clears the live site's whole cache at the edge, hashed files included. Hashed asset
  filenames (what Next.js, Vite, SvelteKit, Propshaft and Django's manifest storage produce) keep
  visitors' browsers right; a release serves only its own, so a page opened just before the switch
  may miss the old ones until reloaded. Give unhashed static files short browser max-ages (Rails
  gives everything in `public/` a year). A `static` service does this for you: browsers keep
  only files with a hash in their name, and check every other file (`styles.css`, `app.js`) on
  each visit, so no `?v=2` renaming is needed after a publish.
- `deploy rollback` puts the previous deployment (or `to`) back live in seconds. The database
  stays as it is; `with_database: true` restores it to just before the first later deployment
  changed its schema (everything written since is replaced, and kept as a snapshot), and needs
  `confirm` and, when you ask, the owner's approval in the portal. A rollback changes the live
  site only: the preview keeps the rolled-back work, so the next publish takes it live again.
  Undo it in the preview too (`git revert`, or `snapshot restore` with scope `code`) unless the
  user wants it back, and say so. The live site stays on its platform version. `project get`
  says what's live and what the preview has that isn't.
- On Free the preview carries a small "Built with LazyFrog" badge (bottom right: keep about 56 px
  free there, and no fixed buttons; it's the platform's, not part of the app, so leave it out of
  what you fix). Every live site sleeps after 15 quiet minutes (the first visitor
  wakes it: a second or two for a light service, about four for a Next.js app, more for
  several) unless it's always on, which paid plans allow. Free runs no scheduled jobs (`service
  set` refuses a `cron` service, and one written into services.json doesn't start) and 2 services
  per project: say so before building reminders or anything on a timer, and offer Hobby.
- **A rush of visitors** (on the radio, in a newsletter): don't load-test through the public
  address from `exec`. Thousands of requests from one address look like an attack to the edge
  and are refused or dropped, so the result measures that, not the site. Test the app's own
  server inside `exec` (`http://127.0.0.1:$PORT`, many requests at once); for the form, run one
  real submission with `preview_try`. What matters on the day: always on for the live site (no
  wake-up wait), pages that don't hit the database on every visit, and the bandwidth left in
  `usage_get`.
- The edge refuses Python's `urllib` default user agent (403 from Cloudflare). A script or service
  calling the site with `urllib` must send its own `User-Agent`; `requests`, `curl` and browsers
  are fine.
