TrayPage Docs

Self-hosting with Docker / VPS

Set up TrayPage on a single server with docker compose. Try it locally, run on a production VPS, use external Postgres and S3, back up, and update.

Run TrayPage on a single server (a VPS, an internal server, or your own machine) with just docker compose up. By default everything stays on that one machine, so it does not depend on external services and works in closed networks.

Browser / AI agent
        │
        ▼
   Caddy (TLS termination, two host names)
        │  app host / viewer host (serves page content only)
        ▼
   app (ghcr.io/8d-inc/tray-page)
        │
        ├── Postgres (bundled; can be replaced with managed Postgres)
        └── SeaweedFS (S3-compatible; can be replaced with S3 / R2)
ServiceRoleData (volume)
caddyEntry point. For https:// origins it obtains and renews Let's Encrypt certificates automaticallycaddy-data (certificates), caddy-config
appTrayPage itself (web UI, REST API, MCP, shared pages). Applies database migrations automatically on startupNone
postgresDatabase (profile postgres)postgres-data
seaweedfsS3-compatible storage for page content (profile s3). Creates the bucket on startupseaweedfs-data
mailpitCatches email when trying locally (profile mailpit)None

COMPOSE_PROFILES in .env decides which services start. All settings go in .env. The files are in the TrayPage repository under deploy/docker/ (compose.yaml, Caddyfile, .env.example).

Requirements

  • Docker Engine 24 or later and Docker Compose v2.20 or later (check with docker compose version)
  • For production: two host names (domains), and a server reachable on ports 80 and 443 from the internet

Try it locally

This runs without TLS.

cd deploy/docker
cp .env.example .env
sed -i.bak "s/^BETTER_AUTH_SECRET=.*/BETTER_AUTH_SECRET=$(openssl rand -hex 32)/" .env && rm .env.bak
docker compose up -d
  1. Open http://localhost:3000, enter your email address, and send yourself a sign-in link.

  2. Open the link in the email that arrives at http://localhost:8025 (the bundled Mailpit).

  3. Connect an MCP client. The MCP URL is http://localhost:3000/api/mcp.

    claude mcp add --transport http --scope user traypage http://localhost:3000/api/mcp
  4. Create and publish a page through MCP, then open the share URL (http://localhost:3000/share/...). Page content is shown in isolation from the viewer host http://view.localhost:3000.

Browsers resolve *.localhost to your own machine automatically. If you change the port, keep APP_ORIGIN, VIEWER_ORIGIN, and HTTP_PORT on the same number.

Run on a production VPS

  1. Choose two host names and point their DNS A / AAAA records at the server's IP (for example traypage.example.com and view.traypage.example.com). The viewer host serves arbitrary HTML written by users and AI, so it must be a different host name from the app. If possible, use a separate domain (for example traypage-content.example.net), which also keeps it out of the cookie scope.

  2. Open ports 80 and 443 (TCP) in the firewall. Port 80 is used to obtain certificates and to redirect to https.

  3. Configure .env.

    COMPOSE_PROFILES=postgres,s3
    TRAYPAGE_IMAGE=ghcr.io/8d-inc/tray-page:v1.2.3
    APP_ORIGIN=https://traypage.example.com
    VIEWER_ORIGIN=https://view.traypage.example.com
    HTTP_PORT=80
    HTTPS_PORT=443
    BETTER_AUTH_SECRET=<openssl rand -hex 32>
    POSTGRES_PASSWORD=<openssl rand -hex 24>
    S3_ACCESS_KEY_ID=traypage
    S3_SECRET_ACCESS_KEY=<openssl rand -hex 24>
    SMTP_HOST=smtp.example.com
    SMTP_PORT=587
    SMTP_USER=...
    SMTP_PASSWORD=...
    EMAIL_FROM=TrayPage <noreply@example.com>
  4. Run docker compose up -d. On the first start it takes tens of seconds for Caddy to obtain certificates (watch with docker compose logs -f caddy).

Decide POSTGRES_PASSWORD and the S3_* values before the first start. Changing POSTGRES_PASSWORD after the database volume exists does not change the password inside the database.

In the current version, anyone who can receive a sign-in link can create an account. If you use TrayPage only inside your organization, restrict who can reach it with a firewall or VPN (Security notes).

Email

Sign-in links and invitations are sent by email. The sender is chosen in this order:

SettingHow email is sent
RESEND_API_KEYThe Resend API
SMTP_HOST (with SMTP_PORT, SMTP_SECURE, SMTP_USER, SMTP_PASSWORD)SMTP. SMTP_SECURE=true uses port 465 (TLS from the start); otherwise STARTTLS

The sender address is EMAIL_FROM. In production, remove mailpit from COMPOSE_PROFILES.

Using external Postgres and S3

You can replace Postgres and S3-compatible storage with external services just by changing .env. Remove the matching profile from COMPOSE_PROFILES and the bundled container does not start.

Postgres (RDS, Cloud SQL, Neon, PlanetScale, and so on)

COMPOSE_PROFILES=s3
DATABASE_URL=postgres://traypage:password@db.example.com:5432/traypage?sslmode=require
DATABASE_POOL_MAX=10

Create the database (empty is fine) and the user first. The app creates the tables on startup; if several instances start at once, only one applies them.

S3-compatible storage (AWS S3, Cloudflare R2, and so on)

Create the bucket first. It can stay private (the app reads and writes with signed requests).

# AWS S3
COMPOSE_PROFILES=postgres
S3_ENDPOINT=https://s3.ap-northeast-1.amazonaws.com
S3_REGION=ap-northeast-1
S3_BUCKET=traypage-pages
S3_ACCESS_KEY_ID=AKIA...
S3_SECRET_ACCESS_KEY=...
# Cloudflare R2 (S3 API)
COMPOSE_PROFILES=postgres
S3_ENDPOINT=https://<account_id>.r2.cloudflarestorage.com
S3_REGION=auto
S3_BUCKET=traypage-pages
S3_ACCESS_KEY_ID=...
S3_SECRET_ACCESS_KEY=...

The default is path-style (endpoint/bucket/key). For services that need virtual-hosted style, set S3_ADDRESSING_STYLE=virtual. If both are external, set COMPOSE_PROFILES= (empty).

Updating

docker compose pull
docker compose up -d

Database migrations are applied automatically when the new image starts. Take a backup before updating.

Image tags

TagContents
vX.Y.ZA release. Pin production to this
vX.YThe latest release of that minor version
latestThe latest release (pre-releases excluded)
edgeThe latest develop branch. For testing
sha-<commit>A specific commit

The images support linux/amd64 and linux/arm64.

Backup and restore

There are three things to back up.

WhatHow
Database (Postgres)pg_dump
Page content (the seaweedfs-data volume, or external S3 / R2)Archive the volume with tar. For external S3 / R2, use that service's versioning or replication
.envCopy it somewhere safe. In particular, losing BETTER_AUTH_SECRET invalidates every signed-in session

Back up the database first, then page content. A version is never changed once written, and its content is written before its database row is created, so this order never leaves a database row without its content.

Run the following in deploy/docker. Volume names are <project name>_<volume name> (the default project name is traypage-selfhost).

# 1. Database (the app can keep running)
docker compose exec -T postgres pg_dump -U traypage -d traypage -Fc > traypage-db-$(date +%F).dump

# 2. Page content (stop writes, then archive the volume)
docker compose stop app seaweedfs
docker run --rm -v traypage-selfhost_seaweedfs-data:/data:ro -v "$PWD":/backup alpine \
  tar czf /backup/traypage-pages-$(date +%F).tar.gz -C /data .
docker compose start seaweedfs app

You do not need to back up caddy-data (certificates); they are re-obtained on startup (mind Let's Encrypt's issuance rate limits).

Restore

docker compose up -d postgres seaweedfs
docker compose stop seaweedfs

# Page content
docker run --rm -v traypage-selfhost_seaweedfs-data:/data alpine find /data -mindepth 1 -delete
docker run --rm -v traypage-selfhost_seaweedfs-data:/data -v "$PWD":/backup:ro alpine \
  tar xzf /backup/traypage-pages-YYYY-MM-DD.tar.gz -C /data

# Database (drops existing tables, then loads the dump)
docker compose exec -T postgres pg_restore -U traypage -d traypage --clean --if-exists --no-owner \
  < traypage-db-YYYY-MM-DD.dump

docker compose up -d

Cleaning up

docker compose down        # stop the containers (data is kept)
docker compose down -v     # also delete volumes (database, page content, certificates). Cannot be undone

Troubleshooting

  • Run docker compose ps and check that every service is running (healthy). Read the app log with docker compose logs app.
  • Sign-in links do not arrive: check SMTP_* (sending failures show up in docker compose logs app). Locally, look at http://localhost:8025.
  • Shared page content does not appear: check that VIEWER_ORIGIN is a different host name from APP_ORIGIN and that your browser can open it.
  • No certificate: check that DNS points at this server and that ports 80 and 443 are reachable from the internet (docker compose logs caddy).

Every setting is listed in the Configuration reference.