Skip to content

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

ComponentRequirement
OSUbuntu 22.04 LTS, or any host running PHP 8.3+
DatabasePostgreSQL (16 or newer)
Cache, queue, locksRedis 7
Object storageS3, R2 or Spaces for artwork uploads
Queue workerA long-lived worker process, Horizon recommended
WebSocketA Reverb process for real-time board updates
Image processingImagick, for the mockup renderer

Install

bash
git clone <your-repo> printersfriend
cd printersfriend/platform
composer install --no-dev --optimize-autoloader
npm ci && npm run build

Then:

bash
php artisan key:generate
php artisan storage:link
php artisan migrate --force
php artisan config:cache
php artisan route:cache
php artisan view:cache

Environment

Copy .env.example to .env and set, at minimum:

GroupKeys
AppAPP_URL, APP_APEX_DOMAIN
StorefrontSHOWCASE_TENANT_SLUG. Which tenant's catalogue backs the apex /shop. Leave empty and the apex shop 404s by design; tenant-subdomain shops are unaffected
DataDatabase, Redis and mail connection strings
BillingSTRIPE_SECRET, STRIPE_WEBHOOK_SECRET, and per-plan per-currency price IDs
AIANTHROPIC_API_KEY, for Demi
SMSTWILIO_SID, TWILIO_TOKEN, TWILIO_FROM
EmailPOSTMARK_TOKEN or your Resend key
ShippingAUSPOST_API_KEY, AUSPOST_ACCOUNT
SuppliersAS_COLOUR_TOKEN, SANMAR_USERNAME and SANMAR_PASSWORD, RALAWISE_API_KEY
RealtimeREVERB_APP_ID, REVERB_APP_KEY, REVERB_APP_SECRET, host and port, plus the VITE_REVERB_* mirrors for the frontend bundle
MonitoringSENTRY_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 *.yourdomain A 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:

  1. 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.
  2. 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-plans

This 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=8080

Run 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>&1

The scheduler runs:

CommandWhenDoes
pf:send-invoice-remindersDailyInvoice reminders
pf:send-artwork-chase --days=3DailyChases proofs sitting unapproved
pf:reconcile-billingDailyDowngrades grace-expired and stale trials, warns trials ending within 3 days
pf:pull-accounting-paymentsHourlyApplies payments recorded in Xero or QuickBooks
pf:purge-tenantsWeeklyHard-deletes data for tenants soft-deleted over 90 days ago
pf:sync-suppliersDailySupplier catalogue sync
queue:prune-failed and horizon:snapshotDaily and 5-minutelyHousekeeping

Storage

  • php artisan storage:link for the public disk.
  • For production object storage, set FILESYSTEM_DISK=s3 and 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

CommandUse
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-webhookFire a test delivery at a subscriber
pf:replay-webhooksRe-deliver failed webhooks
pf:import-skusBulk SKU import
pf:mail-previewRender 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.

Printer's Friend - software for apparel print shops