diff --git a/knowledge-base/assets.mdx b/knowledge-base/assets.mdx
index 2b39e3a..477b714 100644
--- a/knowledge-base/assets.mdx
+++ b/knowledge-base/assets.mdx
@@ -54,4 +54,4 @@ There's no public API for the asset library yet. The closest equivalents are pos
## Self-hosted
- Stock-photo and GIF tabs require valid `UNSPLASH_ACCESS_KEY` and `GIPHY_API_KEY` in your `.env`. Without them, those tabs stay disabled — the Library tab works regardless.
-- Files are stored on the disk configured by `FILESYSTEM_DISK` (defaults to `r2` in shipped config). For production, point this at S3, R2, or any S3-compatible service. See the [Configuration guide](/self-hosting/configuration).
+- Files are stored on the disk configured by `FILESYSTEM_DISK` (defaults to `public`). For production with multiple instances or heavy media, point this at S3, R2, Spaces, or any S3-compatible service. See the [Configuration guide](/self-hosting/configuration).
diff --git a/knowledge-base/media.mdx b/knowledge-base/media.mdx
index 27971c6..375b6c4 100644
--- a/knowledge-base/media.mdx
+++ b/knowledge-base/media.mdx
@@ -126,9 +126,10 @@ Media files are stored on the disk configured by `FILESYSTEM_DISK`. Out of the b
| Driver | Use case |
|--------|----------|
-| `public` | Self-hosted setups serving files directly from `${APP_URL}/storage` — requires `php artisan storage:link` |
+| `public` | Default — serve files from `${APP_URL}/storage` (requires `php artisan storage:link`) |
| `s3` | AWS S3 |
-| `r2` | Cloudflare R2 (S3-compatible — the default in shipped config) |
+| `r2` | Cloudflare R2 (S3-compatible) |
+| `spaces` | DigitalOcean Spaces |
Any S3-compatible service (MinIO, DigitalOcean Spaces, Backblaze B2) works under the `s3` driver with the right endpoint.
diff --git a/platforms/telegram.mdx b/platforms/telegram.mdx
index 360f0a2..8fb08a7 100644
--- a/platforms/telegram.mdx
+++ b/platforms/telegram.mdx
@@ -54,5 +54,5 @@ php artisan telegram:set-webhook
```
- The webhook URL is `{APP_URL}/telegram/webhook`, so `APP_URL` must be a **public HTTPS URL** Telegram can reach. Re-run `telegram:set-webhook` whenever it changes. See the [Self-Hosting configuration guide](/self-hosting/configuration).
+ The webhook URL is `{WEBHOOK_URL or APP_URL}/telegram/webhook`. That base URL must be a **public HTTPS** address Telegram can reach. If `APP_URL` is private (or you use a tunnel in development), set `WEBHOOK_URL` instead — see [Configuration](/self-hosting/configuration#basic-configuration). Re-run `telegram:set-webhook` whenever it changes.
diff --git a/self-hosting/configuration.mdx b/self-hosting/configuration.mdx
index a9a1308..82b350b 100644
--- a/self-hosting/configuration.mdx
+++ b/self-hosting/configuration.mdx
@@ -15,6 +15,7 @@ APP_NAME="TryPost"
APP_ENV=local
APP_DEBUG=true
APP_URL=http://localhost
+WEBHOOK_URL=
```
| Variable | Description |
@@ -23,6 +24,7 @@ APP_URL=http://localhost
| `APP_ENV` | Environment: `local`, `staging`, `production` |
| `APP_DEBUG` | Enable debug mode (set to `false` in production) |
| `APP_URL` | Your application URL |
+| `WEBHOOK_URL` | Optional public base URL for inbound webhooks (e.g. Telegram) when `APP_URL` is not reachable from the internet. Defaults to `APP_URL`. |
## Self-Hosted Mode
@@ -32,7 +34,7 @@ SELF_HOSTED=true
`SELF_HOSTED=true` is the default. It bypasses everything billing-related so you never have to touch Stripe or Cashier, and it locks down public sign-ups:
-- **Public registration is disabled** — `/register` is closed so random users can't create accounts on your instance. Bootstrap the first admin via the `UserSeeder` (see [Create the admin user](/self-hosting/installation#3-create-the-admin-user)); after that, every account comes from a workspace invite (Settings → Members).
+- **Public registration is disabled** — `/register` is closed so random users can't create accounts on your instance. Bootstrap the first admin via the `UserSeeder` (see [Seed default data and create the admin user](/self-hosting/installation#6-seed-passport-client-and-admin-user)); after that, every account comes from a workspace invite (Settings → Members).
- No Stripe, Cashier, or `CASHIER_TRIAL_DAYS` configuration required
- The `LoadWorkspaceFromToken` middleware skips the `402 Payment Required` gate
- Subscription gating is not enforced, so workspaces, social accounts, members, and AI credits are unlimited per account
@@ -76,29 +78,63 @@ REDIS_PASSWORD=null
REDIS_PORT=6379
```
+## Runtime drivers
+
+For a working production-like stack, point the Laravel drivers at Redis / Reverb / the database:
+
+```env
+QUEUE_CONNECTION=redis
+CACHE_STORE=redis
+SESSION_DRIVER=database
+BROADCAST_CONNECTION=reverb
+```
+
+| Variable | Recommended | Why |
+|----------|-------------|-----|
+| `QUEUE_CONNECTION` | `redis` | Horizon processes Redis queues (publishing, notifications, automations) |
+| `CACHE_STORE` | `redis` | Cache + `onOneServer()` schedule locks across instances |
+| `SESSION_DRIVER` | `database` | Durable sessions behind multiple app workers |
+| `BROADCAST_CONNECTION` | `reverb` | Real-time dashboard updates |
+
+Without Redis queues, Horizon has nothing useful to process. Without Redis cache on multi-instance deploys, scheduled jobs can double-fire.
+
+## Passport (API & MCP tokens)
+
+Self-hosted API keys and MCP Bearer auth need both:
+
+```bash
+php artisan passport:keys # once — RSA files in storage/
+php artisan db:seed # once — Personal Access Client in the DB
+```
+
+Alternatively, set `PASSPORT_PRIVATE_KEY` and `PASSPORT_PUBLIC_KEY` in `.env` (PEM contents) instead of key files — useful for secret managers. Do not regenerate keys after users have issued tokens.
+
+Full install order: [Installation](/self-hosting/installation).
+
+
## Media size limits
```env
MEDIA_IMAGE_MAX_SIZE_MB=10
MEDIA_VIDEO_MAX_SIZE_MB=1024
-MCP_UPLOAD_MAX_SIZE_MB=50
-MCP_UPLOAD_URL_TTL_MINUTES=15
+MEDIA_DOCUMENT_MAX_SIZE_MB=100
+MEDIA_SIGNED_UPLOAD_URL_TTL_MINUTES=15
```
| Variable | Default | Description |
|----------|---------|-------------|
| `MEDIA_IMAGE_MAX_SIZE_MB` | `10` | Max image upload size, in MB. Applied to direct uploads and URL fetches. |
| `MEDIA_VIDEO_MAX_SIZE_MB` | `1024` | Max video upload size, in MB. Applied to direct uploads and URL fetches. |
-| `MCP_UPLOAD_MAX_SIZE_MB` | `50` | Hard cap (in MB) for files uploaded through the MCP `request-media-upload-tool` signed-URL flow. Intentionally smaller than the per-type caps because uploads stream through the app server. Raise it if your operators / nginx are sized for larger payloads. |
-| `MCP_UPLOAD_URL_TTL_MINUTES` | `15` | Lifetime of the signed upload URL returned by `request-media-upload-tool`. |
+| `MEDIA_DOCUMENT_MAX_SIZE_MB` | `100` | Max document (PDF) upload size, in MB. |
+| `MEDIA_SIGNED_UPLOAD_URL_TTL_MINUTES` | `15` | Lifetime of the signed upload URL returned by MCP / API media upload flows. (`MCP_UPLOAD_URL_TTL_MINUTES` remains as a legacy fallback.) |
-If you raise the video limit, also bump PHP's `upload_max_filesize` and `post_max_size` and any reverse-proxy body-size cap to match. The MCP upload endpoint is additionally rate-limited at **10 requests / minute / IP** — this is a hard-coded defense; tune the surrounding nginx/Cloudflare limits if you need a different floor.
+Signed uploads are rate-limited per workspace and per IP (`MEDIA_SIGNED_UPLOAD_PER_WORKSPACE_PER_MINUTE`, `MEDIA_SIGNED_UPLOAD_PER_IP_PER_MINUTE`). If you raise the video limit, also bump PHP's `upload_max_filesize` / `post_max_size` and any reverse-proxy body-size cap to match.
## File Storage
TryPost supports local public storage and S3-compatible cloud storage.
-The default disk is **Cloudflare R2** (`FILESYSTEM_DISK=r2`). For self-hosted installs without object storage configured, switch to `public`.
+The application default is **`public`** (`FILESYSTEM_DISK=public`). Use object storage (S3 / R2 / Spaces) when you outgrow local disk or run multiple app instances.
### Public Disk
@@ -108,12 +144,14 @@ Stores files in `storage/app/public` and serves them directly from `${APP_URL}/s
FILESYSTEM_DISK=public
```
-After switching, create the symlink from `public/storage` to `storage/app/public`:
+Create the symlink from `public/storage` to `storage/app/public` (once per install):
```bash
php artisan storage:link
```
+Only needed for the `public` disk. Object storage (`s3`, `r2`, `spaces`) does not use this symlink.
+
See Laravel's [public disk documentation](https://laravel.com/docs/13.x/filesystem#the-public-disk) for more details.
### AWS S3
@@ -443,6 +481,16 @@ HORIZON_ALLOWED_EMAILS=admin@example.com,dev@example.com
If not set, Horizon dashboard will be inaccessible in non-local environments.
+## Advanced
+
+```env
+TRYPOST_ALLOW_PRIVATE_NETWORK=false
+```
+
+| Variable | Default | Description |
+|----------|---------|-------------|
+| `TRYPOST_ALLOW_PRIVATE_NETWORK` | `false` | When `true`, allows HTTP requests from TryPost (RSS, webhooks, URL media fetches, etc.) to private/internal network addresses. Keep `false` unless you intentionally need that for a private network install. |
+
## Next Steps
- [Connect your social accounts](/)
diff --git a/self-hosting/docker.mdx b/self-hosting/docker.mdx
index bd84cdd..597ebb9 100644
--- a/self-hosting/docker.mdx
+++ b/self-hosting/docker.mdx
@@ -1,19 +1,37 @@
---
title: Docker
-description: Run TryPost in Docker with Laravel Sail
+description: Run TryPost with Docker Compose
icon: "docker"
---
# Docker
-TryPost ships with a working `compose.yaml` that uses [Laravel Sail](https://laravel.com/docs/sail) for a containerized dev environment. The compose file includes PostgreSQL 18, Redis, Mailpit (local SMTP UI), and Selenium for browser tests — no `sail:install` step needed.
+TryPost ships two Compose files:
+
+| File | Purpose |
+|------|---------|
+| `compose.prod.yaml` | **Self-hosting in production** — pulls `ghcr.io/trypostit/trypost:latest`, Postgres, Redis, Horizon, Reverb, scheduler, optional Caddy TLS |
+| `compose.yaml` | Local development — builds from source, bind-mounts the repo, Mailpit, Vite |
## Requirements
- Docker Desktop (Mac, Windows) or Docker Engine (Linux)
- Docker Compose v2
-## Quick start
+---
+
+## Production self-hosting
+
+No local PHP/Node install required. The published image includes the built frontend.
+
+### What you do vs what the container does
+
+| You (once) | Entrypoint / Supervisor (automatic) |
+|------------|-------------------------------------|
+| Set `APP_KEY`, `APP_URL`, DB passwords, `REVERB_APP_SECRET` | Wait for Postgres, run migrations |
+| `docker compose … up -d` | `passport:keys` if missing, `storage:link` if missing |
+| `db:seed` + `UserSeeder` | Cache config/routes/views/events |
+| Optional: Caddy TLS profile | Start nginx, php-fpm, **Horizon**, **Reverb**, **schedule:work** |
### 1. Clone the repository
@@ -22,181 +40,188 @@ git clone https://github.com/trypostit/trypost.git
cd trypost
```
-### 2. Install Composer dependencies
+You only need the repo for `compose.prod.yaml` (and optionally `Caddyfile`). The app image comes from GHCR.
-If you have PHP 8.2+ on your host:
+### 2. Generate an app key and edit the compose file
```bash
-composer install
+docker compose -f compose.prod.yaml run --rm app php artisan key:generate --show
```
-Otherwise use a throwaway container — no PHP install required:
+Paste the value into `APP_KEY` in `compose.prod.yaml`. Also set:
-```bash
-docker run --rm \
- -u "$(id -u):$(id -g)" \
- -v "$(pwd):/var/www/html" \
- -w /var/www/html \
- laravelsail/php85-composer:latest \
- composer install --ignore-platform-reqs
-```
+- `APP_URL` — your public URL (e.g. `https://post.yourdomain.com`)
+- `DB_PASSWORD` / `POSTGRES_PASSWORD` (must match)
+- `REVERB_APP_SECRET`
+
+Leave `REVERB_APP_KEY` as `trypost-reverb-key` unless you rebuild the image — it must match the key baked into the published frontend bundle.
-### 3. Configure environment
+Queues, cache, session, and broadcast are already set to Redis / Reverb / database in `compose.prod.yaml`. Configure **mail** (defaults to `log`) and optional social/AI credentials in the same file — see [Configuration](/self-hosting/configuration).
+
+### 3. Start the stack
```bash
-cp .env.example .env
+docker compose -f compose.prod.yaml up -d
```
-Update the database and Redis hosts so they point at the Sail containers:
-
-```env
-APP_URL=http://localhost
+On first boot the entrypoint:
-DB_CONNECTION=pgsql
-DB_HOST=pgsql
-DB_PORT=5432
-DB_DATABASE=trypost
-DB_USERNAME=sail
-DB_PASSWORD=password
+- waits for Postgres
+- runs migrations
+- creates `storage:link` if missing (unused if you later switch to S3 / R2 / Spaces)
+- generates Passport keys if missing (`passport:keys`)
+- caches config / routes / views / events
+- starts nginx, php-fpm, Horizon, Reverb, and `schedule:work`
-REDIS_HOST=redis
-```
+It does **not** seed the database — next step.
-### 4. Start the containers
+### 4. Seed Passport client and admin user (one-time)
```bash
-./vendor/bin/sail up -d
+docker compose -f compose.prod.yaml exec app php artisan db:seed
+docker compose -f compose.prod.yaml exec app php artisan db:seed --class=UserSeeder
```
-That brings up `laravel.test`, `pgsql`, `redis`, `mailpit`, and `selenium`.
+| Seeder | Purpose |
+|--------|---------|
+| `DatabaseSeeder` | Passport Personal Access Client + plan rows |
+| `UserSeeder` | Admin (`admin@trypost.it` / `password`) |
-### 5. Generate the app key
+Change the admin password on first login. Extra accounts come from workspace invites (Settings → Members).
-```bash
-./vendor/bin/sail artisan key:generate
-```
+
+ Passport **keys** = entrypoint. Passport **Personal Access Client** = `db:seed`. API keys and MCP need both.
+
-Migrations run as part of the seed step below.
+### 5. Optional HTTPS with Caddy
-### 6. Seed default data and create the admin user
-
-Self-hosted installs default to `SELF_HOSTED=true`, which closes `/register`. On a fresh database, run this once:
+1. Point DNS at the host
+2. Set `APP_URL` to `https://your-domain` and `APP_DOMAIN` on the `caddy` service
+3. Start with the proxy profile:
```bash
-./vendor/bin/sail artisan migrate:fresh --seed && ./vendor/bin/sail artisan db:seed --class=UserSeeder
+docker compose -f compose.prod.yaml --profile proxy up -d
```
-This rebuilds the schema, runs the default `DatabaseSeeder` (Passport personal access client + plan rows), and creates the admin user.
+Without the profile, the app listens on `http://localhost:8000`.
-
- `migrate:fresh` **drops every table**. Only run it on a brand-new database. If you already have data, run `sail artisan db:seed && sail artisan db:seed --class=UserSeeder` instead.
-
-
-Default credentials — **change the password on first login**:
-
-| Field | Value |
-|---|---|
-| Email | `admin@trypost.it` |
-| Password | `password` |
-
-Additional accounts come from workspace invites (Settings → Members).
-
-### 7. Install npm packages and build the front-end
+### 6. Verify
```bash
-./vendor/bin/sail npm install
-./vendor/bin/sail npm run build
+docker compose -f compose.prod.yaml exec app php artisan horizon:status
+docker compose -f compose.prod.yaml logs -f app
```
-For active development with hot reload, use `sail npm run dev` instead.
+1. Open `APP_URL` and sign in as admin
+2. Create an API key (Settings → API) — proves Passport keys + client exist
+3. Schedule a post — Horizon must show as running
-## Accessing TryPost
+### Access
| Service | URL |
|---------|-----|
-| TryPost dashboard | `http://localhost` |
-| Mailpit (captured emails) | `http://localhost:8025` |
-| Postgres | `localhost:5432` |
-| Redis | `localhost:6379` |
-
-## Common Commands
+| TryPost | `http://localhost:8000` (or your `APP_URL`) |
+| Reverb | port `8080`, proxied under `/app/` and `/apps/` by in-container nginx |
-```bash
-# Start containers
-./vendor/bin/sail up -d
-
-# Stop containers
-./vendor/bin/sail down
+### Reverb on a custom domain
-# View logs
-./vendor/bin/sail logs
+The published image bakes `VITE_REVERB_*` at build time (`localhost:8080`). For HTTPS on your domain, rebuild:
-# Run Artisan commands
-./vendor/bin/sail artisan
+```bash
+docker build \
+ --build-arg VITE_REVERB_HOST=post.yourdomain.com \
+ --build-arg VITE_REVERB_PORT=443 \
+ --build-arg VITE_REVERB_SCHEME=https \
+ --build-arg VITE_REVERB_APP_KEY=trypost-reverb-key \
+ -t trypost-app:custom \
+ -f docker/Dockerfile \
+ --target production \
+ .
+```
-# Run npm commands
-./vendor/bin/sail npm
+Point `compose.prod.yaml` at that image. Changing only runtime `REVERB_HOST` / `PORT` / `SCHEME` does not update the browser client.
-# Access PostgreSQL
-./vendor/bin/sail psql
+### Common production commands
-# Access Redis CLI
-./vendor/bin/sail redis
+```bash
+docker compose -f compose.prod.yaml logs -f app
+docker compose -f compose.prod.yaml exec app php artisan horizon:status
+docker compose -f compose.prod.yaml exec app php artisan migrate --force
+docker compose -f compose.prod.yaml down
```
-## Running the Queue Worker
+---
-For background job processing (publishing, notifications, token refresh):
+## Local development
-```bash
-./vendor/bin/sail artisan horizon
-```
+Use `compose.yaml` when you want a bind-mounted source tree.
-Or run it detached alongside the rest of the stack:
+### 1. Clone and start
```bash
-./vendor/bin/sail up -d
-./vendor/bin/sail artisan horizon
+git clone https://github.com/trypostit/trypost.git
+cd trypost
+docker compose up -d
```
-## Shell alias
+On first boot the entrypoint can seed `.env` from `docker/.env.docker.example`, install Composer/npm deps if missing, generate `APP_KEY`, migrate, `storage:link`, and `passport:keys`. Horizon, Reverb, the scheduler, and Vite start via Supervisor.
+
+For local media URLs, set `FILESYSTEM_DISK=public` in `.env` (the Docker example ships with `local`) and ensure `storage:link` ran.
-Add this alias to your shell profile (`~/.bashrc`, `~/.zshrc`) so you can drop the `./vendor/bin/` prefix:
+### 2. Seed admin + Passport client
```bash
-alias sail='./vendor/bin/sail'
+docker compose exec app php artisan db:seed
+docker compose exec app php artisan db:seed --class=UserSeeder
```
-Then:
+Or wipe-and-rebuild on a brand-new database:
```bash
-sail up -d
-sail artisan migrate
-sail npm run dev
+docker compose exec app php artisan migrate:fresh --seed
+docker compose exec app php artisan db:seed --class=UserSeeder
```
-## Production with Docker
+
+ `migrate:fresh` **drops every table**.
+
-The shipped `compose.yaml` is tuned for development (debug mode, Mailpit, Selenium). For production:
+| Field | Value |
+|---|---|
+| Email | `admin@trypost.it` |
+| Password | `password` |
-1. Use a slimmer compose file without Selenium/Mailpit and with persistent named volumes for `storage/app` and Postgres data
-2. Set `APP_ENV=production`, `APP_DEBUG=false`
-3. Terminate SSL at a reverse proxy (Nginx, Traefik, Caddy)
-4. Configure long-running supervisors for Horizon, Reverb, and the Laravel scheduler — see [Production setup](/self-hosting/production)
+### Access (dev)
+
+| Service | URL |
+|---------|-----|
+| TryPost | `http://localhost:8000` (or `APP_PORT`) |
+| Mailpit | `http://localhost:8025` |
+| Vite HMR | `http://localhost:5173` |
+| Postgres | `localhost:5432` |
+| Redis | `localhost:6379` |
+| Reverb | `localhost:8080` |
+
+### Common development commands
+
+```bash
+docker compose up -d
+docker compose down
+docker compose logs -f app
+docker compose exec app php artisan
+```
## Troubleshooting
### Port conflicts
-If port 80 is already in use, change `APP_PORT` in `.env`:
-
```env
APP_PORT=8080
+FORWARD_DB_PORT=5433
+FORWARD_MAILPIT_UI_PORT=8026
```
-### Permission issues
-
-On Linux you may need:
+### Permission issues (Linux)
```bash
sudo chown -R $USER: .
@@ -204,10 +229,29 @@ sudo chown -R $USER: .
### Database connection refused
-Make sure the database container is healthy:
+```bash
+docker compose ps
+# or
+docker compose -f compose.prod.yaml ps
+```
+
+### API key creation fails with "Personal access client not found"
+
+Seed the Passport client:
+
+```bash
+docker compose -f compose.prod.yaml exec app php artisan db:seed
+```
+
+### Horizon not processing jobs
```bash
-./vendor/bin/sail ps
+docker compose -f compose.prod.yaml exec app php artisan horizon:status
+docker compose -f compose.prod.yaml logs app | grep -i horizon
```
-Give Postgres a few seconds to initialize on first boot.
+## Next steps
+
+- [Configuration](/self-hosting/configuration) — mail, storage, social OAuth, AI
+- [Production setup](/self-hosting/production) — bare-metal Nginx / Supervisor if you are not using `compose.prod.yaml`
+- [Installation](/self-hosting/installation) — clone-and-install without Docker
diff --git a/self-hosting/installation.mdx b/self-hosting/installation.mdx
index 5d83b89..6118394 100644
--- a/self-hosting/installation.mdx
+++ b/self-hosting/installation.mdx
@@ -6,12 +6,12 @@ icon: "download"
# Installation
-This guide walks through installing TryPost on a server (bare-metal or VM). For Docker-first setups, see [Docker](/self-hosting/docker).
+Step-by-step install on a bare-metal server or VM. Prefer containers? Use [Docker production](/self-hosting/docker#production-recommended-for-self-hosting) instead.
## Requirements
-- PHP 8.2 or higher
-- Node.js 20 or higher
+- PHP 8.2+ (with the extensions listed in [Requirements](/self-hosting/requirements))
+- Node.js 20+
- PostgreSQL 14+ or MySQL 8+
- Redis 6+
- Composer 2.x
@@ -25,76 +25,174 @@ git clone https://github.com/trypostit/trypost.git
cd trypost
```
-### 2. First-time setup (one command)
+### 2. Install PHP and Node dependencies
```bash
-composer setup
+composer install
+cp .env.example .env
+php artisan key:generate
+npm install
```
-This runs `composer install`, copies `.env.example` to `.env`, generates `APP_KEY`, runs `php artisan migrate --force`, installs npm packages, and runs `npm run build`. After it finishes, edit `.env` to point at your real database, Redis, and any social platform credentials — see [Configuration](/self-hosting/configuration).
+`php artisan key:generate` writes `APP_KEY` into `.env`. Without it the app will not boot.
+
+### 3. Configure the environment
+
+Edit `.env` **before** migrating. Minimum for a working self-hosted instance:
-If you prefer doing each step yourself:
+```env
+APP_URL=https://your-domain.com
+APP_ENV=production
+APP_DEBUG=false
+
+SELF_HOSTED=true
+
+DB_CONNECTION=pgsql
+DB_HOST=127.0.0.1
+DB_PORT=5432
+DB_DATABASE=trypost
+DB_USERNAME=postgres
+DB_PASSWORD=your_password
+
+REDIS_HOST=127.0.0.1
+REDIS_PORT=6379
+
+QUEUE_CONNECTION=redis
+CACHE_STORE=redis
+SESSION_DRIVER=database
+BROADCAST_CONNECTION=reverb
+
+FILESYSTEM_DISK=public
+```
+
+| Block | Why |
+|-------|-----|
+| `APP_KEY` | Already set by `key:generate` — encrypts sessions, cookies, encrypted data |
+| Database | Where posts, users, and Passport clients live |
+| Redis + queue/cache drivers | Horizon publishes posts and runs automations from Redis queues |
+| `BROADCAST_CONNECTION=reverb` | Live dashboard updates |
+| `FILESYSTEM_DISK=public` | Local media on disk (use `s3` / `r2` / `spaces` for object storage) |
+| `SELF_HOSTED=true` | Disables Stripe billing and public `/register` |
+
+Also configure **mail** (invites / password reset) and optional social OAuth / AI keys — see [Configuration](/self-hosting/configuration).
+
+
+ Prefer a one-liner for deps? `composer setup` runs install + `key:generate` + migrate + npm build, but it migrates **before** you edit `.env`. Safer path for a real server: follow the steps above (configure `.env`, then migrate yourself).
+
+
+### 4. Run migrations
```bash
-composer install
-cp .env.example .env
-php artisan key:generate
-# edit .env now
-php artisan migrate
-npm install
-npm run build
+php artisan migrate --force
```
-### 3. Seed default data and create the admin user
+### 5. Passport keys (and local storage symlink)
-Self-hosted installs default to `SELF_HOSTED=true`, which closes `/register` to the public — your instance only exists for you and the people you invite.
+Passport needs an RSA key pair on disk to **sign and verify** API / MCP tokens:
-On a fresh database, run this once:
+```bash
+php artisan passport:keys
+```
+
+Run once. Do **not** regenerate later unless you intend to invalidate every existing API token.
+
+If you keep `FILESYSTEM_DISK=public`, also create the public media symlink:
```bash
-php artisan migrate:fresh --seed && php artisan db:seed --class=UserSeeder
+php artisan storage:link
```
-This rebuilds the schema, runs the default `DatabaseSeeder` (which provisions the **Laravel Passport Personal Access Client** required to issue API tokens, plus the plan rows), and then creates the admin user.
+Skip `storage:link` when using object storage (`s3`, `r2`, `spaces`) — those disks serve from the bucket URL.
-
- `migrate:fresh` **drops every table**. Only run it on a brand-new database during initial install. If you already have data, run `php artisan db:seed && php artisan db:seed --class=UserSeeder` instead — both seeders are idempotent.
-
+### 6. Seed Passport client and admin user
+
+Self-hosted installs close `/register`. Bootstrap the database once:
+
+```bash
+php artisan db:seed
+php artisan db:seed --class=UserSeeder
+```
+
+| Seeder | What it creates |
+|--------|-----------------|
+| `DatabaseSeeder` (`db:seed`) | **Passport Personal Access Client** (required to issue API tokens) + plan rows |
+| `UserSeeder` | Admin account |
+
+
+ Passport is two independent pieces:
+
+ 1. **Keys** (`passport:keys`) — files on disk
+ 2. **Personal Access Client** (`db:seed`) — row in `oauth_clients`
-The admin account ships with fixed credentials — **change the password immediately on first login**:
+ API keys in Settings and MCP Bearer auth need **both**.
+
+
+Admin credentials — **change the password immediately on first login**:
| Field | Value |
|---|---|
| Email | `admin@trypost.it` |
| Password | `password` |
-After this, every other account on the instance comes from a workspace invite (Settings → Members) — invitees get a link that lets them sign up even though open registration stays off.
+Further accounts come from workspace invites (Settings → Members).
+
+
+ Only on a brand-new empty database you may use `php artisan migrate:fresh --seed && php artisan db:seed --class=UserSeeder`. `migrate:fresh` **drops every table**.
+
-### 4. Run all dev processes in one terminal
+### 7. Build the frontend
+
+```bash
+npm run build
+```
+
+### 8. Run the app
+
+**Local / smoke test** (one terminal):
```bash
composer dev
```
-This concurrently starts:
+This starts `php artisan serve`, a queue listener, log tail, and Vite. Open `http://localhost:8000` and sign in with the admin user.
+
+**Production** — do **not** use `composer dev`. You need long-running processes:
-- `php artisan serve` — HTTP server on `http://localhost:8000`
-- `php artisan queue:listen` — queue worker
-- `php artisan pail` — live log tail
-- `npm run dev` — Vite with hot reload
+| Process | Command | Purpose |
+|---------|---------|---------|
+| Web (PHP-FPM + Nginx) | see [Production](/self-hosting/production#nginx-configuration) | Serve HTTP |
+| **Horizon** | `php artisan horizon` | Queues — publish posts, notifications, automations |
+| **Reverb** | `php artisan reverb:start` | WebSockets |
+| **Scheduler** | cron → `php artisan schedule:run` | Due posts, token refresh, automations |
-For Inertia SSR development, use `composer dev:ssr` instead.
+Full Supervisor configs, Nginx (including Reverb `/app/` + `/apps/`), SSL, and caches: [Production setup](/self-hosting/production).
-### 5. Production process supervisors
+## End-to-end checklist
-For production you don't use `composer dev`. Configure separate supervisor entries for the web server (PHP-FPM / Octane), Horizon (queues), Reverb (WebSockets), and the cron job for `php artisan schedule:run` — see [Production setup](/self-hosting/production).
+| Step | Done when |
+|------|-----------|
+| `APP_KEY` set | `key:generate` ran |
+| Database reachable | `.env` `DB_*` correct |
+| Queues on Redis | `QUEUE_CONNECTION=redis` + Redis up |
+| Migrations applied | `php artisan migrate` |
+| Passport keys | `storage/oauth-private.key` exists |
+| Passport client | `php artisan db:seed` ran |
+| Admin user | `UserSeeder` ran; you can log in |
+| Media (local) | `storage:link` if `FILESYSTEM_DISK=public` |
+| Frontend built | `npm run build` |
+| Horizon running | posts actually publish |
+| Reverb + scheduler | live UI + cron jobs |
+| Mail configured | invites / password reset work |
-## Verify the install
+## Verify
-Visit `http://localhost:8000` (or your configured `APP_URL`) and sign in with the admin credentials you set in step 3. You'll land in the dashboard.
+1. Sign in at `APP_URL` with the admin credentials
+2. Create an API key under workspace Settings → API (fails with 500 if Passport keys or client are missing)
+3. Upload a media file (404s if you use `public` disk without `storage:link`)
+4. Schedule a test post and confirm Horizon processes it
-## Next Steps
+## Next steps
-- [Configure your environment](/self-hosting/configuration)
-- [Connect your first social account](/)
-- [Production deployment](/self-hosting/production)
+- [Configuration](/self-hosting/configuration) — mail, storage, social OAuth, AI
+- [Production setup](/self-hosting/production) — Nginx, SSL, Horizon, Reverb, cron
+- [Docker](/self-hosting/docker) — same stack via Compose
diff --git a/self-hosting/overview.mdx b/self-hosting/overview.mdx
index 558a58c..247cdaf 100644
--- a/self-hosting/overview.mdx
+++ b/self-hosting/overview.mdx
@@ -10,6 +10,23 @@ TryPost is open-source and can be self-hosted on your own server. This section c
Don't want to manage infrastructure? Use [TryPost Cloud](https://app.trypost.it/register) — no setup required, free to get started.
+## Pick a path
+
+| Path | Best when | Guide |
+|------|-----------|-------|
+| **Docker production** | You want the published image with Horizon, Reverb, and the scheduler already wired | [Docker → Production](/self-hosting/docker#production-recommended-for-self-hosting) |
+| **Bare metal** | You install PHP/Node yourself on a VPS | [Installation](/self-hosting/installation) then [Production](/self-hosting/production) |
+| **Docker development** | You contribute to the codebase locally | [Docker → Local development](/self-hosting/docker#local-development) |
+
+Every path needs the same building blocks:
+
+1. **App key** (`APP_KEY`)
+2. **Database** configured and migrated
+3. **Redis** + `QUEUE_CONNECTION=redis` (Horizon)
+4. **Passport keys** + **Personal Access Client** (API / MCP tokens)
+5. **Admin user** (`UserSeeder`)
+6. Long-running **Horizon**, **Reverb**, and the **scheduler** (Docker prod starts these for you)
+
## Deployment options
@@ -17,7 +34,7 @@ TryPost is open-source and can be self-hosted on your own server. This section c
Step-by-step guide to install TryPost on a server.
- Deploy with Docker and Laravel Sail.
+ Deploy with Docker Compose — local development or production.
@@ -28,7 +45,7 @@ TryPost is open-source and can be self-hosted on your own server. This section c
Environment variables, database, mail, storage, and more.
- Nginx, SSL, queue workers, cron jobs, and WebSockets.
+ Nginx, SSL, Horizon, cron, and WebSockets (bare metal).
diff --git a/self-hosting/production.mdx b/self-hosting/production.mdx
index d9db3f4..43e3c7c 100644
--- a/self-hosting/production.mdx
+++ b/self-hosting/production.mdx
@@ -6,11 +6,23 @@ icon: "rocket"
# Production Setup
-This guide covers deploying TryPost to a production environment.
+This guide is for **bare metal / a VM** after you have completed [Installation](/self-hosting/installation) (app key, `.env`, migrations, Passport keys, seed, frontend build).
-## Environment Configuration
+Prefer containers? Use [`compose.prod.yaml`](/self-hosting/docker#production-recommended-for-self-hosting) — it already runs Horizon, Reverb, and the scheduler for you.
-Set these values in your `.env` for production:
+## Before you continue
+
+Confirm the install checklist is done:
+
+- [ ] `APP_KEY` set
+- [ ] Database migrated
+- [ ] `QUEUE_CONNECTION=redis` and Redis reachable
+- [ ] `php artisan passport:keys` ran
+- [ ] `php artisan db:seed` + `UserSeeder` ran
+- [ ] `storage:link` ran **if** `FILESYSTEM_DISK=public`
+- [ ] `npm run build` completed
+
+## Environment (production values)
```env
APP_ENV=production
@@ -18,24 +30,27 @@ APP_DEBUG=false
APP_URL=https://your-domain.com
SELF_HOSTED=true
+
+QUEUE_CONNECTION=redis
+CACHE_STORE=redis
+SESSION_DRIVER=database
+BROADCAST_CONNECTION=reverb
+FILESYSTEM_DISK=public
```
+See [Configuration](/self-hosting/configuration) for mail, object storage, and social OAuth.
+
## Optimizations
Run these commands after deployment:
```bash
-# Cache configuration
-php artisan config:cache
+composer install --optimize-autoloader --no-dev
-# Cache routes
+php artisan config:cache
php artisan route:cache
-
-# Cache views
php artisan view:cache
-
-# Optimize autoloader
-composer install --optimize-autoloader --no-dev
+php artisan event:cache
```
## Queue Worker
@@ -46,8 +61,9 @@ TryPost uses queues for critical background processing:
- **Sending notifications** (email and in-app)
- **Verifying social account connections**
- **Processing analytics events**
+- **Running automations**
-Without a running queue worker, scheduled posts will not be published. Use Supervisor to keep the queue worker running.
+Without a running queue worker, scheduled posts will not be published. Use Supervisor to keep Horizon running.
### Install Supervisor
@@ -112,6 +128,30 @@ server {
add_header X-Content-Type-Options "nosniff";
charset utf-8;
+ client_max_body_size 1G;
+
+ # Reverb WebSocket (Pusher protocol) + HTTP API
+ location /app/ {
+ proxy_pass http://127.0.0.1:8080;
+ proxy_http_version 1.1;
+ proxy_set_header Upgrade $http_upgrade;
+ proxy_set_header Connection "upgrade";
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_read_timeout 86400s;
+ proxy_send_timeout 86400s;
+ }
+
+ location /apps/ {
+ proxy_pass http://127.0.0.1:8080;
+ proxy_http_version 1.1;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ }
location / {
try_files $uri $uri/ /index.php?$query_string;
@@ -132,11 +172,13 @@ server {
location ~ /\.(?!well-known).* {
deny all;
}
-
- client_max_body_size 2G;
}
```
+
+ Both `/app/` (browser WebSocket) and `/apps/` (Pusher HTTP API) must be proxied to Reverb. Omitting `/apps/` causes flaky real-time updates.
+
+
## SSL Certificate
Use Let's Encrypt for free SSL:
@@ -148,14 +190,18 @@ sudo certbot --nginx -d your-domain.com
## Scheduled Tasks (Cron)
-TryPost uses Laravel's task scheduler for several recurring jobs. The scheduler **must** run every minute or scheduled posts won't publish, expiring tokens won't refresh, and stuck posts won't recover.
+TryPost uses Laravel's task scheduler for several recurring jobs. The scheduler **must** run every minute or scheduled posts won't publish, automations won't fire, expiring tokens won't refresh, and stuck posts won't recover.
| Command | Schedule | Purpose |
|---------|----------|---------|
| `posts:process-scheduled` | every minute | Dispatches `PublishPost` jobs for posts that are due |
+| `automation:fire-schedule` | every minute | Fires time-based automation triggers |
+| `automation:process-delays` | every minute | Advances automation runs waiting on delay nodes |
+| `automation:recover-stuck-runs` | every 5 minutes | Recovers automation runs stuck mid-flight |
+| `automation:prune-dry-runs` | every 10 minutes | Prunes dry-run automation history |
+| `social:refresh-expiring-tokens` | every 15 minutes | Refreshes OAuth tokens that are about to expire |
+| `social:recover-stuck-posts` | every 30 minutes | Detects posts stuck in `publishing` and retries or fails them |
| `social:check-connections` | daily | Verifies every connected social account still has a valid token |
-| `social:refresh-expiring-tokens` | hourly | Refreshes OAuth tokens that are about to expire (proactive refresh) |
-| `social:recover-stuck-posts` | every 30 minutes | Detects posts stuck in `publishing` and either retries or marks them failed |
Add the Laravel scheduler to cron:
@@ -170,7 +216,7 @@ Add this line:
```
- This is required. Without the cron job, scheduled posts will not be published automatically, and social tokens will expire silently.
+ This is required. Without the cron job, scheduled posts will not be published automatically, automations will stall, and social tokens will expire silently.
## WebSocket Server (Reverb)
@@ -200,19 +246,7 @@ sudo supervisorctl update
sudo supervisorctl start trypost-reverb
```
-
- In production, proxy WebSocket connections through Nginx with SSL. Add this to your Nginx config:
-
- ```nginx
- location /app {
- proxy_pass http://127.0.0.1:8080;
- proxy_http_version 1.1;
- proxy_set_header Upgrade $http_upgrade;
- proxy_set_header Connection "upgrade";
- proxy_set_header Host $host;
- }
- ```
-
+Proxy `/app/` and `/apps/` through Nginx as shown above.
## File Permissions
@@ -225,18 +259,27 @@ sudo chmod -R 775 /var/www/trypost/storage
sudo chmod -R 775 /var/www/trypost/bootstrap/cache
```
-## Checklist
+Keep `storage/oauth-private.key` readable only by the app user (typically mode `600`).
-Make sure all of these are running in production:
+## Checklist
-| Service | Command | Purpose |
-|---------|---------|---------|
-| **Horizon** | `php artisan horizon` | Processes queues (post publishing, notifications) |
-| **Reverb** | `php artisan reverb:start` | Real-time WebSocket updates |
-| **Scheduler** | `php artisan schedule:run` (cron) | Dispatches scheduled posts |
+Make sure all of these are in place in production:
+
+| Item | How | Purpose |
+|------|-----|---------|
+| **App key** | `php artisan key:generate` | Encrypts sessions / cookies |
+| **Passport keys** | `php artisan passport:keys` (once) | Sign/verify API & MCP tokens |
+| **Passport client** | `php artisan db:seed` (once) | Issue personal access tokens |
+| **Admin user** | `php artisan db:seed --class=UserSeeder` (once) | First login |
+| **Storage link** | `php artisan storage:link` (once, if `FILESYSTEM_DISK=public`) | Local media URLs — skip for S3 / R2 / Spaces |
+| **Redis** | `QUEUE_CONNECTION=redis` + Redis up | Queues, cache, schedule locks |
+| **Mail** | SMTP or SendKit | Invites, password reset, alerts |
+| **Horizon** | `php artisan horizon` (Supervisor) | Publishes posts, notifications, automations |
+| **Reverb** | `php artisan reverb:start` (Supervisor) | Real-time WebSocket updates |
+| **Scheduler** | cron → `schedule:run` every minute | Due posts, token refresh, automations |
- If any of these services are not running, TryPost will not function correctly. Scheduled posts won't publish, notifications won't send, and the dashboard won't update in real time.
+ If Horizon, Reverb, or the scheduler are not running, TryPost will not function correctly. Scheduled posts won't publish, notifications won't send, and the dashboard won't update in real time.
## Monitoring
@@ -254,4 +297,5 @@ Regularly backup:
- Database
- `.env` file
+- Passport keys (`storage/oauth-*.key`) — losing them invalidates all API tokens
- Uploaded media (if using local storage)
diff --git a/self-hosting/requirements.mdx b/self-hosting/requirements.mdx
index f0178fe..06b41f0 100644
--- a/self-hosting/requirements.mdx
+++ b/self-hosting/requirements.mdx
@@ -22,17 +22,19 @@ This guide covers the requirements for self-hosting TryPost.
- **PHP 8.2+** with extensions:
- BCMath, Ctype, JSON, Mbstring, OpenSSL, PDO, Tokenizer, XML
+ - **pcntl** (required by Horizon)
+ - **intl**, **zip**, **exif**, **sockets**
- GD or Imagick (for image processing)
- - Redis extension
+ - Redis extension (or use Predis via Composer — the Redis **service** is still required)
- **Composer** 2.x
-- **Node.js** 20+ and npm (Vite 7 + Vue 3 require Node 20 or newer)
+- **Node.js** 20+ and npm (Vite 7 + Vue 3 require Node 20 or newer) — not needed if you only run the published Docker image
- **PostgreSQL** 14+ or **MySQL** 8+
- **Redis** 6+
### Optional
-- **Nginx** or **Apache** (for production)
-- **Supervisor** (for queue workers)
+- **Nginx** or **Apache** (for production bare-metal)
+- **Supervisor** (for Horizon, Reverb, and related workers)
- **SSL certificate** (Let's Encrypt recommended)
## PHP Configuration
@@ -40,12 +42,14 @@ This guide covers the requirements for self-hosting TryPost.
Recommended `php.ini` settings:
```ini
-upload_max_filesize = 2G
-post_max_size = 2G
+upload_max_filesize = 1G
+post_max_size = 1G
memory_limit = 512M
max_execution_time = 600
```
+Match these to your reverse-proxy body-size limit (`client_max_body_size` in Nginx, etc.). The published Docker image uses a 1G cap.
+
## Supported Operating Systems
TryPost can run on any OS that supports the required software:
@@ -74,16 +78,18 @@ Other supported providers:
## Storage Considerations
-Media files (images, videos) can be stored:
+Media files (images, videos, documents) can be stored:
-- **Locally** - On the server's disk
-- **S3** - Amazon S3
-- **R2** - Cloudflare R2
-- **Any S3-compatible** - MinIO, DigitalOcean Spaces, etc.
+- **Locally** — `FILESYSTEM_DISK=public` + `php artisan storage:link`
+- **S3** — Amazon S3
+- **R2** — Cloudflare R2
+- **Spaces** — DigitalOcean Spaces
+- **Any S3-compatible** — MinIO, etc.
-For production with heavy media usage, cloud storage is recommended.
+For production with heavy media usage, object storage is recommended. See [Configuration → File Storage](/self-hosting/configuration#file-storage).
## Next Steps
-- [Production Setup](/self-hosting/production)
+- [Installation](/self-hosting/installation)
- [Docker Deployment](/self-hosting/docker)
+- [Production Setup](/self-hosting/production)