Tanstack Start
Self-hosting

Docker setup

Bring up a full Feeblo deployment with the published ghcr.io images plus Postgres, SMTP, and S3-compatible storage wired together in one compose file.

This is the hands-on guide for running all of Feeblo in Docker. The shipped docker-compose.yml only contains the two Feeblo services (server and web); you add the three dependencies (PostgreSQL, SMTP, and S3-compatible media storage) and the environment that ties them together.

This page is based on docker-compose.yml, docker/docker-compose.dev.yml, apps/server/Dockerfile, apps/web/Dockerfile, and .env.example in .reference/.

The two published images

Both images are built from the Dockerfiles in apps/ and pushed to ghcr.io:

ImageInternal portStarts withNotes
ghcr.io/g3root/feeblo-server8080node ./migrate/index.js && exec node ./dist/index.jsRuns migrations automatically on boot, then starts the API
ghcr.io/g3root/feeblo-web4321node ./dist/server/entry.mjsAstro SSR, HOST=0.0.0.0, CLOUDFLARE_ADAPTER=false

The server image bundles packages/db/src/migrations and the db-migrator build (packages/db-migrator), so you do not run migrations separately — they execute every time the server container starts. If DATABASE_URL is unset the migrator logs "DATABASE_URL not defined, skipping migrations" and exits cleanly, after which the server itself will fail to start (it also needs DATABASE_URL). See the database page for details.

A complete compose file

Drop this in your repo as docker-compose.yml. It merges the shipped server/web services with Postgres, Mailpit (SMTP), and MinIO (S3-compatible), and a one-shot MinIO bucket bootstrap — the same stack the dev compose (docker/docker-compose.dev.yml) uses internally:

services:
  server:
    container_name: feeblo-server
    image: ghcr.io/g3root/feeblo-server:${IMAGE_TAG:-nightly}
    restart: always
    depends_on:
      - postgres
    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}
      DATABASE_URL: postgres://feeblo:${POSTGRES_PASSWORD}@postgres:5432/feeblo
      AUTH_ENCRYPTION_KEY: ${AUTH_ENCRYPTION_KEY}
      SMTP_HOST: feeblo-smtp
      SMTP_PORT: "1025"
      SMTP_FROM_NAME: "Feeblo"
      SMTP_FROM_ADDRESS: "noreply@feeblo.com"
      MEDIA_UPLOAD_REGION: "us-east-1"
      MEDIA_UPLOAD_ENDPOINT: http://feeblo-minio:9002
      MEDIA_UPLOAD_ACCESS_KEY_ID: ${MINIO_ROOT_USER}
      MEDIA_UPLOAD_SECRET_ACCESS_KEY: ${MINIO_ROOT_PASSWORD}
      MEDIA_PUBLIC_BUCKET_NAME: feeblo-media-public
    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

  postgres:
    image: postgres:17-alpine
    container_name: feeblo-database
    environment:
      POSTGRES_USER: feeblo
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
      POSTGRES_DB: feeblo
    volumes:
      - postgres:/var/lib/postgresql/data

  smtp:
    image: axllent/mailpit
    container_name: feeblo-smtp
    ports:
      - "8025:8025" # Mailpit web UI

  minio:
    image: minio/minio
    container_name: feeblo-minio
    volumes:
      - minio:/data
    environment:
      MINIO_ROOT_USER: ${MINIO_ROOT_USER}
      MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD}
    entrypoint: sh
    command: -c 'minio server /data --console-address ":9001" --address ":9002"'

  mc:
    image: minio/mc
    container_name: feeblo-mc
    depends_on:
      - minio
    entrypoint: sh
    command: |
      -c "
        sleep 10;
        mc alias set myminio http://minio:9002 ${MINIO_ROOT_USER} ${MINIO_ROOT_PASSWORD};
        mc mb myminio/feeblo-media-public;
        mc anonymous set download myminio/feeblo-media-public;
        exit 0;
      "

volumes:
  postgres:
  minio:

What the mc container does

The mc service is a one-shot bootstrap: it creates the feeblo-media-public bucket and makes it anonymously downloadable, which is how Feeblo serves uploaded media over public URLs. It exits after succeeding; if the bucket already exists mc mb errors harmlessly and the anonymous policy is re-applied.

The matching .env

# Image tag to deploy (defaults to nightly if omitted)
IMAGE_TAG=nightly

# Public URLs the apps advertise
APP_URL=https://feeblo.example.com
API_URL=https://api.feeblo.example.com
APP_ROOT_DOMAIN=feeblo.example.com

# Ports on the host (optional)
SERVER_PORT=8080
WEB_PORT=4321

# Postgres
POSTGRES_PASSWORD=change-me

# Object storage (MinIO here)
MINIO_ROOT_USER=feeblo
MINIO_ROOT_PASSWORD=change-me-too

# Auth — keep this stable across restarts
AUTH_ENCRYPTION_KEY=generate-a-long-random-string

Put reverse proxies in front

The compose above does not expose Postgres, MinIO, or Mailpit to the host by default — only server (8080) and web (4321) are published. Put TLS-terminating reverse proxies (or a Cloudflare tunnel) in front of web and server, and set APP_URL/API_URL to those public origins. APP_URL and API_URL must be URLs (https://…); APP_ROOT_DOMAIN must be the bare host (feeblo.example.com) — see the overview for the CORS origin logic.

Bring it up

docker compose up -d
docker compose logs -f server   # watch migrations run, then the API start

Sanity checks once it's up:

  • curl https://api.feeblo.example.com/healthOK
  • https://api.feeblo.example.com/docs → Scalar/OpenAPI UI
  • https://feeblo.example.com → the dashboard

Pinning a version

IMAGE_TAG defaults to nightly everywhere (${IMAGE_TAG:-nightly} in the compose, APP_RELEASE reflects it). For production, pin a real tag:

IMAGE_TAG=v1.2.3
docker compose up -d

The server and web images are built from apps/server/Dockerfile and apps/web/Dockerfile using turbo prune to isolate each app's workspace graph — so each image only contains what it needs. The runner stage sets CLOUDFLARE_ADAPTER=false on the web image and runs both as a non-root user (server for the API, astro for the web, uid/gid 1001) for a smaller attack surface.

On this page