Deployment Guide (All Environments)
This is the authoritative guide for deploying Meo Mai Moi in development, staging, and production.
There are now two deployment entrypoints:
- Manual/operator deploys use:
./utils/deploy.sh [--seed] [--fresh] [--no-cache] [--skip-build] [--no-interactive] [--quiet]- CI-driven development deploys use:
./utils/deploy-ci-dev-ab.sh- CI-driven production deploys use:
./utils/deploy-ci-prod-ab.shSee ./utils/deploy.sh --help for the full manual/operator options.
Prerequisites
- Docker and Docker Compose installed
- Git installed and configured on the server
- Production: HTTPS terminated at your reverse proxy (nginx/caddy/traefik/Cloudflare)
- No host-level Bun installation is required for docs builds
Environment configuration
The deploy script uses a dual-file approach:
- Root
.env: Docker Compose variables (build args likeVAPID_PUBLIC_KEY, database credentials for the container) backend/.env: Laravel runtime configuration (APP_KEY, mail settings, etc.)
If these files don't exist, the deploy script will create them interactively (or non‑interactively with defaults when --no-interactive is used).
Root .env important variables:
VAPID_PUBLIC_KEY,VAPID_PRIVATE_KEY(for push notifications - generate withbun x web-push generate-vapid-keys)- Optional Umami analytics for the frontend SPA:
VITE_UMAMI_URLVITE_UMAMI_WEBSITE_IDVITE_UMAMI_DOMAINS(comma-separated allowlist, optional)VITE_UMAMI_DEBUG,VITE_UMAMI_LAZY_LOAD(optional flags)
POSTGRES_DB,POSTGRES_USER,POSTGRES_PASSWORD(must matchbackend/.envDB_* values)- Optional backend image override for registry-based deploys:
BACKEND_IMAGEBACKEND_IMAGE_PULL_POLICY
- Optional PHP runtime base image override:
PHP_RUNTIME_IMAGE
- Optional A/B rollback-buffer TTL in minutes:
AB_OLD_SLOT_TTL_MINUTES(30by default; set to0to keep the previous slot running indefinitely)
- Optional host port bindings for shared servers:
BACKEND_HOST_BIND,BACKEND_HOST_PORTREVERB_HOST_BIND,REVERB_HOST_PORTDB_HOST_BIND,DB_HOST_PORTHTTPS_HTTP_HOST_BIND,HTTPS_HTTP_HOST_PORTHTTPS_HTTPS_HOST_BIND,HTTPS_HTTPS_HOST_PORT
- Telegram user-bot runtime config lives in
backend/.env, not root.env:TELEGRAM_USER_BOT_TOKEN,TELEGRAM_USER_BOT_USERNAME,TELEGRAM_USER_BOT_WEBHOOK_SECRET_TOKEN - Optional:
DOCS_STRICT_LINKScontrols whether docs dead links fail builds in development (falseby default in development,trueby default in staging/production)
Umami note: these VITE_UMAMI_* values are build-time inputs for the frontend bundle. After changing them, rebuild/redeploy the backend image so the SPA assets are regenerated with the new analytics configuration.
backend/.env important variables:
APP_ENV(development|staging|production)APP_URL(e.g., https://example.com or https://localhost)DB_*(DB host, name, user, password - must match root.envPOSTGRES_* values)- Optional:
DEPLOY_HOST_PORTto override the host port used by deployment verification. If omitted, deploy verification followsBACKEND_HOST_PORTfrom the root.env, then falls back to8000.
Documentation build contract
- Docs are built in a disposable Bun Docker container (
oven/bun:1) during deploy. - The backend serves docs by bind-mounting
docs/.vitepress/distto/var/www/public/docs. - Deploy validates docs artifacts before starting containers.
- In
stagingandproduction, deployment fails ifdocs/.vitepress/dist/index.htmlis missing. - In
production, deployment also fails if the docs mount source is empty.
- In
- Dead-link policy:
productionandstaging: strict by default (DOCS_STRICT_LINKS=truebehavior).development: non-strict by default (DOCS_STRICT_LINKS=false), so deploy can continue with existing docs artifact if the docs rebuild fails.
Deployments
Development
./utils/deploy.sh # migrate only, preserves data
./utils/deploy.sh --seed # migrate + seed sample data
./utils/deploy.sh --skip-build # skip Docker image builds (uses existing images)For CI-driven A/B deployment on the server, use the environment-specific entrypoint:
./utils/deploy-ci-dev-ab.sh
./utils/deploy-ci-prod-ab.shThese scripts deploy into the inactive slot, verify that slot, then switch the reverse proxy only after the new slot is healthy. They intentionally skip the old self-updating git sync flow because CI already decides which commit is being deployed.
Typical Woodpecker flow:
- SSH to the target host and reset the long-lived checkout to the exact pushed commit
- log into the private registry on the target host (needed to pull the prebuilt PHP runtime base image referenced by
backend/Dockerfile) - run the matching A/B deploy script with:
BACKEND_IMAGE=<registry>/<image>:<commit-sha>(a per-commit local tag)BACKEND_IMAGE_PULL_POLICY=missingDEPLOY_USE_PREBUILT_IMAGE=false
- the script builds the backend image on the target host, reusing the host Docker layer cache, then builds docs on-host, verifies the inactive slot, and switches the reverse proxy
- send deployment notifications, if configured:
- deploy started
- A/B switch completed
- deploy finished or failed
Woodpecker itself never builds images. No build step mounts /var/run/docker.sock, so a push cannot turn into Docker-daemon (root) access on the CI agent host. Image assembly happens only on the target host through the SSH deploy key, using that host's own Docker layer cache.
Because the image is built on the target host, the Docker build-time frontend inputs come from the target host's root .env (read by docker compose build), not from a Woodpecker secret. Keep these populated in the deploy checkout's root .env:
VAPID_PUBLIC_KEY=...
VITE_REVERB_APP_KEY=...
VITE_REVERB_HOST=...
VITE_REVERB_PORT=...
VITE_REVERB_SCHEME=...
VITE_UMAMI_URL=...
VITE_UMAMI_WEBSITE_ID=...
VITE_UMAMI_DOMAINS=...
VITE_UMAMI_DEBUG=false
VITE_UMAMI_LAZY_LOAD=falseFor a shared-host A/B deployment, root .env usually includes:
BACKEND_HOST_BIND=127.0.0.1
BACKEND_HOST_PORT=<legacy-or-single-slot-port>
REVERB_HOST_BIND=127.0.0.1
REVERB_HOST_PORT=<legacy-or-single-slot-reverb-port>
SLOT_A_BACKEND_HOST_BIND=127.0.0.1
SLOT_A_BACKEND_HOST_PORT=<slot-a-backend-port>
SLOT_A_REVERB_HOST_BIND=127.0.0.1
SLOT_A_REVERB_HOST_PORT=<slot-a-reverb-port>
SLOT_B_BACKEND_HOST_BIND=127.0.0.1
SLOT_B_BACKEND_HOST_PORT=<slot-b-backend-port>
SLOT_B_REVERB_HOST_BIND=127.0.0.1
SLOT_B_REVERB_HOST_PORT=<slot-b-reverb-port>
AB_OLD_SLOT_TTL_MINUTES=30
DB_HOST_BIND=127.0.0.1
DB_HOST_PORT=<optional-local-db-port>
DB_SERVICE_MODE=external
DB_EXTERNAL_CONTAINER=<external-postgres-container>
SHARED_SERVICES_NETWORK_EXTERNAL=true
SHARED_SERVICES_NETWORK_NAME=<shared-docker-network>And in backend/.env:
APP_ENV=production
APP_URL=https://example.com
ENABLE_HTTPS=false
DB_HOST=<postgres-host>
DB_PORT=5432
DB_DATABASE=<database-name>
DB_USERNAME=<database-user>
DB_PASSWORD=replace-meThis keeps Docker ports private to the host, lets the host reverse proxy own public 80/443, and retires the previously active slot after a short rollback window by default.
In this mode, the backend joins an external Docker network and uses a shared PostgreSQL service instead of starting its own long-lived local db service.
Operational note:
- after the first successful slot rollout, the legacy single-backend
backendservice should no longer stay running deploy-ci-dev-ab.shnow stops that legacy service automatically whenever an active A/B slot already exists, and again after a successful switch- this prevents the old container from holding slot ports
Production A/B slots
Production now uses the same slot-based rollout shape as development, but with a dedicated production slot helper:
./utils/deploy-ci-prod-ab.shProduction uses the same slot environment variables shown above, usually with a shorter rollback-buffer TTL than development.
The active production slot is tracked in a marker file:
<deploy-path>/.deploy-active-slot-prodThe production A/B flow is:
- determine the inactive slot
- pull and start only that target slot
- verify that target slot on its host-bound port
- rewrite the reverse proxy vhost from the configured template
- validate and reload the reverse proxy, then mark the new slot active
- stop the legacy single-backend service after the first successful slot rollout
Operational note:
backend_aandbackend_bboth run the samesupervisordprograms, including Laravel's scheduler.During A/B rollouts, both slots may be alive at the same time for a while, so scheduled commands must be safe to run more than once.
Prefer idempotent scheduled jobs or move scheduling to a single dedicated runtime if a task cannot tolerate duplicate execution.
after the switch, the previously active slot is intentionally kept alive for
AB_OLD_SLOT_TTL_MINUTESminutes (30by default)this is the rollback buffer for production; if the new slot misbehaves after cutover, switch NGINX back before the TTL expires instead of waiting for a rebuild
expect both
backend_aandbackend_bto be alive briefly after a successful production rolloutthe tradeoff is temporary extra memory usage, rather than indefinite dual-slot steady state
only the inactive target slot is stopped before rebuild; the old active slot is not treated as stale cleanup
Important reverse-proxy note:
- the host reverse-proxy vhost must be a pure reverse proxy to the active slot
- do not keep host-side document-root or
try_filesrules in the public app vhost - otherwise the host can serve
public/index.phpas a static file instead of forwarding to PHP-FPM inside the active backend container - slot activation should always validate the proxy config before reload
Development A/B slots
A development deployment can use two backend slots on the same host:
- slot
a->backend_aon configured slot A ports - slot
b->backend_bon configured slot B ports
The active slot is tracked in:
<deploy-path>/.deploy-active-slotUseful operational commands:
cd <deploy-path>
./utils/dev-slot.sh status
./utils/dev-slot.sh active
./utils/dev-slot.sh inactiveThe A/B deploy flow is:
- determine the inactive slot
- build or pull and start only that target slot
- run migrations and application checks against the target slot
- rewrite the reverse-proxy vhost from the configured template
- validate and reload the reverse proxy, then mark the new slot active
This keeps the previous slot available as a rollback target for a short period and avoids the old blanket docker compose stop behavior during development slot deploys.
Registry-backed CI/CD
meo-mai-moi supports two deployment shapes:
- manual/operator deploys: local source checkout plus local Docker build
- Woodpecker CI deploys: CI SSHes into the target host, which builds the image locally using its own Docker layer cache (CI never builds images itself)
The Compose file keeps both build: and image: on the backend services. The active mode is selected by environment:
- default/manual: no override, so Compose uses
meomaimoi/backend:local - CI deploys: export
BACKEND_IMAGEto a per-commit tag and keepDEPLOY_USE_PREBUILT_IMAGE=falseso the target host builds and tags that image
When DEPLOY_USE_PREBUILT_IMAGE=false (the default and the CI behavior), deploy.sh builds the backend image on-host. The CI lane sets BACKEND_IMAGE to a per-commit tag and BACKEND_IMAGE_PULL_POLICY=missing so Compose reuses the just-built local image instead of pulling. Setting DEPLOY_USE_PREBUILT_IMAGE=true is the alternate pull-only mode: deploy.sh then skips the on-host build and runs docker compose pull followed by docker compose up -d --no-build. The current Woodpecker pipeline uses the on-host build mode, not pull-only.
Prebuilt PHP runtime base image
To keep on-host builds fast, backend/Dockerfile builds FROM a prebuilt runtime base image (pinned by a dated tag via the PHP_RUNTIME_IMAGE build arg). That base carries the heavy, slow-changing PHP extensions, system packages, and Composer.
For local development, leave PHP_RUNTIME_IMAGE unset. deploy.sh will build backend/Dockerfile.runtime-base once as meomaimoi/runtime-base:php-8.5-fpm-local when the local image is missing, then use that tag for the backend build.
Registry-backed environments should set PHP_RUNTIME_IMAGE explicitly to the pinned private base tag. The base is rebuilt intentionally from backend/Dockerfile.runtime-base:
docker build -f backend/Dockerfile.runtime-base \
-t <registry>/<namespace>/meo-mai-moi/runtime-base:php-8.5-fpm-<YYYYMMDD> .
docker push <registry>/<namespace>/meo-mai-moi/runtime-base:php-8.5-fpm-<YYYYMMDD>After pushing a new base tag, update the PHP_RUNTIME_IMAGE default in backend/Dockerfile to match so deploys pick it up. The target host needs registry login to pull this base during its on-host build, which is why the CI deploy step runs docker login before invoking the deploy script.
Note: Use --skip-build for faster deployments when you have already built the Docker images and just need to restart containers or run migrations.
Memory Optimization: In development environments (APP_ENV=development), the legacy single-slot deploy stops containers before build to reduce peak memory usage. In A/B mode, the deploy keeps the active slot running and only stops the inactive target service if needed. Production and staging environments build images while services are still running to minimize downtime.
HTTPS in development is handled by the https-proxy service (compose profile https).
To enable HTTPS locally:
- Set in
backend/.env:
APP_ENV=development
ENABLE_HTTPS=true- Generate self‑signed certificates (one time):
./utils/generate-dev-certs.sh- Deploy:
./utils/deploy.shAccess:
- App: http://localhost:8000 or https://localhost
- Admin: http(s)😕/localhost/admin
- Docs: http(s)😕/localhost/docs
Staging / Production
Use the same command on the server:
./utils/deploy.sh --no-interactive --quietNotes:
- The backend container serves HTTP on port 80. In production A/B mode, terminate HTTPS at your reverse proxy and forward to the active slot host port (
8011or8012) via the generated NGINX vhost. - CI-based production rollout prefers the A/B entrypoint above, which verifies the inactive slot before the public switch.
- Migrations run via the deploy script only (the container’s entrypoint has
RUN_MIGRATIONS=falseto avoid race conditions). - Backups are managed outside this repository by shared infrastructure; deploy scripts no longer create repo-managed backups.
- Deploy fails fast if docs artifacts are missing/invalid for staging and production.
- In external PostgreSQL mode, backup and restore helpers must use client tools compatible with the shared server version; prefer the shared DB container over the app container for
pg_dump/psql.
Branch strategy
Deployment target branch is determined by environment and can be customized:
- Defaults:
- production →
main - staging →
staging - development →
dev
- Project‑level overrides: create a
.deploy-configfile in the repo root or base on the example:
# .deploy-config.example
DEPLOY_BRANCH_PRODUCTION=main
DEPLOY_BRANCH_STAGING=staging
DEPLOY_BRANCH_DEVELOPMENT=dev- One‑off override: set
DEPLOY_BRANCH_OVERRIDEenv var when invoking the script.
Webhook / CI automation
Two common ways to automate deployments:
- CI-driven development deployment should SSH into the server and run:
./utils/deploy-ci-dev-ab.shThis is the preferred path for Woodpecker-based dev deployments because it performs slot-aware A/B rollout. Woodpecker decides the commit; the server-side script only deploys the already-checked-out code.
- Manual or legacy automation can still SSH into the server and run:
./utils/deploy.sh --no-interactive --quietThis remains useful for operator-driven deploys and older webhook-style flows where something else has already updated the checkout on the target host.
- A webhook receiver on the server, which validates the payload signature and triggers the same command above. Ensure the deploy user has the repository checked out with proper permissions.
Woodpecker pipelines
The repository includes .woodpecker.yml as one CI/CD implementation. Hostnames, SSH users, registry addresses, deployment paths, and secret values are operator-owned configuration and should be managed outside the public repository.
A typical development pipeline:
- A push to the deployment branch triggers Woodpecker.
- Woodpecker SSHes into the target host (it does not build images itself).
- On the server, the long-lived checkout at the configured deploy path is reset to the pushed commit.
- The server logs into the registry (to pull the prebuilt runtime base image) and runs
./utils/deploy-ci-dev-ab.sh. - The deploy script builds the backend image on-host using the host Docker layer cache, then performs the A/B slot rollout.
- After the slot switch, Woodpecker sends deploy notifications, if configured.
Woodpecker secrets are intentionally split by scope:
- shared/global or organization secrets:
<TARGET>_HOST<TARGET>_USER<TARGET>_SSH_KEYREGISTRY_AUTH_USERNAMEREGISTRY_AUTH_PASSWORDREGISTRY_HOST,IMAGE_REPO
- repo-local secrets for
meo-mai-moi:- environment-specific deploy path
The registry credentials let the target host pull the prebuilt PHP runtime base image during its on-host build. Build-time frontend inputs are not Woodpecker secrets; they live in the target host's root .env (see above).
Why the target host is usually not 127.0.0.1:
- Woodpecker steps run inside containers.
- Inside a CI container,
127.0.0.1means the container itself, not the deployment host. - Use the target host's real reachable address instead.
The pipeline intentionally deploys via SSH into a host checkout instead of using host-path volumes or the host Docker socket inside Woodpecker steps. That keeps the repo compatible with non-trusted Woodpecker project settings and ensures a push cannot escalate to Docker-daemon (root) access on the CI agent host. Image assembly happens only on the target host, pinned to the exact pushed commit.
Operational notes:
- SSH keys should be one-line base64 encodings of the private deploy key content
- manual reruns are allowed in addition to push-triggered runs
- stale
deploy.lockfiles should be treated as interrupted deploy residue, not as proof that a deploy is still active - CI deploy entrypoints now wait and retry for a short window if another deploy is actively holding the lock, instead of failing immediately on the first contention
- lock contention messages should report the holder's original start time and PID, rather than the retrying process's own start time
- deploy notifications should be structured webhook payloads; chat formatting belongs in the notification service
Reading CI-safe deploy logs
In Docker-based deploys, these log lines are expected informational skips, not deployment problems:
Bun not installed on host, skipping API generation checkBun not installed on host, skipping i18n checkphp not found on host; skipping OpenAPI spec generation
They appear because the actual build happens inside Docker rather than relying on host-installed Bun or PHP.
Logs and retention
- Per‑run logs are written to
.deploy/deploy-YYYYMMDD-HHMMSS.logand.deploy/deploy-YYYYMMDD-HHMMSS.json. - Convenience symlinks:
.deploy.logand.deploy.log.jsonpoint to the latest run. - Logs older than 30 days are cleaned up automatically.
- Volume deletion events are logged to
.deploy/volume-deletions.logfor audit trail.
Volume safety and debugging
Database volume protection
The deploy script includes several safeguards against accidental data loss:
- Empty database detection: Deployment fails if the database is empty (unless
--allow-empty-dbor--seedis specified) - Volume fingerprinting: Tracks the database volume creation timestamp in
.db_volume_fingerprintto detect unexpected volume recreation - Volume deletion logging: All
--freshdeployments log volume deletion events to.deploy/volume-deletions.log
Investigating data loss
If you encounter unexpected database emptiness or data loss, use these tools:
Check volume creation time vs fingerprint:
docker volume inspect meo-mai-moi_pgdata --format '{{ .CreatedAt }}'
cat .db_volume_fingerprintIf these don't match, the volume was recreated outside of tracked deployments.
Check volume deletion history:
cat .deploy/volume-deletions.logMonitor volume events in real-time (run in separate terminal):
docker events --filter 'type=volume' --format '{{.Time}} {{.Action}} {{.Actor.Attributes.name}}'Review historical volume events:
./utils/check-volume-events.sh [days-back] # Check last N days (default: 7)Note: Docker event logs are ephemeral and may be cleared/rotated. For persistent tracking, rely on .deploy/volume-deletions.log.
Common causes of volume deletion
- Running
docker compose down -v(the-vflag deletes volumes) - Running
docker system prune -a --volumes - Using
./utils/deploy.sh --fresh(intentional, but logged) - External tools or scripts that manage Docker resources
docker compose exec backend tail -f /var/www/storage/logs/db-monitor.log🔑 Seeder Overrides
Configure the initial Super Admin credentials via environment variables in backend/.env*:
SEED_ADMIN_EMAIL=admin@catarchy.space
SEED_ADMIN_PASSWORD=password
# Optional: SEED_ADMIN_NAME="Super Admin"DatabaseSeeder and deploy.sh will honor these values when seeding and when checking for the admin user during deployments.
Demo account seeding is also configurable:
DEMO_USER_EMAIL=demo@catarchy.space
DEMO_USER_NAME="Demo Caregiver"
DEMO_USER_PASSWORD=password
DEMO_LOGIN_TOKEN_TTL_SECONDS=120
# Optional: DEMO_LOGIN_REDIRECT_PATH=/When DatabaseSeeder runs in non-production environments, it now ensures this demo user exists and seeds a curated set of pets, health records, foster relationships, microchip data, and in-app notifications for the public demo flow.
🌱 Safe Production Seeders
When deploying to production, you may need to update basic reference data (categories, cities, pet types, etc.) without creating test users or pets. The following seeders are safe to run on production as they only populate essential reference data:
Safe Seeders to Run on Production
# Core reference data
docker compose exec backend php artisan db:seed --class=CitySeeder
docker compose exec backend php artisan db:seed --class=PetTypeSeeder
docker compose exec backend php artisan db:seed --class=CategorySeeder
# Authentication & permissions
docker compose exec backend php artisan db:seed --class=ShieldSeeder
docker compose exec backend php artisan db:seed --class=RolesAndPermissionsSeeder
# Configuration & notifications
docker compose exec backend php artisan db:seed --class=SettingsSeeder
docker compose exec backend php artisan db:seed --class=NotificationPreferenceSeeder
docker compose exec backend php artisan db:seed --class=NotificationTemplateSeederWhat These Seeders Provide
- CitySeeder: Creates city entries for various countries (reference data only)
- PetTypeSeeder: Creates pet types (cat, dog, bird, etc.) with their configurations
- CategorySeeder: Creates pet categories/breeds and characteristics for each pet type
- ShieldSeeder: Sets up Laravel Shield authentication/authorization data
- RolesAndPermissionsSeeder: Creates roles and permissions structure
- SettingsSeeder: Sets basic application settings (invite-only mode, email verification)
- NotificationPreferenceSeeder: Creates notification preference templates
- EmailConfigurationSeeder: Sets up email configuration options
- NotificationTemplateSeeder: Creates notification templates for the system
Important Notes
- These seeders use
updateOrCreate()so they're safe to run multiple times without duplicating data - They only create reference/configuration data, not test users, pets, or other entities
- Avoid running
DatabaseSeederon production as it calls multiple seeders including test data creation - Unsafe seeders to avoid:
UserSeeder,HelperProfileSeeder,PlacementRequestSeeder,ReviewSeeder,E2ETestingSeeder,E2EEmailConfigurationSeeder
Data Recovery
- Rollback (
rollback.sh): Revert code changes to a previous deployment snapshot while preserving database data - Restore: Recover data through the shared backup system outside this repository
Use rollback for code issues, use restore for data recovery.
Production Recommendations
- Verify shared backup coverage before changing DB or storage topology.
- Test restore procedures against shared infrastructure, not repo scripts.
- Keep rollback snapshots available for code-only incidents.
Production HTTPS
Terminate HTTPS at your reverse proxy (nginx/caddy/traefik/Cloudflare) and forward to the backend’s HTTP port.
Set headers:
X-Forwarded-ProtoX-Forwarded-ForX-Forwarded-Host
Do not use self‑signed certificates in production.
Migration strategy
- Migrations are run explicitly by the deploy script after the container is healthy.
- This prevents startup races and ensures orderly seeding and verification.