Skip to content

Deployment

Production runs on Laravel Forge. Forge’s push-triggered Quick Deploy is off. The deploy job in the CI workflow runs only on pushes to main, only after quality, tests, audit and docs pass, and calls the Forge deployment trigger URL held in the FORGE_DEPLOY_URL repository secret under the production GitHub environment. A missing secret or a non-2xx answer from Forge fails the job so that a skipped deploy is never mistaken for a successful one (ADR-004).

An accepted trigger is not a finished deploy. After the trigger, the job runs scripts/await-release, which polls https://flowery.com.ua/release (or vars.PRODUCTION_URL) for up to ten minutes. It passes when production reports the deployed commit, or a newer commit that already contains it, because a later push was deployed meanwhile. Otherwise it fails. /release returns {"sha": "…"}: the commit read from the release’s git checkout when Forge caches the configuration (config('app.release'), overridable with APP_RELEASE). Competing deploys are serialised by the deploy-production concurrency group in CI and by Forge itself (S2-F06). When the repository variable STOREFRONT_RELEASE_URL is set, the job also waits up to five minutes for the Astro storefront’s /__storefront/release to report the commit.

flowchart LR
P[Push to main] --> Q[quality] & T[tests] & A[audit] & D[docs]
Q & T & A & D --> G{all green?}
G -->|yes| F[POST Forge trigger URL]
F --> R{/release reports this commit within 10 min?}
R -->|yes| OK[deploy job passes]
R -->|no| RF[deploy job fails: deploy did not complete]
G -->|no| X[deploy job fails, nothing deployed]

Before the first real shop, the deploy may be gated by a local run of every CI check instead (ADR-012). The repository variable DEPLOY_GATE chooses:

DEPLOY_GATE What a push to main waits for
unset or ci Every CI job (above)
local A local-checks commit status on a commit whose tree equals the merged commit

Shipping with the local gate:

Terminal window
git push # the pull request branch, up to date with main
scripts/check-all --stamp # every CI check locally, then stamp the pushed HEAD
gh pr merge <number> --merge # deploy-local triggers Forge right away

If the branch was behind main, the merge commit’s tree differs from the stamped one and nothing deploys: update the branch, run --stamp again, merge. If a pull request was merged before it was stamped, the deploy job fails and nothing deploys. Check out the merged main, run scripts/check-all --stamp, wait for the run to finish, then re-run its failed job with gh run rerun <run-id> --failed. This happened with PR #28 on 4 October 2026, and the re-run deployed it. The same applies to stacked pull requests: merging one PR into another’s branch creates a new, unstamped head, so after the final merge into main, stamp main and re-run the deploy job (PRs #31 and #30, 4 October 2026). Merging several stamped pull requests in a row does the same: from the second merge on, each main commit combines pull requests, so its tree matches no stamp. Only the first merge deploys, and the rest wait until main is stamped and the last run’s deploy job is re-run (#56–#59 on 6 October 2026: production stayed on #57 until then). GitHub CI still runs on every push in both modes; a red run on main after a local-gated deploy is fixed immediately. Switch with gh variable set DEPLOY_GATE --body local and back with gh variable delete DEPLOY_GATE; return to ci before launch.

  1. In Forge, turn off Quick Deploy for the site and copy the deployment trigger URL.
  2. In GitHub, add the repository secret FORGE_DEPLOY_URL (environment production).

Changes that need accompanying configuration

Section titled “Changes that need accompanying configuration”
Change Do this too
SESSION_DOMAIN Change SESSION_COOKIE as well, so browsers holding the old host-only cookie are not stuck logged out (ADR-006)
Google Places key Restrict it by HTTP referrer in Google Cloud; it is a browser key
Google sign-in client In Google Cloud: a Web client with the redirect URI https://flowery.com.ua/auth/google/callback and no JavaScript origins; consent screen External, scopes openid, email, profile, authorized domain flowery.com.ua. While it is in “Testing”, add the people who may sign in as test users; publishing needs a privacy policy page (ADR-019)
Stage 2 schema migrations Follow the cutover below
STOREFRONT_RUNTIME_KEY Change it in Laravel’s environment and in /home/forge/flowery.com.ua/storefront.env together, then restart the storefront daemon
Storefront port or paths Keep deploy/storefront/nginx-locations.conf, the daemon and storefront.env in step (storefront cutover)

The ledger migration (2026_09_13_000003_create_inventory_ledger) is additive and runs its own backfill, but it changes what stock means, so the first deployment carrying it follows this order:

  1. php artisan down so no supply or order writes happen during the switch.
  2. Take a recoverable snapshot: pg_dump -Fc flowery > pre-ledger.dump.
  3. php artisan migrate --force. The migration documents an opening balance for every batch and keeps unbatched flower stock as LEGACY review batches. Output lists counts and any flower whose total was raised to its batches.
  4. php artisan inventory:reconcile; it must exit 0 before reopening.
  5. php artisan orders:report-totals for the list of legacy totals to expect.
  6. php artisan up.
  7. Ask the shop to review LEGACY batches (Supplies → supplier “Legacy stock (pre-ledger)”) and set expiry and cost before those stems are sold; until then they are excluded from automatic allocation.

Rollback is a restore of the snapshot, not a down migration: pg_restore --clean --if-exists -d flowery pre-ledger.dump. The down migration exists for development databases only. This sequence was rehearsed on 14 September 2026 against a database built by the pre-stage code (see the Stage 2 closeout): the three migrations applied, backfills were idempotent, totals matched before and after, and reconciliation was clean apart from the expected legacy notes.

Rollback is a restore of a snapshot, never a down migration. scripts/restore-drill <ssh-host> <remote-app-dir> rehearses this on a disposable local database. Run it before any migration that changes what data means, and after changing the database server:

Terminal window
scripts/restore-drill chat-ui '~/flowery.com.ua/current'
  1. It streams a read-only pg_dump -Fc of production over SSH. Nothing is written on the server, and the credentials stay there.
  2. It restores the dump into a throwaway local flowery_restore_drill database and compares every table’s row count with production.
  3. It runs this checkout’s pending migrations and inventory:reconcile, writes the demo seed through the real services, and reconciles again.
  4. It rehearses the rollback by restoring the snapshot over the changed database (pg_restore --clean --if-exists), then compares the counts with production again.
  5. It drops the database and deletes the dump, even on failure.

First run, 4 October 2026 (S2-F06):

  • Production had 37 tables and 140 KB of data.
  • Row counts were identical after the restore and again after the rollback.
  • There was nothing to migrate, and reconciliation was clean at every step.
  • The drill found that production runs PostgreSQL 17.6 while CI and development ran 18. CI now runs 17 (ADR-007).

The site in docs/ builds to public/docs and is served by the same nginx as the application at /docs/ on the root domain, so no separate host is needed and the private repository is not a constraint. Forge’s deploy script must run npm run docs:build after npm ci (one-time edit of the script in Forge). Because the site lives under a path, content links are written with the /docs/ prefix; the base is set once in docs/astro.config.mjs.

Node on the server bounds the Astro version. The Forge server runs Node 22.21 (shared with other sites; node --version over SSH) and the CI runner the latest 22.x. Astro 7.4 requires Node 22.23.4 or newer, so a docs, landing or storefront lockfile that moves past Astro 7.3 builds locally but stops the deploy in the new release: Forge answers the trigger with 200, npm run docs:build exits 1, current never moves and the deploy job reports “Production still runs ” after its 600 s wait. Found on 25 October 2026 when npm audit fix in docs/ took Astro from 7.3.1 to 7.4.2 (#92); the fix pinned docs/package.json to ~7.3.5 (#93), and landing/ and storefront/ were pinned the same way on 26 October, with an ignore rule for astro >= 7.4 in dependabot.yml, because a pin alone does not stop Dependabot from raising the range (dependency policy). Before raising any Astro past 7.3, upgrade Node on the server first and raise the NODE_VERSION pin in ci.yml and the three pins in the same change; check npm view astro@<version> engines.

The landing page in landing/ builds to public/landing the same way and is shown on the root domain by LandingController. Forge’s deploy script must also run npm run landing:build after npm ci (one-time edit). Until it does, / keeps sending visitors to the sign-in page. Details: Landing page.

The Places key lives per workspace in the control panel. AI keys normally do too, but the platform can provide them from the environment for every shop that has not connected its own (ADR-017). The application runs without any of them.

Variable Production value Why
APP_DOMAIN flowery.com.ua Root routes are bound to it; a wrong value makes / a 404 while /login still works
SESSION_DOMAIN .flowery.com.ua One login across the root and every shop (ADR-006)
LOG_LEVEL warning debug writes request detail, and later customer data, to the log
DEMO_PASSWORD unset The demo seeder never runs in production
QUEUE_CONNECTION database One Forge worker: queue:work database, timeout below the 90 s retry_after
APP_LOCALE uk Ukrainian is the default language of server messages and staff emails; people who choose English keep it (locale cookie)
AI_GLOBAL_CREDENTIALS true Shops without their own AI connection use the platform keys below (ADR-017)
MAGNIFIC_API_KEY set Image generation (Magnific, formerly Freepik)
MAGNIFIC_WEBHOOK_KEY set Verifies Magnific’s signed webhooks; kept for when generation moves from polling to webhooks
OPENAI_API_KEY set when available Descriptions, image analysis and flower-name translation
GOOGLE_CLIENT_ID the Web client’s ID Sign in with Google (ADR-019); the button and routes stay off while it or the secret is empty
GOOGLE_CLIENT_SECRET the Web client’s secret Same client; only in the environment, never in the repository or a chat
GOOGLE_REDIRECT_URI unset (defaults to APP_URL/auth/google/callback) Must equal the client’s authorized redirect URI, https://flowery.com.ua/auth/google/callback
CLOUDFLARE_API_TOKEN a token scoped to the flowery.com.ua zone, SSL and Certificates: Edit Shops’ own domains (ADR-022, custom domains); Settings → Domain stays off while it, the zone id or the target is empty
CLOUDFLARE_ZONE_ID the zone’s id (Cloudflare → Overview) Same
CUSTOM_DOMAINS_CNAME_TARGET connect.flowery.com.ua The proxied name shops point their CNAME at
CLOUDFLARE_OAUTH_CLIENT_ID, CLOUDFLARE_OAUTH_CLIENT_SECRET our OAuth client at Cloudflare “Connect with Cloudflare” on Settings → Domain (custom domains); hidden while either is empty
CLOUDFLARE_OAUTH_SCOPES zone.read dns.write What the consent screen asks for
DOMAIN_CONNECT_PRIVATE_KEY base64 of the RSA private key PEM Signs Domain Connect requests (custom domains); public half is TXT _dcpubkeyv1.flowery.com.ua; the button is hidden while empty

Laravel’s scheduler must run every minute: in Forge → Server → Scheduler, add php /home/forge/flowery.com.ua/current/artisan schedule:run as user forge, every minute. It runs:

  • carts:purge daily at 03:00;
  • uklon:sync every five minutes, which catches up Uklon couriers when webhooks go quiet (checkout);
  • domains:check every five minutes, which reads shops’ own domains from Cloudflare: domains not yet live every few minutes, live ones daily (custom domains). --all checks every domain now;
  • onboarding:remind daily at 10:00 Kyiv, which emails owners who stalled during onboarding, once per step (ADR-020). --dry-run lists who is due without sending.

Checking it. Forge writes scheduled jobs into the system crontab, /etc/crontab, with the user in the line. It does not use the forge user’s own crontab, so crontab -l says “no crontab for forge” even when the scheduler runs. Check read-only:

Terminal window
grep flowery /etc/crontab
# * * * * * forge php8.4 /home/forge/flowery.com.ua/current/artisan schedule:run > /home/forge/.forge/scheduled-<id>.log 2>&1
ls -la --time-style=full-iso /home/forge/.forge/scheduled-<id>.log # rewritten every minute
cat /home/forge/.forge/scheduled-<id>.log # "No scheduled commands are ready to run." between tasks
cd /home/forge/flowery.com.ua/current && php artisan schedule:list

Verified on 6 October 2026: the entry is in /etc/crontab, the log is rewritten every minute, and uklon:sync ran at 23:15 UTC on 5 October. An earlier note here said production had no scheduler. It was wrong: it looked only at crontab -l and /etc/cron.d.

Production sends through Resend: MAIL_MAILER=resend, RESEND_API_KEY, MAIL_FROM_ADDRESS="[email protected]". The transport needs the resend/resend-php package; tests/Feature/MailTransportTest.php fails if it is removed. Email verification gates onboarding (User implements MustVerifyEmail), so with any other mailer (the earlier log) nobody can create a shop.

One-time setup:

  1. In Resend, add the domain flowery.com.ua and copy the DNS records it lists (DKIM TXT resend._domainkey, and the SPF TXT and MX on the send subdomain).
  2. In Cloudflare, add them as DNS only (grey cloud) and wait for Resend to show the domain verified. Add a DMARC record (TXT _dmarc, v=DMARC1; p=none; to start) if none exists.
  3. Create an API key with sending access restricted to that domain and put it in Forge’s environment, never in the repository or chat.
  4. Deploy (the config cache is rebuilt) and register a test account: the verification email must arrive.

Inertia SSR is off (INERTIA_SSR_ENABLED=false, the default). Pages render in the browser; the deploy script runs npm run build, not build:ssr, and no SSR daemon is needed.

When it is switched on, the SSR server needs a port no other site on the host uses. Set INERTIA_SSR_PORT (Laravel’s URL) and VITE_INERTIA_SSR_PORT="${INERTIA_SSR_PORT}" (baked into the bundle at build time, so rebuild after changing it), build with npm run build:ssr, and run a daemon php artisan inertia:start-ssr in the site’s current/ directory.

Found on 4 October 2026: production shares its Forge server with other Laravel sites, and port 13714 (Inertia’s default, which the config used to hard-code) already belonged to a neighbour’s SSR server. inertia:start-ssr first posts a shutdown to the configured URL, so a Flowery bundle built on the default port would have stopped the neighbour’s renderer and then contended for its port. The port is now configurable and SSR off by default.

SSR is not yet correct for this application, so do not enable it before that work is done: i18next is initialised only in the browser (language from localStorage, no shared locale prop), so the server renders translation keys; app.tsx uses createRoot rather than hydrateRoot, discarding server markup; several components touch localStorage, window or document; and CI has no per-page SSR render check. The benefit is limited to the public storefront.