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 publishanddeploy preflightare refused up front withplan_limit(limit: "live_sites", the plans page asupgrade_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 itdeploy publishis 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 schedulewithat(the business's local time with its UTC offset:2026-12-01T00:00:00+02:00; 10 minutes to 62 days ahead),user_asked: trueand asummary. 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 anddeploy publishwithkeep_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_readgetanddeploy_listshow one already waiting, and scheduling another is refused untildeploy cancelcancels 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 asummary: one or two sentences for the user, from the commits) puts the preview live athttps://<slug>.lazyfrog.app; the user can also press Publish in the portal. It runs as a job: follow it withjob_readget. 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 assecrets_in_code(file, line,kind, the name in the code,generatable, and asuggestionwith the environment variable to read and how);project_scanreturns 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:- 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.
- After a yes: values you can make yourself (
generatable: true: random salts, auth keys, session secrets) getsecrets setwith the suggested name andgenerate, 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 requestwith the suggested names, tell the user to enter the values in the portal link, and go on when they have. - Change the code to read each one from the environment, and take the value out of the file
(and out of
.envfiles that aren't git-ignored; keep the names in.env.example): Nodeprocess.env.NAME, Pythonos.environ["NAME"], PHP/WordPressgetenv('NAME')(define('AUTH_KEY', getenv('WP_AUTH_KEY'))), Goos.Getenv("NAME"), RubyENV.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). - Run
deploy preflightagain, 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_codegives them, and the commit refusal prints them) on a line of its own in.gitleaksignore, all of them for that line. A commentlazyfrog-allow-secreton a line silences only the checks ofdeploy preflightandproject_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: ifseed_offer.reasonisswitched_off, it isn't offered and the live site starts empty, as said above). At the project's first release only,deploy preflightreturnsseed_offer: whenavailableis 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 withseed_live_from_preview: true(the default is false). It takes a snapshot of the live site first, copies the preview's database and/dataafter 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: anafter_seedcommand 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'safter_seedhas its exit code and the last lines of its output. When the preview's own address appears inside the data and noafter_seedis declared,seed_offer.warningssays "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 needsDATABASE_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 preflightdoes the same without publishing: run it first when you changed services.json, migrations or the build.deploy_listshows every deployment and why one failed.- Before the first publish:
startandbuildcommands in services.json (devonly runs in preview), ahealthcheck_pathon web services that answers 200 without login (a/healthroute), and every preview secret also set for production (live keys there). Builds see only the production secrets set withbuild: true, and the rehearsal starts the app with those alone: a secret the app needs to start (Phoenix and RailsSECRET_KEY_BASE, Django'sSECRET_KEY,BETTER_AUTH_SECRET) isbuild: true. Commands that are the same in both environments (migrate, a sharedenv) tell them apart byLF_ENV(previeworproduction): setMIX_ENV,RAILS_ENVorAPP_ENVfrom it inbuild,startandmigrate(MIX_ENV=prodon production), asNODE_ENValready is. Elixir releases start withRELEASE_DISTRIBUTIONoff (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 productiondb_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=, Railsdefault:, 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 inself.ignored_columnsthe 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 withFOR UPDATE SKIP LOCKED. A scheduler inside a worker (Laravel'sschedule: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 acronservice instead (php artisan schedule:runevery 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 acronservice, or catch up from the last run on start. Dropping or narrowing anything, or deleting or updating every row, needsconfirm: true(tried on a copy first; a snapshot is taken). With the framework's ownmigratecommand 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). Astaticservice 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=2renaming is needed after a publish. deploy rollbackputs the previous deployment (orto) back live in seconds. The database stays as it is;with_database: truerestores it to just before the first later deployment changed its schema (everything written since is replaced, and kept as a snapshot), and needsconfirmand, 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, orsnapshot restorewith scopecode) unless the user wants it back, and say so. The live site stays on its platform version.project getsays 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 setrefuses acronservice, 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 insideexec(http://127.0.0.1:$PORT, many requests at once); for the form, run one real submission withpreview_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 inusage_get. - The edge refuses Python's
urllibdefault user agent (403 from Cloudflare). A script or service calling the site withurllibmust send its ownUser-Agent;requests,curland browsers are fine.
For your AI: this page as Markdown · llms.txt