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)| Service | Role | Data (volume) |
|---|---|---|
caddy | Entry point. For https:// origins it obtains and renews Let's Encrypt certificates automatically | caddy-data (certificates), caddy-config |
app | TrayPage itself (web UI, REST API, MCP, shared pages). Applies database migrations automatically on startup | None |
postgres | Database (profile postgres) | postgres-data |
seaweedfs | S3-compatible storage for page content (profile s3). Creates the bucket on startup | seaweedfs-data |
mailpit | Catches 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-
Open http://localhost:3000, enter your email address, and send yourself a sign-in link.
-
Open the link in the email that arrives at http://localhost:8025 (the bundled Mailpit).
-
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 -
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 hosthttp://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
-
Choose two host names and point their DNS A / AAAA records at the server's IP (for example
traypage.example.comandview.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 exampletraypage-content.example.net), which also keeps it out of the cookie scope. -
Open ports 80 and 443 (TCP) in the firewall. Port 80 is used to obtain certificates and to redirect to https.
-
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> -
Run
docker compose up -d. On the first start it takes tens of seconds for Caddy to obtain certificates (watch withdocker 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).
Sign-in links and invitations are sent by email. The sender is chosen in this order:
| Setting | How email is sent |
|---|---|
RESEND_API_KEY | The 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=10Create 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 -dDatabase migrations are applied automatically when the new image starts. Take a backup before updating.
Image tags
| Tag | Contents |
|---|---|
vX.Y.Z | A release. Pin production to this |
vX.Y | The latest release of that minor version |
latest | The latest release (pre-releases excluded) |
edge | The 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.
| What | How |
|---|---|
| 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 |
.env | Copy 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 appYou 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 -dCleaning up
docker compose down # stop the containers (data is kept)
docker compose down -v # also delete volumes (database, page content, certificates). Cannot be undoneTroubleshooting
- Run
docker compose psand check that every service isrunning(healthy). Read the app log withdocker compose logs app. - Sign-in links do not arrive: check
SMTP_*(sending failures show up indocker compose logs app). Locally, look at http://localhost:8025. - Shared page content does not appear: check that
VIEWER_ORIGINis a different host name fromAPP_ORIGINand 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.