Other hosts
Any host that runs containers and gives you Postgres and Redis can run the kit.
docker-compose.production.yml is a complete working example. This page is for setting one
up by hand; to make a host part of the kit, as code, like Railway and
AWS, see Add a host.
Build from the root of the repository
Section titled “Build from the root of the repository”The apps share the contracts package, so build from the root:
docker build -f apps/api/Dockerfile -t saas-stack-api .docker build -f apps/web/Dockerfile -t saas-stack-web .docker build -f apps/site/Dockerfile -t saas-stack-site \ --build-arg SITE_URL=https://example.com \ --build-arg PUBLIC_APP_URL=https://app.example.com .The site’s two addresses are baked into its pages, so change them and you rebuild the site.
Run the containers
Section titled “Run the containers”| Container | Command | Needs |
|---|---|---|
| api | bun src/main.ts |
DATABASE_URL, REDIS_URL and its variables |
| web | Caddy | API_URL: the API’s private address, such as http://api:3000 |
| site | Caddy | API_URL: the API’s private address, for the contact form |
| worker | bun src/worker.ts |
Same as the API. Optional: set JOBS_WORKER=false on the API |
A host that gives the database in parts can set PGHOST, PGPORT, PGUSER, PGPASSWORD
and PGDATABASE instead of DATABASE_URL. Redis must not evict keys: set
maxmemory-policy noeviction, or jobs can be lost.
Every image listens on PORT. Give api no public address: browsers reach it through web
under /api. Put HTTPS in front of web and site (Caddy’s own HTTPS is off), and set the
API’s WEB_URL to the https:// address.
Migrate before each new version
Section titled “Migrate before each new version”Run this in the API image with the same DATABASE_URL, before the new version starts:
bun src/db/migrate.tsIf it fails, don’t start the new version. The old version keeps serving while it runs, so migrations must work with the running code: add a column in one release, stop using the old one in the next.
Health checks
Section titled “Health checks”| Container | Path | Healthy when |
|---|---|---|
| api | /api/health |
200; 503 if the database is down |
| web, site | /healthz |
200 |
The worker has no health check. Restart it when it stops. What the API answers, and how to watch it from outside, is on Health checks.
Trusted proxies
Section titled “Trusted proxies”Sign-in is rate limited per visitor address. Caddy in the web image trusts X-Forwarded-For
only from private addresses (private_ranges and 100.64.0.0/10) and passes the API a single
visitor address. If your host’s proxy reaches the container from a public address, add its
range to trusted_proxies in apps/web/Caddyfile and apps/site/Caddyfile, or every visitor
shares one limit.
Then check it with bun run deploy:check.