# Production deployment ## Coolify on one VM ### Current Manyangles storage `compose.coolify.yml` uses the private Spaces bucket `hltma` in `nyc3` for web/worker storage. Set `SPACES_ACCESS_KEY` and `SPACES_SECRET_KEY` in Coolify; never commit their values. The endpoint is `https://nyc3.digitaloceanspaces.com` and virtual-host addressing is enabled. Configure bucket CORS separately for `https://ma.hadlock.tech` (GET/PUT/HEAD, allowed headers `*`, exposed headers ETag/Content-Length). The bucket-scoped object key cannot administer these rules. Garage and its initializer/volumes remain in the stack for migration rollback. Do not remove them until the migration and a recovery path are verified. Do not blindly roll back after new uploads reach Spaces: synchronize those objects back first. Bucket lifecycle must also be configured separately (expire `exports/` after one day; never expire originals). The worker performs export cleanup too. For the initial migration, `scripts/copy-to-spaces.ts` runs in the old worker's storage package directory, receiving the two Spaces secrets as JSON on stdin. It preserves object paths and verifies SHA-256, refusing differing existing objects. It does not delete anything or perform the live cutover. Pause web writes, allow the ten-minute signed-upload lifetime to expire and any in-flight transfers to finish, drain workers, then repeat the copy before deploying. ### Original Garage setup (retained for reference) Use the repository Docker Compose build pack and `/compose.coolify.yml`. This adds isolated Postgres and Garage volumes, migrations, and a one-shot storage initializer (exact-origin CORS, one-day export expiry and abandoned multipart cleanup). It does not import local demo data or create accounts. Coolify generates `SERVICE_URL_WEB` and `SERVICE_URL_GARAGE`; these are passed to the app/auth and browser storage endpoint respectively. Route web to port 3000 and Garage to 3900. Both generated domains must use HTTPS. Only these services are public; do not expose Postgres or Garage's admin/RPC ports. Set `EMAIL_FROM`, `RESEND_API_KEY`, and `RESEND_WEBHOOK_SECRET` in Coolify before starting. Generated credentials belong to this stack, not other applications. Coolify does not generate 24-character hex values automatically: initialize `SERVICE_HEX_24_S3KEY` with 12 cryptographically random bytes encoded as hex before the first deployment. The Compose file adds Garage's `GK` prefix. Keep that identifier and all storage secrets stable across redeployments. Keep worker concurrency at one initially on a shared VM. Back up all three data volumes off-host and verify recovery before collecting real wedding photos. ## Standalone Compose Requires Docker Compose, an HTTPS reverse proxy, and a private S3-compatible bucket. Compose includes persistent Postgres, a one-shot migration service, the standalone web app, and a persistent Bun image/email/export worker. Mailpit and demo credentials are not included. Production never auto-creates the bucket or changes its CORS configuration. 1. Copy `.env.production.example` to `.env.production` and fill every required secret. Use random values, not development credentials. Keep auth and public URLs identical to the final HTTPS origin. The public URL is also a build arg; rebuild the web image when changing it. 2. Provision the private bucket and configure CORS: allow your exact public app origin, GET/PUT/HEAD, Content-Type and necessary S3 checksum headers; expose ETag and Content-Length. Guests must reach `S3_PUBLIC_ENDPOINT` over HTTPS. The worker needs list/get/put/delete and multipart-upload permissions. Set a one-day lifecycle expiry for the `exports/` prefix and abort incomplete multipart uploads after one day. Do not expire originals. The worker also removes expired ZIPs, but lifecycle handles archives belonging to deleted events. 3. Verify the Resend sender domain. Configure `/api/webhooks/resend` as described in `email-delivery.md`. Set Authentik's callback if using it (see README). 4. Run: ```sh docker compose --env-file .env.production -f compose.production.yml build docker compose --env-file .env.production -f compose.production.yml up -d docker compose --env-file .env.production -f compose.production.yml ps ``` The web service binds only `127.0.0.1:3000`. Configure your host reverse proxy to forward HTTPS to that address, preserve Host and X-Forwarded-Proto, and support normal streaming responses. Postgres is not published to the host. If your proxy runs in Docker, attach it to the Compose network and use `web:3000` instead. ## First administrator Register your real account in the app and verify its email (or sign in through Authentik). Obtain its ID from your database administrator, then run: ```sh docker compose --env-file .env.production -f compose.production.yml exec worker bun packages/database/src/bootstrap-admin.ts USER_ID ``` This promotes only an existing verified account and refuses once any platform administrator exists. No passwords are created or logged. Never run demo seeds. ## Updates and operations Back up Postgres and the original-image bucket before updates. Build updated images, then run `docker compose ... run --rm migrate` before `up -d` (replace `...` with the same env-file/file arguments above). Do not use `down -v` on a live installation. Schema rollback requires a tested backup/restore strategy. Monitor web health at `/api/health` (process liveness only), worker logs, failed jobs, disk usage, database backups and storage lifecycle rules. Test restores. Pin image digests in your deployment pipeline after verifying them. ## Originals exports Event → Photos → Export originals. Owners/managers with settings and private-photo access can choose approved or all processed photos. Exports are requester-only; permissions are rechecked before issuing each five-minute signed download URL. Already-issued URLs remain usable until expiry. ZIP files contain unchanged original bytes (including EXIF/GPS), named by photo ID to prevent unsafe or duplicate filenames. Standalone banners and unprocessed uploads are excluded. The selection is taken when processing starts. No originals pass through Next.js. ZIP64, streaming multipart uploads and one-at-a-time input reads bound memory. One active export per requester/event is allowed; limit 10,000 photos / 20 GB. Archives expire after 24 hours; interrupted jobs fail and may be requested again. ## Verification Validated locally: web/worker/migration Docker image builds (Linux ARM64), web container startup and health response, worker native Sharp and ZIP dependencies, Compose configuration, clean database migrations, typecheck and application build. The demo's eight exported originals were compared byte-for-byte successfully. No production services were deployed and no production emails were sent. ```sh EXPORT_INTEGRATION=1 bun --env-file=.env test apps/worker/src/exports.integration.test.ts cd apps/web EXPORT_PERMISSIONS_INTEGRATION=1 bun --env-file=../../.env test src/server/exports.integration.test.ts ``` The local-only integration tests cover archive contents, original bytes, approved/private filtering, expiry deletion, duplicate jobs, event/requester scoping, denied roles, revoked permissions, and expired download rejection. They remove their own temporary fixtures. Validate your actual S3 provider and target architecture in staging before launch.