Deployment
Pipeline
Section titled “Pipeline”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]Local gate before launch
Section titled “Local gate before launch”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:
git push # the pull request branch, up to date with mainscripts/check-all --stamp # every CI check locally, then stamp the pushed HEADgh pr merge <number> --merge # deploy-local triggers Forge right awayIf 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.
One-time setup
Section titled “One-time setup”- In Forge, turn off Quick Deploy for the site and copy the deployment trigger URL.
- In GitHub, add the repository secret
FORGE_DEPLOY_URL(environmentproduction).
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) |
Stage 2 inventory cutover
Section titled “Stage 2 inventory 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:
php artisan downso no supply or order writes happen during the switch.- Take a recoverable snapshot:
pg_dump -Fc flowery > pre-ledger.dump. php artisan migrate --force. The migration documents an opening balance for every batch and keeps unbatched flower stock asLEGACYreview batches. Output lists counts and any flower whose total was raised to its batches.php artisan inventory:reconcile; it must exit 0 before reopening.php artisan orders:report-totalsfor the list of legacy totals to expect.php artisan up.- Ask the shop to review
LEGACYbatches (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.
Backup and restore drill
Section titled “Backup and restore drill”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:
scripts/restore-drill chat-ui '~/flowery.com.ua/current'- It streams a read-only
pg_dump -Fcof production over SSH. Nothing is written on the server, and the credentials stay there. - It restores the dump into a throwaway local
flowery_restore_drilldatabase and compares every table’s row count with production. - It runs this checkout’s pending migrations and
inventory:reconcile, writes the demo seed through the real services, and reconciles again. - It rehearses the rollback by restoring the snapshot over the changed database (
pg_restore --clean --if-exists), then compares the counts with production again. - 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).
Documentation site
Section titled “Documentation site”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 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.
Landing page
Section titled “Landing page”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.
Environment
Section titled “Environment”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 |
Scheduler
Section titled “Scheduler”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:purgedaily at 03:00;uklon:syncevery five minutes, which catches up Uklon couriers when webhooks go quiet (checkout);domains:checkevery five minutes, which reads shops’ own domains from Cloudflare: domains not yet live every few minutes, live ones daily (custom domains).--allchecks every domain now;onboarding:reminddaily at 10:00 Kyiv, which emails owners who stalled during onboarding, once per step (ADR-020).--dry-runlists 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:
grep flowery /etc/crontab# * * * * * forge php8.4 /home/forge/flowery.com.ua/current/artisan schedule:run > /home/forge/.forge/scheduled-<id>.log 2>&1ls -la --time-style=full-iso /home/forge/.forge/scheduled-<id>.log # rewritten every minutecat /home/forge/.forge/scheduled-<id>.log # "No scheduled commands are ready to run." between taskscd /home/forge/flowery.com.ua/current && php artisan schedule:listVerified 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:
- In Resend, add the domain
flowery.com.uaand copy the DNS records it lists (DKIMTXT resend._domainkey, and the SPFTXTandMXon thesendsubdomain). - 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. - Create an API key with sending access restricted to that domain and put it in Forge’s environment, never in the repository or chat.
- Deploy (the config cache is rebuilt) and register a test account: the verification email must arrive.
Server-side rendering
Section titled “Server-side rendering”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.