Skip to content

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 ​

ComponentRequirement
OSUbuntu 24.04 LTS
PHP8.3 with the intl extension (the deploy script installs php8.3-intl), running under PHP-FPM
DatabasePostgreSQL 16
Cache, queue, sessionsRedis 7
Web servernginx, with certbot for Let's Encrypt
NodeNode 20, for puppeteer and the Chrome build the PDF renderer drives. There is no front-end build
PDF renderingChrome, installed by the deploy script into the web user's cache
EmailAn SMTP relay such as Amazon SES for the platform mailer
File storageThe local disk, covered by the backup

Install from Nothing ​

  1. Packages: nginx, php8.3-fpm with the extensions listed in .github/workflows/ci.yml plus php8.3-redis, postgresql-16, redis-server, Node 20 and certbot with the nginx plugin.
  2. Clone the repository to /var/www/printersfriend. The Laravel application is in platform/.
  3. Copy platform/.env.example to platform/.env and fill in the environment table below, including BACKUP_ARCHIVE_PASSWORD.
  4. Create the database: createuser printersfriend, createdb -O printersfriend printersfriend, and ALTER ROLE printersfriend CREATEDB for the restore drill.
  5. Install the nginx snippet and site file from deploy/nginx/, then issue the apex certificate with certbot --nginx -d yourdomain -d www.yourdomain.
  6. 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. Run deploy/bin/tenant-certs.sh by hand with PF_CERT_EMAIL set, then uncomment the block and reload nginx.
  7. Install the systemd unit and the cron file as described below.
  8. Run pf:doctor, backup:run and pf: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 ci

In 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 ​

ProcessHowDefined in
nginx and php8.3-fpmsystemddeploy/nginx/ and the distro pool
pf-queuesystemd: queue:work --queue=default,webhooks,email --sleep=3 --tries=3 --max-time=3600 --memory=256 --timeout=120, Restart=always, User=www-datadeploy/systemd/pf-queue.service
Schedulercron every minute as www-datadeploy/cron/printersfriend
Tenant certificatescron every five minutes as rootdeploy/bin/tenant-certs.sh
Certificate renewalcertbot's own timer, with the tracked reload hookdeploy/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/printersfriend

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

GroupKeys
AppAPP_ENV=production, APP_URL, APP_APEX_DOMAIN, APP_DEBUG=false. Leave APP_TIMEZONE unset (UTC); tenant time zones handle display
DataDB_* for PostgreSQL 16; REDIS_*; CACHE_STORE=redis, QUEUE_CONNECTION=redis, SESSION_DRIVER=redis; FILESYSTEM_DISK=local
EmailMAIL_MAILER=smtp and the MAIL_* credentials for your relay; MAIL_FROM_ADDRESS and MAIL_FROM_NAME for the platform mailer
Admin inboxMAIL_ADMIN_ADDRESS: scheduler failures, backup notifications, signup and payment alerts
BackupsBACKUP_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
MonitoringSENTRY_LARAVEL_DSN, LOG_CHANNEL=daily, LOG_DAILY_DAYS=60, STATUS_PROBE_BASE_URL
BillingSTRIPE_KEY, STRIPE_SECRET, STRIPE_WEBHOOK_SECRET, STRIPE_CONNECT_WEBHOOK_SECRET, the STRIPE_PRICE_<PLAN>_<CURRENCY> ids, BILLING_STRIPE_TAX, BILLING_CHECKOUT_TERMS_CONSENT
Platform integrationsANTHROPIC_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 pagesLEGAL_ENTITY_NAME, LEGAL_ENTITY_REGISTRATION_NUMBER, LEGAL_ENTITY_ADDRESS, LEGAL_GOVERNING_LAW, the LEGAL_ADDRESS_* mailboxes, LEGAL_CLOSING_DAYS
StorefrontSHOWCASE_TENANT_SLUG, the tenant whose catalogue backs the apex /shop; blank means the apex shop answers 404
SecurityMFA_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 *.yourdomain to 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.sh runs from cron every five minutes, asks the app for the host list with php artisan pf:tenant-hosts, and when a host is missing runs certbot with --expand and reloads nginx. A new tenant has HTTPS within five minutes. Let's Encrypt allows 100 names per certificate; past that the script opens pf-tenants-2 and writes an nginx block for it.
  • When your DNS host offers an API, issue *.yourdomain with a DNS-01 challenge and point the tenant 443 block at that lineage. Nothing else changes.
  • The Certificates row on /status and a daily pf:doctor --only=certificates watch 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=chrome

Web Server ​

Two things catch people out in an nginx config, and both are handled by the tracked snippet:

  1. 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.
  2. client_max_body_size must allow the uploads the app accepts (64M in the snippet; the asset library accepts 50 MB files), with upload_max_filesize and post_max_size in php.ini to match.

Stripe ​

  1. With STRIPE_SECRET set, run php artisan stripe:sync-plans --dry-run, then php artisan stripe:sync-plans. It creates one product per plan and one monthly price per plan per currency at the amounts in config/plans.php, and prints the STRIPE_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.
  2. The trial length is set per subscription at Checkout from config('pricing.trial_days').
  3. Register a webhook endpoint at https://yourdomain/webhooks/stripe subscribed 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. Subscribe to invoice.paid or invoice.payment_succeeded, never both.
  4. For Stripe Connect, register a second endpoint at https://yourdomain/webhooks/stripe/connect that listens on connected accounts, subscribed to payment_intent.succeeded, checkout.session.completed, charge.refunded, charge.dispute.created and account.updated, with its secret in STRIPE_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.
  5. In Stripe's customer portal configuration, enable plan switching and add every product, or subscribers cannot change plan themselves.
  6. php artisan pf:stripe-doctor lists 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.

CommandWhenDoes
pf:send-invoice-remindersHourly, acting at 09:00 in each shop's time zoneInvoice reminders on the cadence
pf:send-quote-chaseHourly, acting at 09:30 localQuote follow-ups
pf:send-artwork-chase --days=3Hourly, acting at 10:00 localArtwork chases, twice per proof, then a staff task
pf:reconcile-billing02:15 SydneyDowngrades lapsed trials and expired grace periods after confirming with Stripe; warns trials ending within 3 days
pf:stripe-doctor02:45 SydneyThe platform Stripe account against the database
pf:pull-accounting-paymentsHourlyApplies payments recorded in Xero or QuickBooks
pf:replay-webhooksHourlyRe-dispatches Stripe events stuck pending or failed
pf:sync-suppliers04:30 SydneySupplier catalogue sync for shops with credentials
pf:close-tenants03:00 SydneyNotifies cancelled workspaces and closes those whose 30 day window has ended
pf:purge-tenants03:30 SydneyDeletes closed workspaces after the deletion window
backup:clean, backup:run, backup:monitor02:30, 03:00 and 08:00 UTCBackups
pf:doctor --only=certificates07:30 UTCEmails the admin inbox once a certificate has under 14 days left
queue:prune-failed --hours=168DailyQueue hygiene
Scheduler heartbeatEvery minuteA 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 ​

  • /status and /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:doctor runs 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.log for LOG_DAILY_DAYS (60), and journalctl -u pf-queue for the worker.

Operator Commands ​

All run from platform/ as www-data (runuser -u www-data -- php8.3 artisan ...).

CommandUse
pf:doctor [--only=...]Health check, exit 1 on failure
pf:stripe-doctorStripe endpoints, secrets, orphaned subscriptions, emails with logins in more than one workspace
pf:tenant-hostsThe host list the certificate job feeds to certbot
pf:backup-restore-drillRestore 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-runPreview 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-passwordsNew random passwords on the demo tenant's users
pf:sync-suppliers, pf:replay-webhooks, pf:test-webhook, pf:import-skus, pf:mail-previewAs 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.

Printer's Friend - software for apparel print shops