Deployment
GiftWrapt is a regular TanStack Start app, so it runs anywhere a Node.js server runs. The supported paths:
| Path | When to pick it |
|---|---|
| Hosted (Vercel + Supabase) | Tried-and-true managed path. The configuration I run in production. |
| Hosted (Railway) | One-click multi-service template. Web + Postgres + 5 cron services in a single deploy. |
| Hosted (Render) | One-click render.yaml blueprint. Web + managed Postgres + 5 cron jobs. |
| Self-hosting with Docker | Full control. One docker compose up, runs anywhere. |
| Coolify | Self-hosted PaaS. Uses the same compose files as Docker self-hosting. |
| Custom Node deploy | Fly, a VPS, bare metal, etc. |
Coolify
Section titled “Coolify”Coolify is self-hosted, so there’s no public deploy button - you trigger the deploy from your own Coolify dashboard.
- In your Coolify instance: + New → Public Repository.
- Repository:
https://github.com/shawnphoffman/giftwrapt. - Build pack: Docker Compose.
- Compose file: pick the shape that matches what you want running. The matrix is
docker/compose.selfhost-{garage,rustfs}-{minimal,cron,full,traefik}.yaml.-cron.yamlis a good default (app + Postgres + storage + the cron sidecar);-full.yamladds the profile-gated MCP service. - Coolify reads the compose file and spins up app + Postgres + Garage in one go.
- Fill in the env vars Coolify prompts for (same set as
env.example):BETTER_AUTH_SECRET,BETTER_AUTH_URL,POSTGRES_PASSWORD,STORAGE_*,GARAGE_*. The bundledINIT_GARAGE=truehandles bucket creation on first boot.
This lands the entire stack (app + DB + storage) without any post-deploy paste step. Full self-host walkthrough: Self-hosting.
Cron and Background Jobs
Section titled “Cron and Background Jobs”The app exposes five /api/cron/* endpoints (auto-archive, birthday emails, intelligence recommendations, item-scrape queue, verification cleanup). They are protected by CRON_SECRET and only fire if a scheduler is wired up - the app does not self-schedule. Always set CRON_SECRET to a long random string before relying on cron.
Confirm what’s actually firing in production via /admin/scheduling. Each row shows last run, last success (amber when stale > 3× the expected interval), next fire time computed from the schedule, and the last 24h count of runs and errors. There is also a “Run now” button per endpoint that bypasses the schedule - the fastest way to verify each route is healthy after a deploy.
Per-platform cron details:
- Vercel: schedules ship in
vercel.json. See Hosted (Vercel + Supabase). - Railway: schedules ship as separate cron services in the template. See Hosted (Railway).
- Render: schedules ship as Cron Jobs in
render.yaml. See Hosted (Render). - Self-hosted Docker Compose: a busybox crond sidecar runs alongside the app. See Self-hosting → Scheduled jobs.
Customizing Schedules Across Deployments
Section titled “Customizing Schedules Across Deployments”Cron expressions live in several places that should stay in sync:
| File | Used by |
|---|---|
vercel.json | Vercel’s scheduler at deploy time |
render.yaml | Render Cron Jobs |
| Railway template | Railway cron services (dashboard-managed; republish the template to change defaults) |
docker/cron-entrypoint.sh | Self-hosted Docker Compose cron sidecar |
src/lib/cron/registry.ts | /admin/scheduling “next fire” estimates |
When you change a schedule, update every file your deployment uses plus the registry so the admin page’s expectations match reality. The registry is the only one that affects what’s shown to the user; the others drive actual firing.
The full deployment-platform matrix, the lock-key namespace convention for adding a new cron-tick runner, and the complete sample-schedule table live in .notes/cron-and-jobs.md.
Custom Node Deploy
Section titled “Custom Node Deploy”pnpm install --frozen-lockfilepnpm buildnode .output/server/index.mjsThe build emits a self-contained Nitro bundle in .output/server/. Run migrations once before first boot:
node .output/scripts/migrate.mjsAll env vars from env.example apply. The runtime needs:
- A reachable Postgres (
DATABASE_URL) - An S3-compatible bucket (
STORAGE_*) - A long-random
BETTER_AUTH_SECRETand the publicBETTER_AUTH_URL
The Dockerfile in the repo root is the canonical reference for what a minimal production runtime looks like. Note that it sets HOST=:: so the server binds IPv6 by default - required for PaaS platforms with IPv6-only private networking (Railway, Fly). Override with HOST=0.0.0.0 if you need IPv4-only.
Releases and the GHCR Image
Section titled “Releases and the GHCR Image”release-please watches main and opens a release PR with the next semver bump and a generated CHANGELOG.md entry. Merging that PR tags the release, which triggers the GHCR image publish:
ghcr.io/shawnphoffman/giftwrapt:latestghcr.io/shawnphoffman/giftwrapt:vX.Y.ZPin a specific tag in production by setting APP_IMAGE in your .env. The compose files default to :latest for first-time setup but you should pin once you’re past the “does it work” phase.