Invoicing is the last thing a small operation wants to pay a per-seat subscription for, and it’s the first thing every vendor charges one for. FreshBooks starts at $23/month for Lite and caps you at five billable clients; Plus is $43 and caps at fifty; Premium is $70 to remove the cap. Every one of those tiers is single-user, and each additional team member is $11/month on top. QuickBooks Online runs $38/month for Simple Start, and after the August 2026 US increase, $85 for Essentials and $140 for Plus. Wave is the honest free option, but the free tier takes 2.9% + $0.60 per card transaction, and that extra 30 cents over the standard processing rate is exactly what you’re paying for “free.” Even Invoice Ninja’s own hosted plans run $14/month for Pro and start at $18/month for Enterprise once you pass five clients.
Self-hosted Invoice Ninja v5 is the way out, and it’s more generous than most self-host stories: the Pro and Enterprise features are in the self-hosted code at no cost. The only thing you pay for is the white-label license at $40/year, which removes the “Powered by Invoice Ninja” branding from client-facing pages. Be precise about what it is, though. Invoice Ninja is licensed under the Elastic License 2.0, which is source-available, not OSI open source: self-host it and modify it for your own business freely, but you cannot turn around and sell it as a managed service. And it is invoicing software (clients, products, recurring invoices, payments, expenses, time tracking, projects, multi-user teams), not a double-entry bookkeeping ledger. None of this is accounting or tax advice.
The path is a domain, a $7/month box, the Cloudflare free tier, a Stripe account, and an evening. What makes it different from standing up a signing service or a wiki is the shape of the traffic. Invoice Ninja has a public-facing client portal and webhook ingress. Customers who are not in your identity provider have to reach it, and so does Stripe, which cannot carry an authentication cookie no matter how you ask. “Put the whole thing behind Cloudflare Access” is the reflex answer, and it quietly breaks the product: the invoice sends, the customer clicks, and they land on a login page for an account they don’t have. The access model is the interesting part of this build, and it gets its own step.
One thing stated up front, because a guide that hides it isn’t worth much. Steps 1 through 6 below were validated end to end on a live deploy on 2026-05-09, down to a real $1 test charge marking a real invoice paid. Step 7’s narrow access policy was designed from a real route-discovery pass against that running stack, but it was not applied to it, and Step 8 is the honest list of what’s left before this box would be ready for a paying customer. Both are flagged where they land.
Pick the Stack and Confirm It Fits
The threshold question isn’t whether self-hosted invoicing works. It does, and the feature gap against hosted Invoice Ninja is essentially zero. The question is whether invoicing is where your requirement actually stops.
Invoice Ninja handles the billing lifecycle: clients, products, quotes, invoices, recurring invoices, payments, expenses, time tracking, projects, and multi-user teams with role permissions. What it does not do is act as your general ledger. There’s no chart of accounts in the sense an accountant means it, no double-entry, no trial balance, no bank reconciliation workflow to hand your CPA at year end. If that’s the requirement, the honest answer is a different tool: Akaunting and Manager both self-host and both actually do bookkeeping. Crater is the closest sibling to Invoice Ninja if you want something lighter. Plenty of small operations run Invoice Ninja for billing and hand a CSV export to a bookkeeper, which is a perfectly reasonable division of labor. Just decide that consciously instead of discovering it in April.
Two other things to settle now because they size later steps. First, how many humans touch this. Invoice Ninja’s self-hosted build includes advanced permissions, so multi-user with real role separation is available to you, but each user is a login you have to manage and an entry in whatever access policy you land on in Step 7. Second, records retention. Invoices are tax records, and in the US that generally means keeping them three to seven years depending on the situation. That number is the input to backup retention in Step 8, not an afterthought, so write it down now.
Choose Your Hostname and Access Model
This is the decision that shapes everything downstream, and it’s worth ten minutes of thought before you spend a dollar.
The recommended v1, and what this guide builds: one public hostname, billing.yourdomain.com, with a Cloudflare Tunnel in front of it. The application’s own authentication plus 2FA protects the admin side. Cloudflare Access sits in front of the whole hostname as a blanket “require login” policy while you build, and that blanket stays in place permanently as the catch-all. What Step 7 does is carve specific public paths out in front of it, based on traffic you actually observe. Simple, one DNS name, one tunnel route, one set of URLs.
The locked-down v2, which is more appealing than it is practical: split the surfaces across two hostnames, with admin.yourdomain.com reachable only over Tailscale or hard-gated by Access, and pay.yourdomain.com public for the client portal. Architecturally this is the right instinct. Operationally, Invoice Ninja’s APP_URL, cookie domain, CSRF origin, generated portal links, mobile app configuration, and webhook targets all have to agree with each other across that split, and the app assumes they’re the same host. Don’t make this the default path until it has survived a real test invoice, a Stripe test payment, a PDF view, a client-portal login, a mobile app login, and a webhook callback without breaking. Ship v1 first.
Whichever you pick, pick it before you send the first real invoice. Invoice-email links and generated PDFs bake in APP_URL at send time. Changing the hostname later doesn’t rewrite invoices already in a customer’s inbox; it just breaks them.
Important: Do not install into a subdirectory. Invoice Ninja’s self-host documentation is explicit that subdirectory installs (yourdomain.com/billing, example.com/ninja) are not supported; the application expects a domain or subdomain document root. Use billing.yourdomain.com, not yourdomain.com/billing. This is not a “probably works, might be weird” situation. Asset paths, portal links, and the webhook URL all derive from a root-anchored APP_URL.
Harden the VPS Before Anything Else
A fresh VPS with port 22 open to the internet starts collecting brute-force attempts within minutes of first boot, and there is no reason for this box to have a public attack surface at all. The full treatment is its own guide on this site (VPS Security Foundations), and the short version is: put the box on Tailscale, move SSH behind it, default-deny inbound on UFW, keys-only authentication with root login disabled, fail2ban as a second layer, and unattended security upgrades on. Do that before Docker, before the domain, before anything.
Nothing about Invoice Ninja changes that posture. The scheduler and the queue workers are internal to the container and never need an inbound port. The only thing that ever reaches this box from the public internet is a tunnel the box itself dials out to establish.
Sizing
This build ran on a Hetzner CPX11 (2 vCPU, 2 GB RAM, 40 GB NVMe) in a US East region on Debian 13 (trixie), with 2 GB of swap configured, at roughly $7/month. With the full stack up and serving the login page, memory sat at 1350 MiB used out of 1926 MiB total: about 580 MiB of headroom plus the swap. That’s a real measurement from a real box under light single-user load, not an official requirement. Invoice Ninja’s own docs list the PHP and MySQL components but never commit to a RAM floor.
The reason to treat 2 GB as “measured, not proven” is the bundled Chrome. The Debian image ships Chromium for PDF generation via SnapPDF, and a Laravel app on a small VPS is unremarkable right up until it’s driving a headless browser. A PDF render is what makes 2 GB tight, which is why Step 8 lists render-under-load measurement as an open item rather than pretending the idle numbers settle it.
Size up to a CPX21 (4 GB, around $10/month) if you’re running other Docker workloads on the same box, expect heavy multi-user load, handle large attachments, or see sustained pressure once you actually measure PDF render. DigitalOcean, Vultr, Linode, and OVH all have equivalent tiers within a few dollars; the DocuSeal guide works through those alternatives and the home/homelab/NAS path in more detail, and all of it applies here unchanged.
Domain and Cloudflare Account
Buy the domain wherever you like, then get it onto Cloudflare DNS, either by registering through Cloudflare Registrar directly or by pointing nameservers there after registration. Most TLDs land in the $10 to $15 per year range. A .app domain is a clean choice for this specific use because the entire TLD is on the HSTS preload list, so browsers refuse plaintext to it at the protocol level before your configuration gets a vote.
Add the hostname you picked in Step 2 to the zone. Do not create a DNS record for it by hand: the tunnel creates it in Step 5, at the exact moment you want it created, and a stale manual A record pointing at the VPS is precisely the thing this architecture exists to avoid.
Turn on 2FA for the Cloudflare account itself, and enable Zero Trust on it before you start Step 5. The free plan covers teams under fifty users, which covers both the tunnel and every Access application in this guide. You need Access available before the app is reachable, not after, and discovering that the Zero Trust onboarding wants a few minutes of your attention is better done now than with an ungated invoicing app sitting on the public internet.
Deploy the Docker Stack Behind a Temporary Access Policy
Order matters more here than in most deploys, and the reason is a gap most guides leave open. If you publish the tunnel route first and add the Access policy second, then for the interval between those two clicks (which might be thirty seconds or might be however long it takes you to find the right dashboard page) a freshly installed invoicing app with known default credentials is openly reachable on the internet. So: bring the stack up bound internally, create the Access application with a blanket “require login on the whole hostname” policy, and only then publish the route. Step 7 later carves the public paths out in front of that blanket without removing it.
Sequenced this way, the hostname does not resolve at all until an Access policy already exists for it, which closes the window a reader would otherwise open by following the dashboard wizard straight through. It is worth being precise about what that does and doesn’t guarantee: policy creation and route publication are two separate control-plane changes, and nothing enforces a barrier between them. The ordering removes the obvious exposure; the clean-browser check in the first-boot sequence below is what actually confirms it, and it is not a formality.
Pin the image. Use the official Debian image, invoiceninja/invoiceninja-debian, at a specific tag. This build validated on 5.13.22; as of publication the current tag is 5.13.33, so check the Docker Hub tag list and the dockerfiles repo and pin whatever is current when you deploy. Do not float latest in the compose file. The Debian image is the convenient one because it bundles nginx-compatible PHP-FPM, the supervisor stack, the scheduler, the queue workers, and Chrome for PDF generation.
Do not publish nginx to the host. The official example compose maps 80:80, and that single line is the difference between this architecture and a normal one. Put cloudflared on the same Docker network as nginx and route to it by service name instead. The property that buys you is structural rather than configurational: with no ports: key anywhere in the compose file, there is nothing to accidentally widen from 127.0.0.1 to 0.0.0.0 in six months. The host listens on nothing.
The stack is five services:
services:
app:
image: invoiceninja/invoiceninja-debian:${TAG}
restart: unless-stopped
env_file:
- ./.env
volumes:
- app_public:/var/www/html/public
- app_storage:/var/www/html/storage
depends_on:
mysql:
condition: service_healthy
redis:
condition: service_healthy
nginx:
image: nginx:alpine
restart: unless-stopped
volumes:
- ./nginx:/etc/nginx/conf.d:ro
- app_public:/var/www/html/public:ro
- app_storage:/var/www/html/storage:ro
depends_on:
- app
mysql:
image: mysql:8.0
restart: unless-stopped
environment:
MYSQL_DATABASE: ${DB_DATABASE}
MYSQL_USER: ${DB_USERNAME}
MYSQL_PASSWORD: ${DB_PASSWORD}
MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}
volumes:
- mysql_data:/var/lib/mysql
healthcheck:
test: ["CMD-SHELL", "mysqladmin ping -h 127.0.0.1 --silent"]
interval: 10s
timeout: 5s
retries: 10
redis:
image: redis:7-alpine
restart: unless-stopped
volumes:
- redis_data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 10
cloudflared:
image: cloudflare/cloudflared:latest
restart: unless-stopped
command: tunnel --no-autoupdate run
env_file:
- ./cloudflared.env
depends_on:
- nginx
volumes:
app_public:
app_storage:
mysql_data:
redis_data:
Note that cloudflared reads a separate cloudflared.env holding only TUNNEL_TOKEN. That’s deliberate: the tunnel token is a different kind of secret with a different rotation story than your database password and APP_KEY, and keeping it out of the app’s env file means you can hand it around or rotate it without touching anything else. Both files get chmod 600.
The ./nginx bind mount is not optional and it is not empty. That mount replaces nginx’s bundled conf.d, so if you create the directory and leave it empty, nginx starts, serves its default page, and never forwards anything to PHP-FPM. The server block you need (root at /var/www/html/public, try_files into index.php, FastCGI to app:9000, and the Laravel-specific location rules) ships in the official dockerfiles repo alongside the Debian image. Take it from there for the tag you’re deploying rather than hand-rolling it, drop it in ./nginx/, and confirm the directory is populated before the first docker compose up.
Four named volumes, but only two of them are in the backup set. app_storage holds uploaded logos and generated PDFs; mysql_data is the database. app_public is compiled assets the image regenerates on boot. redis_data is the one to think about rather than dismiss: with QUEUE_CONNECTION and SESSION_DRIVER both set to redis below, Redis holds pending jobs and live sessions, not just cache. That’s fine to exclude from backups, but exclude it deliberately, and drain the queue before you snapshot anything you plan to restore from. Losing Redis costs you in-flight jobs and logs everyone out; it does not cost you business records.
On the environment file, the values you must set before first boot and the settings that are easy to get wrong:
APP_URL=https://billing.yourdomain.com
APP_KEY=
APP_ENV=production
APP_DEBUG=false
APP_TIMEZONE=America/New_York
TZ=America/New_York
REQUIRE_HTTPS=true
TRUSTED_PROXIES=*
PDF_GENERATOR=snappdf
CACHE_DRIVER=redis
QUEUE_CONNECTION=redis
SESSION_DRIVER=redis
REDIS_HOST=redis
REDIS_PORT=6379
FILESYSTEM_DISK=debian_docker
IS_DOCKER=true
DB_CONNECTION=mysql
DB_HOST=mysql
DB_PORT=3306
DB_DATABASE=ninja
DB_USERNAME=ninja
DB_PASSWORD=
DB_ROOT_PASSWORD=
MAIL_MAILER=log
IN_USER_EMAIL=admin@yourdomain.com
IN_PASSWORD=
The four blank values are mandatory and the stack will misbehave in a different way for each one you skip. Generate APP_KEY once with docker run --rm invoiceninja/invoiceninja-debian:<tag> php artisan key:generate --show and never regenerate it, because it is what makes encrypted fields in the database readable and a new key orphans them. DB_PASSWORD and DB_ROOT_PASSWORD are consumed directly by the MySQL service in the compose file above. IN_PASSWORD, with IN_USER_EMAIL, is what stops the image creating the default admin account described in the warning below. Generate the passwords with openssl rand -base64 32, put the APP_KEY in your password manager before you paste it into the file, and chmod 600 the result.
REQUIRE_HTTPS and TRUSTED_PROXIES are the pair that bite, and they do three distinct jobs that are easy to blur together. APP_URL supplies the canonical external URL the application uses to build links. TRUSTED_PROXIES is what lets Laravel believe the X-Forwarded-Proto header cloudflared sends, because the hop from cloudflared to nginx is plaintext HTTP inside the Docker network and without it the framework concludes the request arrived over HTTP. REQUIRE_HTTPS forces the HTTPS behavior on top of that. Get the combination wrong and you get generated http:// links, a scheme mismatch between the page origin and the session cookie, and CSRF failures that present as a login page that submits and reloads itself forever. Set the timezone explicitly in both forms too, or invoice dates and scheduler windows drift somewhere you won’t think to look.
MAIL_MAILER=log is intentional for first boot. It lets the app start cleanly before SMTP credentials exist, and Step 6 switches it.
First boot, in order
- Bring up everything except the tunnel:
docker compose up -d app nginx mysql redis. The cloudflared service exists in the file but has no token yet. - Watch
docker compose logs --tail=50 app. The image’s first-boot script runs migrations, runs every seeder (Banks, Currencies, Languages, Countries, Industries, PaymentTypes, GatewayTypes, DateFormats, DesignSeeder), and creates the admin account fromIN_USER_EMAILandIN_PASSWORD. There is no manualphp artisan migratestep, whatever older guides written against the unbundled image tell you. Supervisor then brings up PHP-FPM, two queue workers, and the scheduler. Total bootstrap is around thirty seconds. - Confirm the host is silent:
sudo ss -tulpn | grep -E ':(80|443|3306|6379) 'should return nothing at all. - Create the tunnel in the Zero Trust dashboard (Networks, then Tunnels, then Create), pick the Cloudflared connector, name it, and copy the
TUNNEL_TOKEN(the longeyJ...string) out of the install instructions. Stop there. Do not let the wizard walk you into adding a public hostname. - Put the token in
cloudflared.env,chmod 600it, anddocker compose up -d cloudflared. The connector should register four QUIC connections to nearby edge POPs and the dashboard should flip from Inactive to Healthy. The tunnel is now connected and has no route, so the app is still unreachable from the internet. That’s the state you want. - Add the One-time PIN identity provider if the account doesn’t have one: Integrations, Identity providers, Add new, One-time PIN. No configuration; it emails a six-digit code.
- Create the Access application covering the hostname with no path, and a single inline policy: Allow, Include, Emails, your address. Access controls, Applications, Add an application, Self-hosted. Access now exists in front of a hostname that doesn’t resolve yet.
- Add the published application route to the tunnel: Networking, Tunnels, your tunnel, Routes, Add route, Service URL
http://nginx:80. Cloudflare creates the DNS CNAME at this moment, so the hostname goes from not-resolving to resolving-and-gated in one transition. (Naming note: what used to be called “Public hostname” was renamed “Published application route” in the April 2026 dashboard redesign, and both the account-level Networking path and the Zero Trust Connectors path show the same data.) - Verify in a clean private browser session. You should get the Access one-time-PIN page, then the code, then the Invoice Ninja login form.
- Log in with the admin email and the password from the env file (
grep IN_PASSWORD ~/invoiceninja/.env). - Turn on 2FA on the admin account before you configure anything else: My Account, Two-Factor Authentication, scan with a TOTP app, confirm the code, and save the recovery secret in your password manager. Log out and back in to prove the whole flow works.
Before moving on, verify the exposure claim from outside rather than trusting the compose file. From a host that is neither on your tailnet nor behind your tunnel, scan the VPS on both address families: nmap -Pn -sT -p 80,443,3306,6379 <ipv4> and then nmap -6 -Pn -sT -p 80,443,3306,6379 <ipv6>. The -6 is required; without it nmap will not scan IPv6 at all, and “I ran the same command against the v6 address” is a check that silently didn’t happen. On the VPS, run sudo ss -tulpn -4 and sudo ss -tulpn -6. Nothing should be listening on 0.0.0.0:80, 0.0.0.0:443, [::]:80, or [::]:443. On this build all four ports came back filtered from a VPN exit, corroborated against an online scanner on a wider port set. Two gotchas from doing it for real: nmap’s default SYN scan fails with Failed to determine dst MAC address when run over a VPN’s virtual adapter, so use -sT (TCP connect) which goes through normal sockets; and most home networks and consumer VPNs have no global IPv6 route, so the v6 scan may just report failed to determine route. If you can’t get an off-net IPv6 vantage point easily, the on-box ss -tulpn -6 plus a symmetric UFW v6 deny covers that case.
One log line to ignore: cloudflared in an unprivileged container prints failed to sufficiently increase receive buffer size (was: 208 kiB, wanted: 7168 kiB, got: 416 kiB) at startup. That’s quic-go noting it can’t raise UDP buffers without CAP_NET_ADMIN. The smaller buffer is fine at this scale. Do not grant CAP_NET_ADMIN to silence it.
Warning: If IN_USER_EMAIL and IN_PASSWORD are not set in the environment before first boot, the Debian image creates a default admin account at admin@example.com with the password changeme!. Set both before you ever start the container, or log in on the defaults and change the email and password before doing literally anything else. This is the single reason the ordering in this step is what it is: a default-credentialed invoicing app and a published tunnel route must never overlap in time.
How AI can help
The compose file is the easy part; the environment file is where a subtle mistake costs you an hour. Hand an assistant your .env and the architecture (cloudflared to nginx over plaintext inside the Docker network, HSTS-preloaded domain, Redis for cache/queue/session) and ask specifically what breaks. The proxy-trust and forced-HTTPS interaction is exactly the kind of thing it catches instantly and you stare past. It's also good at the verification side: ask it to write the external exposure check as a single script that scans both address families, runs the on-box ss checks over SSH, and prints one pass/fail line, so re-running it after any change in Step 7 is one command instead of a checklist you'll skip.
Mail and Stripe in Test Mode
Both of these get wired while the blanket Access policy from Step 5 is still in force. That’s not a compromise, it’s the plan: the failures this produces are the discovery signal Step 7 runs on.
Pick a transactional SMTP relay and move on. Postmark, SES, Resend, and Mailgun all work, and their free tiers move often enough that you should check current limits rather than trust any guide including this one. This build used Resend: API key in MAIL_PASSWORD, host smtp.resend.com, port 587 with TLS, and the username is the literal string resend. Add the provider’s DKIM record at your DNS provider and the first test send comes back dkim=pass in the receiving inbox’s headers.
Add SPF and DMARC as well, and be clear about which one does what, because the common shorthand is wrong. SPF authenticates the envelope sender (the return path), not the From: address your customer actually sees, so SPF alone does not stop someone spoofing your billing domain. DMARC is the mechanism that does: it requires the visible From: domain to align with an authenticated SPF or DKIM result, and it lets you publish an enforcement policy for what receivers should do when alignment fails. Publish all three, and check first whether the domain already has an SPF record, because a second SPF TXT record does not add to the first one, it invalidates both.
One Invoice Ninja specific trap before you debug anything: the global MAIL_* environment variables only take effect when the company’s own Email Provider setting is left as Default. If that has been switched at the company level, the application ignores your environment entirely and you will spend the evening testing SMTP credentials that were never being used. Confirm that setting before you suspect the relay.
Do not run your own Postfix for invoice email. Deliverability for transactional mail from a self-managed MTA on a small VPS is a multi-month project involving IP warmup, reputation, feedback loops, and complaint handling. Pay the relay somewhere between $0 and $15/month and spend the time on the business instead.
Two practical notes. First, switching from MAIL_MAILER=log to smtp requires recreating the container, not restarting it: docker compose up -d --force-recreate app. An env_file is read at container creation, so docker compose restart app silently reuses the old environment and you’ll spend twenty minutes debugging credentials that are already correct in the file. That’s generic Compose behavior rather than an Invoice Ninja quirk, but it catches people often enough to say out loud.
Second, Invoice Ninja v5 has no one-click “send test email” button. The minimum viable test is to create a test client with an inbox you control, draft an invoice with a $1 line item, save it, and then explicitly click Email on the saved invoice. Sending is a deliberate action, not a side effect of saving. Successful sends appear in the relay’s activity log within seconds. Open the raw headers on the receiving end (Gmail’s “Show original”) and confirm dkim=pass before you call mail done.
Stripe
Switch Stripe to Test mode before touching anything. The toggle’s position moves with dashboard redesigns, so trust the TEST badge rather than a remembered location. Test keys live under Developers in an API keys section, and you need both the publishable key (pk_test_...) and the secret key (sk_test_...).
In Invoice Ninja, add a Stripe gateway under online payments. The field labels do not match Stripe’s vocabulary, which is worth slowing down for:
- Publishable Key is the publishable key. Fine.
- Api Key is where the Stripe secret key goes. Stripe calls this the “Secret key” in every part of its own interface; Invoice Ninja calls it “Api Key” here. Do not fill it from the publishable side.
- Webhook Secret is the signing secret (
whsec_...), and it doesn’t exist yet. Leave it for now.
Save with the two keys, and the gateway detail page will display a webhook URL specific to this gateway. On 5.13.22 the route is payment_webhook/{company_key}/{company_gateway_id}, so the full URL is your APP_URL followed by /payment_webhook/<company_key>/<company_gateway_id>. Those two segments identify which company and which configured gateway an event belongs to. Copy the URL the page generates rather than assembling it yourself from those names, and create a Stripe webhook endpoint pointing at it.
When Stripe asks which events to send, do not select all of them. Create the destination in Stripe’s Snapshot event format, which is what Invoice Ninja’s handler is written against, and then subscribe only to the events it actually dispatches on. Selecting everything buys you hundreds of event types that will never be acted on and makes the handful that matter harder to find when something breaks. Reading app/PaymentDrivers/StripePaymentDriver.php at the tag deployed here, the handler dispatches on these eleven:
charge.succeeded, charge.failed, charge.refunded, payment_intent.succeeded, payment_intent.processing, payment_intent.partially_funded, payment_intent.payment_failed, customer.source.updated, setup_intent.succeeded, source.chargeable, and mandate.updated.
Subscribe to those, save, and Stripe shows you the signing secret on the endpoint detail page. Paste it into the gateway’s Webhook Secret field and save again. Re-read that handler for whatever tag you deploy rather than trusting this list, since the set moves across point releases. And note what is not in it: there is no charge.dispute.* branch, so disputes and chargebacks are not reconciled into Invoice Ninja by this configuration at all. They live in the Stripe dashboard, and you handle them there.
Now the part that looks like a failure and isn’t. Every Stripe webhook delivery will return HTTP 302, not 403. Cloudflare Access redirects the unauthenticated POST to its own login page rather than hard-blocking it, and Stripe reads a 302 as a failed delivery and retries on its usual backoff. That is the expected state before Step 7, and those failures are useful: Stripe’s attempt log now contains the exact path Stripe is targeting, which is one of the four discovery sources Step 7 cross-references.
That the webhook is failing does not stop a card payment from working, and understanding why matters for what you gate later. On this build, a $1 test charge with 4242 4242 4242 4242 cleanly marked its invoice paid while every webhook delivery was still 302ing. The reason is that the interactive card path completes in the browser: the portal confirms the PaymentIntent, returns to Invoice Ninja, and the backend fetches that PaymentIntent from Stripe server-side and records the completed payment from what it gets back. No webhook required for the happy path.
Do not read that as “the webhook is optional,” which is where this observation gets misused. It holds only for a customer who stays on the page through the redirect. Invoice Ninja’s PaymentIntentWebhook job contains a recovery path that creates the payment when no completed payment exists yet, which is exactly the case where the customer closed the tab after Stripe succeeded but before the return trip finished. Stripe’s own guidance says the same thing for the same reason. Add the genuinely asynchronous cases (refunds initiated from the Stripe dashboard, delayed ACH or SEPA settlement, mandate updates) and the webhook is what keeps your records converged with reality. Production needs that path open, which is the next step.
Discover the Real Public Routes, Then Carve Them Out
This is the step that diverges most from every other self-host guide, and it comes with a caveat stated plainly: what follows was designed from a real route-discovery pass against the running stack described above, cross-referenced against Invoice Ninja’s own route definitions, and reviewed against Cloudflare’s documented wildcard behavior. It was not applied to that box. The application design and the verification procedure are the deliverable here; the “I ran this and watched the webhook flip to 200” line is in Step 8’s open items, not in this one.
Say clearly what “carve out” means, because the wrong verb here is dangerous. The blanket root application from Step 5 does not get deleted. It stays exactly where it is and keeps requiring login for every path that nothing more specific matches, which is the entire admin surface. What you add is a set of narrower applications, each scoped to one public path, each carrying a Bypass policy. If you delete the root application instead, every unmatched path on the hostname, including /dashboard and /api/v1/*, becomes reachable by anyone. The root app is the thing holding the floor up.
The order is the other important part. Discover routes first, write policy second. Do not assume /client/* covers the public surface, because it doesn’t.
Discovery, with the blanket policy still on
Leave the blanket policy in place for this pass. You’re authenticated through Access while you do it, so you’re recording the paths the application touches, not the paths Access blocks. From a clean private window, authenticate once, then run the whole flow: send a test invoice to an address you control, click every link in the email, view the invoice as a client, download the PDF, and pay with a Stripe test card end to end.
Then read four sources, because no single one of them sees everything:
The nginx access log on the VPS captures every URL the browser-driven flow hit. From the compose directory: docker compose logs nginx --since 15m | grep -E '"GET|"POST' | awk '{print $9,$11}' | sort -u. Fields 9 and 11 land on path and status because Docker’s log prefix shifts nginx’s standard columns by two; that’s verified against the actual output format, not assumed. This source will not show you the Stripe webhook, because Access intercepts it before it ever reaches nginx.
Stripe’s webhook delivery view (Developers, Webhooks, your endpoint, the events or attempts tab) shows the failed deliveries with the exact target URL and response code. Look for 302, not 403. If you go grepping for 403s you will find nothing and conclude the webhook is fine.
Cloudflare’s Zero Trust logs, with a caveat on how far to trust them. Access authentication logs record authentication events, not every unauthenticated request that got redirected before login, and full per-request URI and user-agent logging is a Logpush capability rather than something the free dashboard guarantees. Check what your account actually surfaces; if you get per-request detail it’s a useful corroborating view, and if you don’t, Stripe’s delivery-attempt log is the authoritative source for the blocked webhook path anyway.
Invoice Ninja’s own route files for the version you deployed, routes/client.php and routes/web.php. This is the source that catches what a single test flow doesn’t exercise, and it earned its place: the bare parent paths /client/invoices and /client/payments only surfaced by cross-referencing the route file against the log capture. Do not trust route lists from older guides, since names drift across point releases.
The inventory that came out of that pass: /payment_webhook/<company_key>/<company_gateway_id>, /payments/process/response, /client/invoice/<hash>, /client/invoices, /client/invoices/<hash>, /client/payments, /client/payments/<hash>, /client/showBlob/<hash>, /livewire/update, and /build/assets/<file>.js.
Carving out the public paths
The structural decision is to use multiple Access applications, each scoped to the narrowest path that survived discovery, rather than one root application carrying a long list of Bypass rules. The difference is not stylistic. A “Bypass Everyone” policy attached at the root exposes the entire hostname, and Cloudflare evaluates Bypass and Service Auth policies ahead of normal allow/block ordering, so that one rule wins before your “require login” rule on the admin paths ever gets evaluated. Per-path applications make that failure mode unreachable by construction.
Path specificity decides precedence, not creation order, so the ten applications below can be created in any sequence. Each is more specific than the bare-hostname root application from Step 5, so each wins its match, and the root catches everything else: the admin UI, /api/v1/*, /login, /dashboard. The root application stays exactly as it is, which also means the rollback is simply deleting the ten new applications.
| Path | Why it bypasses |
|---|---|
payment_webhook/* |
Stripe’s webhook target. Machine traffic, no cookie possible. No bare path needed; Stripe always sends the hash suffix |
payments/process/response |
Stripe’s success callback, as an exact path. Tighter than payments/process/* |
client/invoice/* |
Singular form; where the emailed invitation key lands |
client/invoices |
Bare parent. The wildcard below does not cover it |
client/invoices/* |
The customer-facing portal invoice list |
client/payments |
Bare parent |
client/payments/* |
Customer payment listing |
client/showBlob/* |
Logo and asset blobs the portal pages load |
livewire/* |
The portal uses Livewire for interactive elements. Broadest of the ten; revisit on major upgrades |
build/assets/* |
Compiled JS and CSS the portal needs to render at all |
Each row becomes one self-hosted Access application on your hostname, carrying a single Bypass policy whose Include rule is selector Everyone, value Everyone. In the current dashboard that’s Zero Trust, Access controls, Applications, Create new application, Self-hosted and private, Add public hostname; set the subdomain and domain from the dropdown, put the path in the Path field, and add the inline Bypass policy. Identity provider and session duration are irrelevant under Bypass. The Path field takes no leading slash.
The reasoning behind bypassing each of them is one sentence: a customer receiving an invoice is not in your Access allowlist, and never will be. Without a bypass on the customer-facing routes they hit a one-time-PIN prompt for an identity you never authorized, and there is no version of that they can get past. With the bypass, they reach Invoice Ninja directly and the application authenticates them off the hash in the invoice link. Invoice Ninja’s own authentication takes over on those paths; authentication doesn’t disappear, it changes hands. Stripe needs the webhook path for the same reason, minus the possibility of any credential at all. The static assets and Livewire endpoints are simply what the portal pages need to render and stay interactive.
What these ten paths do not cover
This is the limitation to be honest about, and it follows directly from how the list was built. Those ten paths are what one specific journey touched: an emailed invoice, opened from the link, viewed, downloaded as a PDF, and paid by card. That is the journey that was tested, so that is the journey the list covers.
Invoice Ninja’s client portal is considerably larger than that. Reading routes/client.php at the tag deployed here, the customer-facing surface also includes client/login, client/register, client/password and the password-reset flow, client/key_login and client/magic_link, client/dashboard, client/quotes and client/quote, client/credits and client/credit, client/recurring_invoices, client/subscriptions, client/documents, client/statement, client/profile, client/payment_methods, client/tasks, client/projects, client/pay, and the client/email_preferences and client/unsubscribe endpoints that your own invoice emails link to. Under the ten applications above, every one of those still hits the root application and challenges the customer for a login they don’t have.
For the emailed-invoice-and-pay workflow this guide walks, that’s correct behavior and arguably desirable: the surface is minimal because the workflow is. But if you send quotes, take deposits through subscriptions, expect customers to log into a portal dashboard rather than click invoice links, or care that the unsubscribe link in your own email actually works, then the ten paths are a starting point and not an answer. Run the same discovery pass against each feature you turn on, and add applications the same way. The method is the transferable part here; the specific list is only as complete as the flow that produced it.
Do not add /api/v1/* to that list, even though it’s tempting when something breaks. Bypass disables Access enforcement and, per Cloudflare’s own documentation, bypassed requests are not logged by Access, so a broad API bypass costs you both the gate and the visibility. The admin browser session already carries the Access cookie when it calls /api/v1/*, so the admin UI works without any bypass at all. For machine integrations you control (a script, a Zapier step, anything where you can set headers), use Cloudflare Service Auth tokens, which keep enforcement and logging instead of discarding both. Service tokens work by sending CF-Access-Client-Id and CF-Access-Client-Secret on every request, so they only help where the client can actually attach those headers. A third-party app that can’t is a separate problem, and the answer there is a separate integration hostname with its own policy, not a hole in this one.
Important: A Cloudflare path wildcard like client/invoices/* covers everything under /client/invoices/ and does not cover the bare path /client/invoices. Invoice Ninja exposes both, so both need their own application. This is what took the design from eight applications to ten, and it is the kind of thing that produces a portal which works for every customer except the ones who click the wrong link.
Verifying it
Everything below runs from a clean private session with no Access cookie, and the point of running all of it is that the failure modes are not symmetrical: a too-narrow policy breaks the portal loudly, and a too-broad one exposes the admin UI silently.
- The public paths must land in the application, not on a login page. Test the two bare parents explicitly, since they’re the ones the wildcards miss.
- The admin paths (
/,/login,/dashboard) must still redirect to the Access login page. A one-line spot check on each:curl -sSI https://billing.yourdomain.com/client/invoices | grep -i locationshould not point at*.cloudflareaccess.com, and the same command against/loginshould. - With DevTools open on the Network tab, run the full portal flow and confirm there are zero
302responses pointing at a Cloudflare Access login URL on any of/livewire/*,/build/assets/*,/client/showBlob/*, or/payments/process/response. A blank or unstyled portal page is this failure with the evidence one layer down. - In Stripe, open the most recent failed delivery and hit Resend. The status should flip from
302to200. - Run a fresh test invoice and a
4242 4242 4242 4242payment end to end through the clean session, and confirm the invoice marks paid with the narrow policy in force. - Re-run the external port scan from Step 5. Access policy changes shouldn’t move layer 3, but this is cheap and it’s the check that catches a mistake you made somewhere else while you were in here.
How AI can help
Route discovery is the best use of an assistant in this entire build, because the work is correlating four sources that each speak a different dialect: nginx access lines with Docker's column offset, Stripe's delivery log, Cloudflare's Access log, and a Laravel route file. Paste all four in and ask for the deduplicated set of externally-reachable paths, explicitly including bare parents that the wildcards won't cover. That last instruction is the one that matters; the bare-parent problem is exactly what a human eye skims past. It's also worth having it read the route file for the version you deployed and flag any public route your test flow never exercised, since a single pass through the portal doesn't touch everything a real customer might.
What’s Left Before You Go Live
This is where the build stopped, so this step is a scope statement rather than a walkthrough. Four things stand between the stack described above and a box that should see a paying customer, and none of them are optional.
Apply and verify the narrow access policy. Step 7’s ten applications are designed and the verification list is written; creating them and working through that list is the immediate next action. Until the Stripe webhook returns 200 from a clean session and the admin paths still challenge, the access model is a design, not a result.
Back it up, and restore it once. The backup set is four things: a database dump, the app_storage volume, the .env file, and the deployment files (compose, nginx config, cloudflared config). The .env is not a convenience item on that list. It holds APP_KEY, which is what encrypts the sensitive fields in the database, gateway credentials among them. Restore the database against a different key and the tables are all there and the encrypted values in them are gone, which is a worse failure than an obviously broken restore because it looks like it worked. Encrypt before it leaves the box, push it somewhere offsite (R2, B2, Wasabi are all fine), and size retention against the records-retention number from Step 1 rather than against a default. The DocuSeal guide works through the encrypt-and-ship mechanics in detail and that part transfers directly.
The restore test has one requirement specific to this application that a generic restore procedure will not tell you. Disable outbound mail before starting the restored stack. Set MAIL_MAILER=log in the restored .env, or block egress to the SMTP relay at the firewall. A restored Invoice Ninja instance holds real customer addresses and boots a working scheduler and queue, and it will cheerfully re-send invoices, receipts, and reminders to real people the moment it comes up. Then restore, bring it up, and confirm login works, clients are visible, PDFs render, and payment history is intact. Send one email from the restored instance and verify it landed in the log sink rather than an inbox. Tear it down when you’re done. One note while you’re designing this: php artisan backup:run via spatie/laravel-backup is plausible inside this image but was not verified here, so the boring path (mysqldump, a tar of the storage volume, and a copy of the env and config files) is what this guide would recommend until someone confirms otherwise.
Measure it under the load that actually hurts. The idle numbers are known: post-boot with all four containers up and no activity, roughly 389 MiB used with 911 MiB in buff/cache and about 1536 MiB available on a 1926 MiB box. After first login, 2FA enrollment, mail wiring, and one invoice send, that moved to roughly 1100 MiB used with about 200 MiB free, and the 2 GB of swap was never touched. Neither reading is the answer, because Chrome had not rendered a PDF at either capture point. The measurements that decide whether 2 GB is enough are a single PDF render’s memory delta and wall time, a bulk run of ten to fifty sequential renders with peak memory and total time, queue worker and MySQL resident memory in that same window, swap usage at the worst point, and page-load behavior after a cold start. Anyone sizing a box off this guide should run those before trusting the $7 tier.
Alongside the numbers, the functional gate: the scheduler firing every minute, the queue worker processing jobs, a clean failed-jobs table, PDF generation working through the queue path and not just interactively, invoice email landing in the inbox (not spam) at both a Gmail and an Outlook address with dkim=pass and spf=pass in the headers, admin cookies carrying Secure and HttpOnly, no http:// links anywhere in a sent invoice, and application logs clean of unhandled exceptions through all of it. The scheduler is the one that needs real time to elapse rather than a checkbox: schedule a recurring invoice for a specific time tomorrow, come back after it passes, and confirm it fired and emailed.
Then plan the boring maintenance. The upgrade flow is: pin a new tested tag, back up first, let the container run its migrations, and re-run the subset of the validation checklist that touches what changed. Image tags move regularly (this build validated on 5.13.22 in May and the current tag is 5.13.33 in August), so “pinned” means “reviewed on a schedule,” not “frozen.” The white-label license at $40/year is the only paid item if the “Powered by Invoice Ninja” footer bothers you. The official mobile apps work against a self-hosted instance given the right APP_URL, though whether they can be made to work through Access is its own unanswered question here: they’d need to attach Cloudflare service-token headers, and nothing in this build tested that. If mobile matters to you, resolve it before you commit to a single gated hostname, because the fallback is a separate integration hostname rather than a broad API bypass. And importing from FreshBooks, QuickBooks, or Xero via CSV is a real path if you’re migrating rather than starting clean.
Important: Test the restore before you trust the backup, and test it with outbound mail disabled. Those are two separate disciplines and skipping either one has its own distinct failure: an untested backup is an aspirational tarball, and a tested backup restored with live SMTP credentials is a mass email to every customer you have.
How AI can help
Two things here are worth handing off wholesale. The first is the backup and restore harness: the snapshot and encrypt and upload script, the cron entry, and separately a restore-test.sh that untars, decrypts, forces the mail driver to a sink, brings up a scratch stack, verifies the tables and a rendered PDF, and prints one pass or fail line. The mail-sink override is the part to specify explicitly in the prompt, because a generic restore script will not include it and that omission is the one with consequences outside your own infrastructure. The second is the measurement pass: ask for a script that drives a bulk PDF render while sampling container memory, and hand you a table instead of a scroll of docker stats. Both of these are the kind of tedious-but-mechanical work where an assistant genuinely compresses an afternoon into twenty minutes.
What You Spent
Important: Steps 1 through 6 were validated on 2026-05-09 against a Hetzner CPX11 running Debian 13 (trixie), Invoice Ninja 5.13.22, and cloudflared 2026.3.0, including a real Stripe test-mode charge marking a real invoice paid. Step 7’s policy design was derived from a real discovery pass on that stack but was not applied to it, and Step 8’s items are open. Provider pricing, mail relay free tiers, and Invoice Ninja’s licensing all change; verify current numbers before following any of them.
For light single-user or small-team use:
- Domain: $10 to $15/year
- VPS: about $7/month, or $84/year, on a Hetzner CPX11
- Cloudflare: $0. The free tier covers DNS, the tunnel, and Zero Trust Access for teams under fifty
- Stripe: 2.9% + $0.30 per successful card charge, no monthly fee
- Mail relay: $0 to $15/month depending on volume and provider
- White-label license, optional: $40/year
That lands somewhere around $100 to $250/year all in, plus processing fees. The comparison worth making is not against Invoice Ninja’s own hosted Pro plan at $140/year, which is a fair price and saves you this entire guide. It’s against the tools people actually end up on: FreshBooks Plus at $516/year for one user with a fifty-client cap and $11/month per additional seat, or QuickBooks Online Essentials at $85/month. The self-hosted math works at one user and gets more lopsided with every seat you add, because the box costs the same whether one person or six are logged into it.
What you’re giving up is real, though, and it’s the same trade as every self-host decision. There’s no SLA, no support contract, and no one but you to notice at 2 AM that the tunnel is down and three invoices didn’t send. If invoicing is load-bearing for your cash flow, the Step 8 validation gate and an uptime check on the public hostname are not optional extras. They’re the price of the $7 tier.
Toolkit Reference
The user-facing tools that appear across this guide, and the concrete spots where an AI assistant earns its keep.
Tools and Services
- Invoice Ninja v5
- The application. Source-available under Elastic License 2.0; Pro and Enterprise features are included in the self-hosted build at no cost.
- Invoice Ninja Debian image
- The official Docker image. Bundles PHP-FPM, supervisor, scheduler, queue workers, and Chrome for PDF generation. Pin a specific tag.
- Cloudflare
- Domain registration, DNS, Universal SSL, the free Tunnel connector, and the free Zero Trust plan that carries every Access application in Step 7.
- Tailscale
- Private mesh network for SSH. The only administrative path to the box, since nothing is published publicly.
- Hetzner Cloud
- The $7/month CPX11 this build ran on. DigitalOcean, Vultr, Linode, and OVH have equivalent tiers.
- Stripe
- Payment processing. Test mode carries the entire validation flow; the eleven Snapshot webhook events are the only ones Invoice Ninja acts on.
- Resend (or Postmark, SES, Mailgun)
- Transactional SMTP for invoice email. DKIM at minimum, plus SPF and DMARC.
- Cloudflare Service Auth
- The correct answer for machine access to
/api/v1/*, instead of widening a Bypass rule.
Where AI Earns Its Keep
- Environment file review
- Catch the proxy-trust and forced-HTTPS interaction before it produces a login loop on an HSTS-preloaded domain. The compose file is easy; the env file is where the subtle mistake lives.
- Route discovery correlation
- Deduplicate externally-reachable paths across four sources that each speak a different dialect: nginx logs with Docker's column offset, Stripe delivery attempts, Cloudflare Access logs, and Laravel route files. Ask explicitly for bare parents the wildcards won't cover.
- Exposure verification script
- One script that scans both address families from outside, runs the on-box listener checks over SSH, and prints a single pass or fail line, so re-running it after any policy change is cheap enough to actually do.
- Backup and restore harness
- The snapshot, encrypt, and upload path, plus a restore test that forces the mail driver to a sink before the restored stack ever boots. Specify the mail-sink override explicitly; a generic script won't include it.
- Load measurement
- Drive a bulk PDF render while sampling container memory and return a table, so the 2 GB sizing question gets answered with numbers instead of a shrug.