Appearance
Self-Hosting and Deployment
Most shops should use the hosted service and stop reading here. This page is for an organisation that runs the platform on its own infrastructure, and for whoever operates it. It assumes you administer Linux, PostgreSQL and nginx. The repository carries every piece production runs: DEPLOY.md is the operating manual, deploy/README.md the index of artefacts, and this page the summary.
What It Needs
| Component | Requirement |
|---|---|
| OS | Ubuntu 24.04 LTS |
| PHP | 8.3 with the intl extension (the deploy script installs php8.3-intl), running under PHP-FPM |
| Database | PostgreSQL 16 |
| Cache, queue, sessions | Redis 7 |
| Web server | nginx, with certbot for Let's Encrypt |
| Node | Node 20, for puppeteer and the Chrome build the PDF renderer drives. There is no front-end build |
| PDF rendering | Chrome, installed by the deploy script into the web user's cache |
| An SMTP relay such as Amazon SES for the platform mailer | |
| File storage | The local disk, covered by the backup |
Install from Nothing
- Packages: nginx, php8.3-fpm with the extensions listed in
.github/workflows/ci.ymlplusphp8.3-redis, postgresql-16, redis-server, Node 20 and certbot with the nginx plugin. - Clone the repository to
/var/www/printersfriend. The Laravel application is inplatform/. - Copy
platform/.env.exampletoplatform/.envand fill in the environment table below, includingBACKUP_ARCHIVE_PASSWORD. - Create the database:
createuser printersfriend,createdb -O printersfriend printersfriend, andALTER ROLE printersfriend CREATEDBfor the restore drill. - Install the nginx snippet and site file from
deploy/nginx/, then issue the apex certificate withcertbot --nginx -d yourdomain -d www.yourdomain. - Run the first deploy with
PF_SKIP_STATUS_CHECK=1 sudo -E deploy/deploy.sh(no backup exists yet, so the status check would fail), with the tenant 443 block commented out. Rundeploy/bin/tenant-certs.shby hand withPF_CERT_EMAILset, then uncomment the block and reload nginx. - Install the systemd unit and the cron file as described below.
- Run
pf:doctor,backup:runandpf:backup-restore-drill.
The Deploy Script
deploy/deploy.sh is the only way code reaches the server. Run it as root:
bash
sudo /var/www/printersfriend/deploy/deploy.sh # origin/main
sudo /var/www/printersfriend/deploy/deploy.sh v2026.10.09 # a tag, branch or commit
sudo /var/www/printersfriend/deploy/deploy.sh --assets # force npm ciIn order it runs a preflight (root, clean checkout, .env present, BACKUP_ARCHIVE_PASSWORD set), git fetch and checkout, installs any missing system packages (php8.3-intl and the Ubuntu libraries Chrome links against), composer install --no-dev --optimize-autoloader, npm ci --omit=dev when package.json or the lock file changed, npx puppeteer browsers install chrome as the web user, ownership of storage/, bootstrap/cache and .env, then the short window with the site down: artisan down --retry=15, migrate --force, config:cache, route:cache, view:cache, event:cache, systemctl restart pf-queue, artisan up. It finishes by fetching /up, /status.json and one tenant login page over HTTPS with certificate verification, and running pf:doctor on the database, Redis, storage and Chrome. Any failure exits non-zero and prints the rollback command. Tag each release so a rollback is deploy.sh <previous tag>.
Ownership on the box: the code and vendor/ are root-owned and read-only for www-data; storage/ and bootstrap/cache/ are owned by www-data; .env is root:www-data mode 640. The script enforces this on every release.
Long-Lived Processes
| Process | How | Defined in |
|---|---|---|
| nginx and php8.3-fpm | systemd | deploy/nginx/ and the distro pool |
pf-queue | systemd: queue:work --queue=default,webhooks,email --sleep=3 --tries=3 --max-time=3600 --memory=256 --timeout=120, Restart=always, User=www-data | deploy/systemd/pf-queue.service |
| Scheduler | cron every minute as www-data | deploy/cron/printersfriend |
| Tenant certificates | cron every five minutes as root | deploy/bin/tenant-certs.sh |
| Certificate renewal | certbot's own timer, with the tracked reload hook | deploy/letsencrypt/renewal-hooks/deploy/ |
bash
sudo cp deploy/systemd/pf-queue.service /etc/systemd/system/
sudo systemctl daemon-reload && sudo systemctl enable --now pf-queue
sudo cp deploy/cron/printersfriend /etc/cron.d/printersfriend && sudo chmod 644 /etc/cron.d/printersfriendThe worker restarts on every deploy and exits on its own every hour, with systemd starting a fresh one. Without it, webhooks, email and PDF attachments stop. Broadcasting uses the log driver and the boards poll every fifteen seconds; a Reverb WebSocket process is an option for a dedicated server and is not part of the deploy.
Environment
platform/.env is the only place secrets live.
| Group | Keys |
|---|---|
| App | APP_ENV=production, APP_URL, APP_APEX_DOMAIN, APP_DEBUG=false. Leave APP_TIMEZONE unset (UTC); tenant time zones handle display |
| Data | DB_* for PostgreSQL 16; REDIS_*; CACHE_STORE=redis, QUEUE_CONNECTION=redis, SESSION_DRIVER=redis; FILESYSTEM_DISK=local |
MAIL_MAILER=smtp and the MAIL_* credentials for your relay; MAIL_FROM_ADDRESS and MAIL_FROM_NAME for the platform mailer | |
| Admin inbox | MAIL_ADMIN_ADDRESS: scheduler failures, backup notifications, signup and payment alerts |
| Backups | BACKUP_ARCHIVE_PASSWORD (required; the app refuses to boot in production without it), BACKUP_DISK once an off-site S3-compatible bucket exists, with its AWS_* keys, and BACKUP_NOTIFY_EMAIL |
| Monitoring | SENTRY_LARAVEL_DSN, LOG_CHANNEL=daily, LOG_DAILY_DAYS=60, STATUS_PROBE_BASE_URL |
| Billing | STRIPE_KEY, STRIPE_SECRET, STRIPE_WEBHOOK_SECRET, STRIPE_CONNECT_WEBHOOK_SECRET, the STRIPE_PRICE_<PLAN>_<CURRENCY> ids, BILLING_STRIPE_TAX, BILLING_CHECKOUT_TERMS_CONSENT |
| Platform integrations | ANTHROPIC_API_KEY for Demi, XERO_CLIENT_ID and XERO_CLIENT_SECRET, QUICKBOOKS_CLIENT_ID and QUICKBOOKS_CLIENT_SECRET. Each has an honest default until set: Demi says it is not enabled, the accounting card says not available yet |
| Legal pages | LEGAL_ENTITY_NAME, LEGAL_ENTITY_REGISTRATION_NUMBER, LEGAL_ENTITY_ADDRESS, LEGAL_GOVERNING_LAW, the LEGAL_ADDRESS_* mailboxes, LEGAL_CLOSING_DAYS |
| Storefront | SHOWCASE_TENANT_SLUG, the tenant whose catalogue backs the apex /shop; blank means the apex shop answers 404 |
| Security | MFA_ENFORCED and MFA_REQUIRED_ROLES (default owner and manager), SANCTUM_TOKEN_EXPIRATION_MINUTES (default 365 days) |
Twilio, Australia Post, supplier and tenant Stripe credentials have no env keys. Each workspace connects its own under Integrations, encrypted with APP_KEY. After an env change, run the deploy script or config:cache plus systemctl restart pf-queue.
DNS and Certificates
- An A record for the apex, one for
www(which redirects to the apex) and a wildcard*.yourdomainto the same host. Tenant resolution reads the subdomain at request time. - The apex sends HSTS with
includeSubDomains, so every tenant host must present a valid certificate. - Tenant hosts share one certificate lineage,
pf-tenants, listing every tenant host as a subject alternative name.deploy/bin/tenant-certs.shruns from cron every five minutes, asks the app for the host list withphp artisan pf:tenant-hosts, and when a host is missing runs certbot with--expandand reloads nginx. A new tenant has HTTPS within five minutes. Let's Encrypt allows 100 names per certificate; past that the script openspf-tenants-2and writes an nginx block for it. - When your DNS host offers an API, issue
*.yourdomainwith a DNS-01 challenge and point the tenant 443 block at that lineage. Nothing else changes. - The Certificates row on
/statusand a dailypf:doctor --only=certificateswatch for a lineage that stopped renewing.
Chrome for PDFs
Quotes, invoices, work orders, packing slips and purchase orders render through a real browser. The deploy script installs the Ubuntu libraries Chrome needs and runs npx puppeteer browsers install chrome as the web user with HOME=/var/www, so the browser lands in /var/www/.cache/puppeteer, where PHP-FPM and the queue worker find it. In production a failed render is an error the download reports and the email leaves out; only local and testing fall back to an HTML file. Verify after a deploy:
bash
runuser -u www-data -- env HOME=/var/www php8.3 artisan pf:doctor --only=chromeWeb Server
Two things catch people out in an nginx config, and both are handled by the tracked snippet:
- Livewire serves its JavaScript and its update endpoint through application routes. A
location ^~ /livewire/block that passes to PHP must come before any regex static-asset block, or the admin login form fails to submit. client_max_body_sizemust allow the uploads the app accepts (64M in the snippet; the asset library accepts 50 MB files), withupload_max_filesizeandpost_max_sizeinphp.inito match.
Stripe
- With
STRIPE_SECRETset, runphp artisan stripe:sync-plans --dry-run, thenphp artisan stripe:sync-plans. It creates one product per plan and one monthly price per plan per currency at the amounts inconfig/plans.php, and prints theSTRIPE_PRICE_*lines to paste into.env. Re-running reuses matching prices. Prices are immutable in Stripe, so changing an amount creates a new price and existing subscribers stay on theirs. - The trial length is set per subscription at Checkout from
config('pricing.trial_days'). - Register a webhook endpoint at
https://yourdomain/webhooks/stripesubscribed tocheckout.session.completed,customer.subscription.created,customer.subscription.updated,customer.subscription.deleted,invoice.paidandinvoice.payment_failed, and put its signing secret inSTRIPE_WEBHOOK_SECRET. Subscribe toinvoice.paidorinvoice.payment_succeeded, never both. - For Stripe Connect, register a second endpoint at
https://yourdomain/webhooks/stripe/connectthat listens on connected accounts, subscribed topayment_intent.succeeded,checkout.session.completed,charge.refunded,charge.dispute.createdandaccount.updated, with its secret inSTRIPE_CONNECT_WEBHOOK_SECRET. Until it is set the Integrations card reads "awaiting platform setup" and Connect is not offered. Shops on their own keys register their own endpoint,https://yourdomain/webhooks/stripe/tenant/{slug}, from the card. - In Stripe's customer portal configuration, enable plan switching and add every product, or subscribers cannot change plan themselves.
php artisan pf:stripe-doctorlists the live endpoints, the events each is missing, subscriptions on your prices with no workspace, and which secrets are present. The scheduler runs it daily.
Scheduler
Everything below runs from schedule:run every minute. Every command emails its output to MAIL_ADMIN_ADDRESS when it exits non-zero and holds a one-server lock for its run. php artisan schedule:list prints the live table.
| Command | When | Does |
|---|---|---|
pf:send-invoice-reminders | Hourly, acting at 09:00 in each shop's time zone | Invoice reminders on the cadence |
pf:send-quote-chase | Hourly, acting at 09:30 local | Quote follow-ups |
pf:send-artwork-chase --days=3 | Hourly, acting at 10:00 local | Artwork chases, twice per proof, then a staff task |
pf:reconcile-billing | 02:15 Sydney | Downgrades lapsed trials and expired grace periods after confirming with Stripe; warns trials ending within 3 days |
pf:stripe-doctor | 02:45 Sydney | The platform Stripe account against the database |
pf:pull-accounting-payments | Hourly | Applies payments recorded in Xero or QuickBooks |
pf:replay-webhooks | Hourly | Re-dispatches Stripe events stuck pending or failed |
pf:sync-suppliers | 04:30 Sydney | Supplier catalogue sync for shops with credentials |
pf:close-tenants | 03:00 Sydney | Notifies cancelled workspaces and closes those whose 30 day window has ended |
pf:purge-tenants | 03:30 Sydney | Deletes closed workspaces after the deletion window |
backup:clean, backup:run, backup:monitor | 02:30, 03:00 and 08:00 UTC | Backups |
pf:doctor --only=certificates | 07:30 UTC | Emails the admin inbox once a certificate has under 14 days left |
queue:prune-failed --hours=168 | Daily | Queue hygiene |
| Scheduler heartbeat | Every minute | A cache key the status page and pf:doctor read |
Backups
spatie/laravel-backup, configured in platform/config/backup.php. The archive holds a gzipped pg_dump of the database and storage/app (uploads, artwork, rendered documents), excluding .env, bootstrap/cache and logs. It is encrypted with BACKUP_ARCHIVE_PASSWORD, verified after writing, and copied to the local disk and to BACKUP_DISK when set. Retention: every archive for 7 days, then one a day for 16 days, one a week for 8 weeks, one a month for 4 months and one a year for 2 years, capped at 5 GB. backup:monitor emails when any destination lacks an archive younger than a day, and the status page shows the backup row as down when the newest archive is older than 26 hours.
Run php artisan pf:backup-restore-drill monthly. It restores the newest archive into a scratch database, prints a row count per table, reports the files in the archive and drops the scratch database. A real restore is documented step by step in DEPLOY.md.
Monitoring
/statusand/status.json: each row is a real probe (database, cache, the staff and portal sign-in pages, the API, the queue heartbeat and failure ratio, the scheduler heartbeat, backup age on every disk, certificates on the apex and one tenant host, a write on the upload disk). The JSON answers 503 when any row is not operational. Point an external monitor at it and at one tenant login page with certificate checking on.- Sentry through
SENTRY_LARAVEL_DSN; 401, 404, 422 and 429 are never reported. pf:doctorruns the same probes from a shell plus Chrome, Redis and the legal entity, and exits 1 on any failure.- Logs:
storage/logs/laravel-YYYY-MM-DD.logforLOG_DAILY_DAYS(60), andjournalctl -u pf-queuefor the worker.
Operator Commands
All run from platform/ as www-data (runuser -u www-data -- php8.3 artisan ...).
| Command | Use |
|---|---|
pf:doctor [--only=...] | Health check, exit 1 on failure |
pf:stripe-doctor | Stripe endpoints, secrets, orphaned subscriptions, emails with logins in more than one workspace |
pf:tenant-hosts | The host list the certificate job feeds to certbot |
pf:backup-restore-drill | Restore the newest archive into a scratch database and report |
stripe:sync-plans [--dry-run] | Create or reuse the Stripe products and prices |
pf:close-tenants --dry-run, pf:purge-tenants --dry-run | Preview which cancelled workspaces the daily close and purge would act on |
pf:anonymise-portal-user {workspace} {email} | Erasure for one portal user |
pf:rotate-demo-passwords | New random passwords on the demo tenant's users |
pf:sync-suppliers, pf:replay-webhooks, pf:test-webhook, pf:import-skus, pf:mail-preview | As named |
config:cache and env()
config:cache is part of every release. It is safe only while every runtime value is read through the config layer. If you fork the application and call env() outside a config file, those calls return null in production and the failures are confusing. Read your own code before you add one.
Related Pages
- Screen-by-Screen Map: every screen with its path, its role and its guide
- Connected Accounts: the connected accounts: Stripe, email, SMS, accounting, shipping and suppliers
- What You Need Before You Start: what to have ready before you set up
- Contact Support: how to reach support and what to include