> ## Documentation Index
> Fetch the complete documentation index at: https://docs.trypost.it/llms.txt
> Use this file to discover all available pages before exploring further.

# Production Setup

> Deploy TryPost to a production environment

# Production Setup

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).

Prefer containers? Use [`compose.prod.yaml`](/self-hosting/docker#production-recommended-for-self-hosting) — it already runs Horizon, Reverb, and the scheduler for you.

## Before you continue

Confirm the install checklist is done:

* [ ] `APP_KEY` set
* [ ] Database migrated
* [ ] `QUEUE_CONNECTION=redis` and Redis reachable
* [ ] Passport keys present (`PASSPORT_*` env for Docker prod, or `php artisan passport:keys` on bare metal)
* [ ] `php artisan db:seed` + `UserSeeder` ran
* [ ] `storage:link` ran **if** `FILESYSTEM_DISK=public`
* [ ] `npm run build` completed

## Environment (production values)

```env theme={null}
APP_ENV=production
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 theme={null}
composer install --optimize-autoloader --no-dev

php artisan config:cache
php artisan route:cache
php artisan view:cache
php artisan event:cache
```

## Queue Worker

TryPost uses queues for critical background processing:

* **Publishing posts** to social platforms at scheduled times
* **Sending notifications** (email and in-app)
* **Verifying social account connections**
* **Processing analytics events**
* **Delivering [webhooks](/knowledge-base/webhooks)** and their retries

Without a running queue worker, scheduled posts will not be published. Use Supervisor to keep Horizon running.

### Install Supervisor

```bash theme={null}
# Ubuntu/Debian
sudo apt install supervisor

# CentOS/RHEL
sudo yum install supervisor
```

### Configure Supervisor

Create `/etc/supervisor/conf.d/trypost-worker.conf`:

```ini theme={null}
[program:trypost-horizon]
process_name=%(program_name)s
command=php /var/www/trypost/artisan horizon
autostart=true
autorestart=true
stopasgroup=true
killasgroup=true
user=www-data
redirect_stderr=true
stdout_logfile=/var/www/trypost/storage/logs/horizon.log
stopwaitsecs=3600
```

Then start it:

```bash theme={null}
sudo supervisorctl reread
sudo supervisorctl update
sudo supervisorctl start trypost-horizon
```

## Nginx Configuration

Example Nginx configuration:

```nginx theme={null}
server {
    listen 80;
    listen [::]:80;
    server_name your-domain.com;
    return 301 https://$server_name$request_uri;
}

server {
    listen 443 ssl http2;
    listen [::]:443 ssl http2;
    server_name your-domain.com;

    root /var/www/trypost/public;
    index index.php;

    ssl_certificate /etc/letsencrypt/live/your-domain.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/your-domain.com/privkey.pem;

    add_header X-Frame-Options "SAMEORIGIN";
    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;
    }

    location = /favicon.ico { access_log off; log_not_found off; }
    location = /robots.txt  { access_log off; log_not_found off; }

    error_page 404 /index.php;

    location ~ \.php$ {
        # Replace with the FPM socket for the PHP version you installed (e.g. php8.2-fpm.sock, php8.3-fpm.sock, php8.4-fpm.sock).
        fastcgi_pass unix:/var/run/php/php-fpm.sock;
        fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
        include fastcgi_params;
    }

    location ~ /\.(?!well-known).* {
        deny all;
    }
}
```

<Note>
  Both `/app/` (browser WebSocket) and `/apps/` (Pusher HTTP API) must be proxied to Reverb. Omitting `/apps/` causes flaky real-time updates.
</Note>

## SSL Certificate

Use Let's Encrypt for free SSL:

```bash theme={null}
sudo apt install certbot python3-certbot-nginx
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.

| Command | Schedule | Purpose |
| - | - | - |
| `posts:process-scheduled` | every minute | Dispatches `PublishPost` jobs for posts that are due |
| `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 |
| `app:prune-webhook-logs` | daily | Deletes [webhook](/knowledge-base/webhooks) delivery logs older than 7 days |
| `repurpose:poll` | every 5 minutes | Queues a check for each [repurpose](/knowledge-base/repurposes) source that is due |

Add the Laravel scheduler to cron:

```bash theme={null}
crontab -e
```

Add this line:

```
* * * * * cd /var/www/trypost && php artisan schedule:run >> /dev/null 2>&1
```

<Warning>
  This is required. Without the cron job, scheduled posts will not be published automatically and social tokens will expire silently.
</Warning>

## WebSocket Server (Reverb)

TryPost uses [Laravel Reverb](https://laravel.com/docs/reverb) for real-time updates in the dashboard (live post status changes, notifications). Add a Supervisor config to keep it running:

Create `/etc/supervisor/conf.d/trypost-reverb.conf`:

```ini theme={null}
[program:trypost-reverb]
process_name=%(program_name)s
command=php /var/www/trypost/artisan reverb:start --host=0.0.0.0 --port=8080
autostart=true
autorestart=true
stopasgroup=true
killasgroup=true
user=www-data
redirect_stderr=true
stdout_logfile=/var/www/trypost/storage/logs/reverb.log
```

Then reload Supervisor:

```bash theme={null}
sudo supervisorctl reread
sudo supervisorctl update
sudo supervisorctl start trypost-reverb
```

Proxy `/app/` and `/apps/` through Nginx as shown above.

## File Permissions

Set correct permissions:

```bash theme={null}
sudo chown -R www-data:www-data /var/www/trypost
sudo chmod -R 755 /var/www/trypost
sudo chmod -R 775 /var/www/trypost/storage
sudo chmod -R 775 /var/www/trypost/bootstrap/cache
```

Keep `storage/oauth-private.key` readable only by the app user (typically mode `600`).

## Checklist

Make sure all of these are in place in production:

| Item | How | Purpose |
| - | - | - |
| **App key** | `php artisan key:generate` | Encrypts sessions / cookies |
| **Passport keys** | `PASSPORT_PRIVATE_KEY` / `PASSPORT_PUBLIC_KEY` (or `php artisan passport:keys` once on bare metal) | Sign/verify API & MCP tokens |
| **Passport client** | `php artisan db:seed` (once; Docker prod also runs `PassportSeeder` on boot) | 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, and [webhooks](/knowledge-base/webhooks) |
| **Reverb** | `php artisan reverb:start` (Supervisor) | Real-time WebSocket updates |
| **Scheduler** | cron → `schedule:run` every minute | Due posts, token refresh, stuck-post recovery |

<Warning>
  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.
</Warning>

## Monitoring

Consider setting up monitoring for:

* Server health (CPU, memory, disk)
* Application errors (Laravel logs in `storage/logs/`)
* Queue status (Horizon dashboard at `/horizon`)
* WebSocket connections (Reverb logs in `storage/logs/reverb.log`)

## Backups

Regularly backup:

* Database
* `.env` file
* Passport keys (`storage/oauth-*.key` or `PASSPORT_*` env) — losing them invalidates API keys and MCP OAuth grants
* Uploaded media (if using local storage)

[Upgrading](/self-hosting/upgrading) asks for a database dump before every `migrate --force`, so it is worth having the command to hand:

Both read the credentials from the app's own environment, so they work as written wherever `.env` is loaded — no need to remember whether this install calls the user `postgres`, `root` or `trypost`.

**PostgreSQL**

```bash theme={null}
PGPASSWORD="$DB_PASSWORD" pg_dump -h "$DB_HOST" -U "$DB_USERNAME" -d "$DB_DATABASE" \
  -Fc -f "trypost-$(date +%F).dump"

# restore
PGPASSWORD="$DB_PASSWORD" pg_restore -h "$DB_HOST" -U "$DB_USERNAME" -d "$DB_DATABASE" \
  --clean "trypost-2026-01-01.dump"
```

**MySQL**

```bash theme={null}
mysqldump -h "$DB_HOST" -u "$DB_USERNAME" -p"$DB_PASSWORD" \
  --single-transaction "$DB_DATABASE" > "trypost-$(date +%F).sql"

# restore
mysql -h "$DB_HOST" -u "$DB_USERNAME" -p"$DB_PASSWORD" "$DB_DATABASE" < "trypost-2026-01-01.sql"
```

On the bundled Compose stack the app container carries both clients and already has those variables, so `docker compose -f compose.prod.yaml exec app` runs either one as written — as does `php artisan db` for an interactive session.

<Note>
  `mysql-client` was added to the image alongside `pdo_mysql`; images up to and including `v1.0.8` carry only the PostgreSQL client. On those, run the MySQL commands from the database host instead.
</Note>


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.