Appearance
Self-hosting and deployment
Most shops should use the hosted service and stop reading here. This page is for organisations that need the platform on their own infrastructure, and for whoever operates it.
It is a technical page. It assumes you administer Linux, Postgres and a web server.
What it needs
| Component | Requirement |
|---|---|
| OS | Ubuntu 22.04 LTS, or any host running PHP 8.3+ |
| Database | PostgreSQL (16 or newer) |
| Cache, queue, locks | Redis 7 |
| Object storage | S3, R2 or Spaces for artwork uploads |
| Queue worker | A long-lived worker process, Horizon recommended |
| WebSocket | A Reverb process for real-time board updates |
| Image processing | Imagick, for the mockup renderer |
Install
bash
git clone <your-repo> printersfriend
cd printersfriend/platform
composer install --no-dev --optimize-autoloader
npm ci && npm run buildThen:
bash
php artisan key:generate
php artisan storage:link
php artisan migrate --force
php artisan config:cache
php artisan route:cache
php artisan view:cacheEnvironment
Copy .env.example to .env and set, at minimum:
| Group | Keys |
|---|---|
| App | APP_URL, APP_APEX_DOMAIN |
| Storefront | SHOWCASE_TENANT_SLUG. Which tenant's catalogue backs the apex /shop. Leave empty and the apex shop 404s by design; tenant-subdomain shops are unaffected |
| Data | Database, Redis and mail connection strings |
| Billing | STRIPE_SECRET, STRIPE_WEBHOOK_SECRET, and per-plan per-currency price IDs |
| AI | ANTHROPIC_API_KEY, for Demi |
| SMS | TWILIO_SID, TWILIO_TOKEN, TWILIO_FROM |
POSTMARK_TOKEN or your Resend key | |
| Shipping | AUSPOST_API_KEY, AUSPOST_ACCOUNT |
| Suppliers | AS_COLOUR_TOKEN, SANMAR_USERNAME and SANMAR_PASSWORD, RALAWISE_API_KEY |
| Realtime | REVERB_APP_ID, REVERB_APP_KEY, REVERB_APP_SECRET, host and port, plus the VITE_REVERB_* mirrors for the frontend bundle |
| Monitoring | SENTRY_DSN |
Note that per-tenant integration credentials entered in the workspace UI take precedence over these env values. The env keys are the platform-level defaults.
DNS and TLS
- Apex A record to your host.
- Wildcard
*.yourdomainA record to the same host, so each tenant gets{slug}.yourdomain. Tenant resolution reads the subdomain at request time. - Wildcard TLS is required for the tenant subdomain scheme. Let's Encrypt needs a DNS-01 challenge for a wildcard.
Web server
Two things catch people out in an nginx config:
- Livewire serves its JS and its update endpoint through application routes, not real files. A
location ^~ /livewire/block that passes to PHP must come before any regex static-asset block, or the admin login form silently fails to submit. - Allow a reasonable
client_max_body_size, 25M is a sensible starting point, or artwork uploads fail with an opaque error.
Stripe plan setup
bash
php artisan stripe:sync-plans --dry-run
php artisan stripe:sync-plansThis creates one product per plan and one monthly price per plan per currency at the amounts in the plan config, then prints the env lines to paste back. Re-running is safe: it reuses matching prices and creates only what is missing.
Prices are immutable in Stripe, so changing an amount creates a new price and existing subscribers stay on the one they signed up on. Trial length is set per subscription at checkout, not on the price, so changing it needs no new prices.
Add a webhook endpoint at /webhooks/stripe subscribing to checkout.session.completed, customer.subscription.created, customer.subscription.updated, customer.subscription.deleted, invoice.paid and invoice.payment_failed, and put its signing secret in STRIPE_WEBHOOK_SECRET.
In Stripe's customer portal configuration, enable plan switching and add every product, or subscribers cannot self-serve a change.
Long-lived processes
bash
php artisan queue:work --queue=default,webhooks,email --sleep=3 --tries=3
php artisan schedule:work
php artisan reverb:start --host=0.0.0.0 --port=8080Run them under supervisord or systemd. Without the queue worker, webhooks and email stop. Without Reverb, boards stop updating live but everything else works.
Cron
text
* * * * * cd /path/to/platform && php artisan schedule:run >> /dev/null 2>&1The scheduler runs:
| Command | When | Does |
|---|---|---|
pf:send-invoice-reminders | Daily | Invoice reminders |
pf:send-artwork-chase --days=3 | Daily | Chases proofs sitting unapproved |
pf:reconcile-billing | Daily | Downgrades grace-expired and stale trials, warns trials ending within 3 days |
pf:pull-accounting-payments | Hourly | Applies payments recorded in Xero or QuickBooks |
pf:purge-tenants | Weekly | Hard-deletes data for tenants soft-deleted over 90 days ago |
pf:sync-suppliers | Daily | Supplier catalogue sync |
queue:prune-failed and horizon:snapshot | Daily and 5-minutely | Housekeeping |
Storage
php artisan storage:linkfor the public disk.- For production object storage, set
FILESYSTEM_DISK=s3and the AWS variables. - Garment mockup templates live under
storage/app/mockups/templates/, one PNG per style and view, picked up automatically by the renderer.
Operator commands
| Command | Use |
|---|---|
pf:anonymise-portal-user {tenant-slug} {email} | GDPR or APP erasure for one portal user. See privacy |
pf:sync-suppliers {supplier?} | Force a catalogue sync |
pf:test-webhook | Fire a test delivery at a subscriber |
pf:replay-webhooks | Re-deliver failed webhooks |
pf:import-skus | Bulk SKU import |
pf:mail-preview | Render an email template without sending |
A caching gotcha worth knowing
config:cache is safe here only if every runtime value is read through the config layer rather than through env() at request time. If you fork the application and add env() calls outside config files, caching config will make them return null in production, and the failures will be confusing. Read your own code before enabling it.
Backups
At minimum: nightly Postgres dumps with a tested restore, and object storage versioning for artwork. Test the restore. A backup you have never restored is a hypothesis.