Skip to content

Self-hosting guide: Docker, Postgres, environment

Run Postbag yourself: the Docker image (api, worker or all), Postgres 16, docker-compose, every environment variable (DATABASE_URL, APP_URL, BETTER_AUTH_SECRET, POSTBAG_ROLE, MIGRATE_ON_BOOT, RESEND_API_KEY, MAIL_FROM), health checks and upgrades.

Updated · Markdown

Postbag is one image plus Postgres. The hosted product runs the same image.

docker-compose

services:
  db:
    image: postgres:16-alpine
    environment: { POSTGRES_DB: postbag, POSTGRES_USER: postbag, POSTGRES_PASSWORD: change-me }
    volumes: [ "postbag-postgres:/var/lib/postgresql/data" ]
    healthcheck: { test: ["CMD-SHELL", "pg_isready -U postbag -d postbag"], interval: 2s, retries: 15 }
  postbag:
    build: .                       # or the published image when available
    depends_on: { db: { condition: service_healthy } }
    environment:
      DATABASE_URL: postgres://postbag:change-me@db:5432/postbag
      NODE_ENV: production
      PORT: "3000"
      APP_URL: https://forms.example.com
      BETTER_AUTH_SECRET: a-long-random-secret
      POSTBAG_ROLE: all            # api | worker | all
      MIGRATE_ON_BOOT: "true"
      TZ: UTC
      RESEND_API_KEY: re_…
      MAIL_FROM: "Forms <[email protected]>"
    ports: [ "3000:3000" ]
volumes: { postbag-postgres: {} }

Environment

VariableMeaning
DATABASE_URLPostgres connection string.
APP_URLPublic origin. Used in submit URLs, embed snippets, llms.txt and docs links.
BETTER_AUTH_SECRETSession signing secret.
POSTBAG_ROLEapi, worker or all. Run two containers for independent scaling; several workers are safe.
PORT, TZ, NODE_ENVDefaults 3000, UTC, production.
MIGRATE_ON_BOOTtrue runs pending Drizzle migrations at start.
RESEND_API_KEY, MAIL_FROMEmail destinations. Verify the sending domain in Resend.

Health and operations

GET /health returns database status, worker heartbeat and oldest pending delivery age; the image has a Docker HEALTHCHECK on it. Logs are structured JSON. Migrations live in packages/db/drizzle and are applied in order; never edit an applied migration.

Upgrades

Pull the new image, restart with MIGRATE_ON_BOOT=true. Schemas (form and stream) are immutable versions, so upgrades never rewrite your contracts.

Single-organization installs

Disable signups after creating the first organization if the instance is private. Plan limits under the selfhost plan are effectively unlimited.

Access to the image

Postbag is open source: the server is licensed under AGPL-3.0 and the client packages (SDK, CLI, MCP server) under MIT. The repository opens publicly with the first npm release of the client packages; until then, get in touch and we will arrange access. The image is multi-arch (arm64, amd64) and built from the same Dockerfile as production.

Your first form is three minutes away.

Sign up, get a submit URL, point a form at it. The first submission lands in your inbox and your email. Everything else appears when you need it.