Skip to content
LazyFrog

Publishing

How publishing works on LazyFrog: a test run first, no downtime, your live address, scheduled publishes, and going back if something breaks.

  • 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.

For your AI: this page as Markdown · llms.txt