Tanstack Start
Self-hosting

Self-hosting overview

How a self-hosted Feeblo deployment fits together — the two images, the external services, and the environment that wires them up.

Feeblo is shipped as two container images plus three external services. This page explains how the pieces connect so you know exactly what you are responsible for providing when you self-host. Detailed setup lives in the dedicated Docker, environment, database, media, email, and Cloudflare pages.

This page is based on docker-compose.yml, docker/docker-compose.dev.yml, .env.example, apps/server/src/index.ts, and the root README.md in .reference/.

What you run

A production Feeblo deployment has two of its own services and three dependencies:

  • server — the Effect HTTP API (ghcr.io/g3root/feeblo-server). Serves the HTTP/REST surface, the RPC router the dashboard calls, better-auth session handlers, and the OpenAPI/Scalar UI at /docs. Default container port 8080.
  • web — the Astro dashboard (ghcr.io/g3root/feeblo-web). The control panel your users log into. Default container port 4321.
  • PostgreSQL — the only database. Drizzle migrations define the schema.
  • SMTP — an email server (or API relay) used for transactional email.
  • S3-compatible object storage — for media uploads (e.g. MinIO, Cloudflare R2, AWS S3).

The official compose file only ships server and web. PostgreSQL, SMTP, and media storage are external to it — you point Feeblo at them with environment variables. This is the most common thing to miss when self-hosting, so it's covered in detail below.

The official Docker Compose

docker-compose.yml in .reference/ defines the two Feeblo services:

services:
  server:
    container_name: feeblo-server
    image: ghcr.io/g3root/feeblo-server:${IMAGE_TAG:-nightly}
    restart: always
    environment:
      NODE_ENV: production
      PORT: ${SERVER_PORT:-8080}
      SERVER_PORT: ${SERVER_PORT:-8080}
      APP_URL: ${APP_URL}
      API_URL: ${API_URL}
      APP_ROOT_DOMAIN: ${APP_ROOT_DOMAIN}
      APP_RELEASE: ${IMAGE_TAG:-nightly}
    ports:
      - "${SERVER_PORT:-8080}:8080"

  web:
    container_name: feeblo-web
    image: ghcr.io/g3root/feeblo-web:${IMAGE_TAG:-nightly}
    restart: always
    environment:
      NODE_ENV: production
      HOST: 0.0.0.0
      PORT: ${WEB_PORT:-4321}
      API_URL: ${API_URL}
      APP_URL: ${APP_URL}
      APP_ROOT_DOMAIN: ${APP_ROOT_DOMAIN}
      APP_RELEASE: ${IMAGE_TAG:-nightly}
    ports:
      - "${WEB_PORT:-4321}:4321"
    depends_on:
      - server

Note a few specifics straight from the file:

  • Image tag defaults to nightly via ${IMAGE_TAG:-nightly}. Pin a specific tag in production.
  • The server container listens on 8080 internally (PORT/SERVER_PORT); SERVER_PORT also controls the host port mapping. The web container listens on 4321 internally.
  • web depends on server, so compose starts the API first.

Environment the compose does NOT include

The compose forwards APP_URL, API_URL, APP_ROOT_DOMAIN, and APP_RELEASE into both services. It does not forward the database, auth, SMTP, or media variables. Those are read by the server at startup, so you must add them to the server service (and any others that need them):

  • DATABASE_URL — required, read as a redacted config in packages/db/src/config.ts.
  • AUTH_ENCRYPTION_KEY — required, read in packages/auth/src/config.ts and packages/domain/src/auth/config.ts.
  • SMTP_* — transactional email settings (SMTP_TRANSPORT, SMTP_HOST, SMTP_PORT, SMTP_FROM_NAME, SMTP_FROM_ADDRESS, …).
  • MEDIA_UPLOAD_* — object storage settings (MEDIA_UPLOAD_REGION, MEDIA_UPLOAD_ENDPOINT, MEDIA_UPLOAD_ACCESS_KEY_ID, MEDIA_UPLOAD_SECRET_ACCESS_KEY, MEDIA_PUBLIC_BUCKET_NAME).
  • NODE_ENV, PORT/SERVER_PORT — already in the compose.

Forward the missing env vars

The combined docker compose file as shipped intentionally only contains server and web. If you run it as-is without adding DATABASE_URL, AUTH_ENCRYPTION_KEY, SMTP_*, and MEDIA_UPLOAD_* to the server service (and a postgres/smtp/minio service if you don't have external ones), the server will fail to start. Either extend this compose or provide the values some other way.

How the origin checks work

The server validates Cross-Origin requests with an allowlist computed in apps/server/src/index.ts (isAllowedOrigin). A request Origin is allowed when its hostname matches:

  • the API_URL hostname, or
  • the APP_URL hostname, or
  • any subdomain of APP_ROOT_DOMAIN (e.g. feedback.acme.com if APP_ROOT_DOMAIN=acme.com).

In development, requests to localhost / 127.0.0.1 / *.localhost origins are allowed when NODE_ENV=development. CORS sends Access-Control-Allow-Credentials and a maxAge of one day.

APP_ROOT_DOMAIN is a hostname, not a URL

isAllowedOrigin strips a single :port from APP_ROOT_DOMAIN before comparing. Set it to the bare host (acme.com), not https://acme.com. The dev .env.example ships APP_ROOT_DOMAIN=localhost:3001, which works because the port is stripped and the dev-host branch handles localhost.

Helpful server endpoints

When the server is running you can sanity-check the deployment:

  • GET /health — returns the text OK (defined in apps/server/src/index.ts).
  • GET /docs — the Scalar/OpenAPI UI for the Api (wired via HttpApiScalar.layer(Api, { path: "/docs" })).
  • GET /api/auth/* — better-auth handlers mounted through the server.

Pointers before you start

  • Environment — start with .reference/.env.example; every variable Feeblo understands is listed there (see the environment page for a walkthrough).
  • Database — PostgreSQL only; Drizzle manages the schema (see the database page).
  • Media — any S3-compatible store; MEDIA_UPLOAD_REGION should be auto when using Cloudflare R2 (see the media page).
  • Email — a single nodemailer SMTP transport built from the SMTP_* vars (Mailpit for dev) (see the email page).
  • Local development — for running from source instead of the published images, see the development setup page; the dev compose (docker/docker-compose.dev.yml) brings up the same Postgres / Mailpit / MinIO stack with sensible defaults.

On this page