Can I self-host FreshBooks?

YES, BUT · ONE WEEKEND— setup effort 3 of 4

YES, BUT — it's called Invoice Ninja. It takes one prompt, a 3072 MB VPS, and about 240 minutes. That is $43 a month you stop paying FreshBooks — $516 a year on the Plus plan.

Why people pay for FreshBooks

Stated as the vendor would want it stated. A replacement you pick without knowing what the subscription actually buys is a replacement you abandon in a fortnight.

FreshBooks sells the part of getting paid that nobody enjoys building: an invoice that lands in an inbox instead of a spam folder, a payment link the client can actually use, reminders that chase the late ones without you having to, and books an accountant will accept in April. You are renting deliverability, a payments relationship and somebody else's tax-season liability, not an invoice template.

FreshBooks plans and list prices
PlanList priceWhat it buys
Lite$23/moBilled monthly, capped at 5 billable clients, one user. Annual billing is advertised at about 10% less.
Plusthe plan this page prices against$43/moBilled monthly, capped at 50 billable clients, one user. The tier that adds proposals, retainers and accountant access.
Premium$70/moBilled monthly, unlimited billable clients, one user. Adds project profitability and email customization.
Selectquote onlyQuote only. The page says Let's Talk, and includes two team member accounts and data migration.

Vendor list prices in USD, read from the pricing page on 2026-08-06 · confidence: high

Replaced by Invoice Ninja

One project, named before the prompt, so you know what you are about to install.

Invoices, quotes, expenses and a client portal on your own domain, with no cap on how many clients you bill.

The only one here that covers the whole billing loop the way FreshBooks does: clients, quotes, invoices, recurring invoices, expenses, a client portal your customer signs into, and payment-gateway integrations you connect yourself. It is also the heaviest to run, four containers and a MySQL you own, and it is source-available under the Elastic License rather than open source, with an Invoice Ninja footer on client-facing PDFs until you buy the white-label licence. What you get for that is the end of the client cap that decides your FreshBooks tier.

What else we looked at

Ranked, with the reason each one placed where it did. Only the pick has a prompt on this page — the runners-up are named so the choice is visible, not hidden.

  1. Invoice Ninjaour pickONE WEEKEND— setup effort 3 of 4

    Invoices, quotes, expenses and a client portal on your own domain, with no cap on how many clients you bill.

    The only one here that covers the whole billing loop the way FreshBooks does: clients, quotes, invoices, recurring invoices, expenses, a client portal your customer signs into, and payment-gateway integrations you connect yourself. It is also the heaviest to run, four containers and a MySQL you own, and it is source-available under the Elastic License rather than open source, with an Invoice Ninja footer on client-facing PDFs until you buy the white-label licence. What you get for that is the end of the client cap that decides your FreshBooks tier.

  2. KimaiONE EVENING— setup effort 2 of 4

    Timesheets with customers, projects, hourly rates and invoices, on a server that never asks how many seats you have.

    The right answer if what you actually bill is hours. Kimai is a time tracker first and an invoicer second, so it turns tracked time into an invoice cleanly and does not pretend to be an accounting system. Pick it over Invoice Ninja when the timesheet is the source of truth and the invoice is the last five minutes of the month.

The swap

You're paying

FreshBooks

$43/mo · $516/yr

is replaced by

You'd run

Invoice Ninja

ONE WEEKEND · ~240 min to running · 3072 MB RAM

FreshBooks Plus · vendor list price · checked 2026-08-06 · source

Before you start

RAM floor
3072 MBfloor from upstream docs — not measured by us yet
Disk
10 GBthe app, its data, and room for one backup
Domain needed
yes, one A recorda hostname pointed at the box before you start — TLS needs it on the cloud path, and the local path needs none
Time budget
~240 min3–24 hours, through the first backup

The prompt

Two paths to the same Invoice Ninja: the cloud one assumes Prompt Zero is done on a server you rent, the local one assumes nothing but a computer that can run Docker Desktop. Read whichever you pick before you paste it, which is the whole reason both are on the page instead of behind a download.

authored from upstream docs · not yet machine-verified · Claude Code

Where it runs

356 lines · 14,847 bytes

What this prompt will do
  1. Preflight
  2. Layout
  3. Secrets
  4. compose.yml
  5. Caddy and TLS
  6. Firewall
  7. Start and verify
  8. First backup and restore
  9. Updating later
  10. What will probably go wrong
  11. Out of scope

Read out of the prompt’s own step headings at build time — if the prompt changes, this list changes with it.

paste it into Claude Code in a terminal on your own machine · it runs the install over ssh vps

You are Claude Code on the user's machine. The user has completed Prompt Zero: `ssh vps` works,
Docker and Caddy are installed, the firewall is default-deny.

Run every command in this prompt on the server over `ssh vps` unless the step says otherwise.

Install Invoice Ninja 5.13.30 on that server, reachable at https://<DOMAIN>, behind the existing
Caddy with automatic TLS.

## 1. Preflight

If `<DOMAIN>` or `<ADMIN_EMAIL>` is still literal, ask the user for both once and stop until they
answer. Say why the hostname matters: it becomes `APP_URL`, which the first boot writes into the
company record as the client-portal domain, so it is on every invoice link a client opens, and
its A record must already point here. `<ADMIN_EMAIL>` is the login for the account this makes.

Invoice Ninja needs 3072 MB of RAM available and 10 GB free on /srv: MySQL 8.4, php-fpm, two
queue workers and a headless Chrome that appears whenever a PDF renders. All four images publish
amd64 and arm64; only the Saxon extension, which validates e-invoice XML, is amd64 only.

```bash
free -m | awk '/^Mem:/ {print $7 " MB available of " $2 " MB"}'
df -BG --output=avail /srv | tail -1
dpkg --print-architecture
dig +short <DOMAIN>
```

If available RAM is under 3072 MB or free disk is under 10 GB, print both and stop. Do not install
and hope. If `dig +short` prints nothing, print that and stop: Caddy cannot certify a name that
does not resolve, and failed attempts count against a rate limit.

## 2. Layout

```bash
sudo install -d -m 750 -o $(id -u) -g $(id -g) /srv/invoice-ninja /srv/invoice-ninja/backups /srv/invoice-ninja/nginx
sudo install -d -m 700 /srv/invoice-ninja/mysql /srv/invoice-ninja/redis
cat > /srv/invoice-ninja/nginx/invoice-ninja.conf <<'EOF'
# Invoice Ninja · nginx for php-fpm, from https://laravel.com/docs/12.x/deployment#nginx

server {
	listen 80 default_server;
	root /var/www/html/public;
	index index.php;
	client_max_body_size 20M;

	location / {
		try_files $uri $uri/ /index.php?$query_string;
	}

	location ~ \.php$ {
		fastcgi_pass app:9000;
		fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
		include fastcgi_params;
	}
}
EOF
ls -la /srv/invoice-ninja
```

Assert: `ls -la` shows `backups` and `nginx` owned by the login user, `mysql` and `redis` at mode
`700` owned by root. Leave those two: both images chown their own data directory on first start.
`public` and `storage` are not here; compose keeps them in named volumes.

## 3. Secrets

Four, generated here: the Laravel application key, the MySQL password for the `ninja` user, the
root password the image demands before it will initialise, and the password for the account the
first boot creates. Print none of them; keep them out of your summary and every log line.

`APP_KEY` has a shape: `base64:` followed by 32 random bytes in base64, which is what
`php artisan key:generate --show` prints. The line below makes the same thing with no container.

```bash
umask 077
cat > /srv/invoice-ninja/.env <<EOF
APP_URL=https://<DOMAIN>
APP_ENV=production
APP_DEBUG=false
REQUIRE_HTTPS=false
TRUSTED_PROXIES=*
IS_DOCKER=true
FILESYSTEM_DISK=debian_docker
CACHE_DRIVER=redis
SESSION_DRIVER=redis
QUEUE_CONNECTION=redis
REDIS_HOST=redis
DB_CONNECTION=mysql
DB_HOST=mysql
DB_DATABASE=ninja
DB_USERNAME=ninja
MAIL_MAILER=log
IN_USER_EMAIL=<ADMIN_EMAIL>
APP_KEY=base64:$(openssl rand -base64 32)
DB_PASSWORD=$(openssl rand -hex 32)
DB_ROOT_PASSWORD=$(openssl rand -hex 32)
IN_PASSWORD=$(openssl rand -hex 24)
EOF
chmod 600 /srv/invoice-ninja/.env
umask 022
ls -l /srv/invoice-ninja/.env
```

Assert: mode `-rw-------`. `APP_KEY` matters most of the four: upstream documents it as the key
that encrypts and decrypts datapoints inside the application, so a database restored beside a
different one comes back unreadable. Step 8 gets a copy off the box.

## 4. compose.yml

```bash
cat > /srv/invoice-ninja/compose.yml <<'EOF'
# Invoice Ninja · the deterministic fallback. Authored by caniselfhostit from
# the upstream documentation, not copied from a repository:
#   install .. https://invoiceninja.github.io/docs/self-host/self-host-installation
#   env vars . https://invoiceninja.github.io/docs/self-host/env-variables
#   docker ... https://github.com/invoiceninja/dockerfiles/blob/debian/README.md
#
# Four services. The app image is php-fpm under supervisord, which also runs the
# two queue workers and the scheduler, so there is no worker or cron container.
# nginx hands PHP to app:9000; Redis holds sessions, cache and the queue.
# public/ and storage/ are named volumes, not binds: the image ships its own
# public tree and chowns both to www-data. Digests read 2026-08-06, multi-arch.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  mysql:
    image: mysql:8.4.11@sha256:b3b90af2a6552ae30c266fdb7d5dd55f3afb72404bb78d37fe8a23eb857fd3fb
    restart: unless-stopped
    environment:
      MYSQL_DATABASE: ninja
      MYSQL_USER: ninja
      MYSQL_PASSWORD: ${DB_PASSWORD}
      MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}
    volumes:
      - /srv/invoice-ninja/mysql:/var/lib/mysql
    healthcheck:
      test: ["CMD-SHELL", 'mysqladmin ping -h 127.0.0.1 -u ninja -p"$$MYSQL_PASSWORD" --silent']
      interval: 10s
      retries: 30

  redis:
    image: redis:8.10.0-alpine@sha256:978f0e01593e65eed801f2402944efcd936d43b5027e4908a7897baf88ed6241
    restart: unless-stopped
    volumes:
      - /srv/invoice-ninja/redis:/data
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 10s
      retries: 30

  app:
    image: invoiceninja/invoiceninja-debian:5.13.30@sha256:3e8649be15e9fb7d76626d6ab06cd46dabc8dcba5910d77f7f7f8c885e367cac
    restart: unless-stopped
    env_file: /srv/invoice-ninja/.env
    volumes:
      - app_public:/var/www/html/public
      - app_storage:/var/www/html/storage
    depends_on:
      mysql:
        condition: service_healthy
      redis:
        condition: service_healthy
    # No `ports:` on these three: 3306, 6379 and 9000 stay inside the network.

  nginx:
    image: nginx:1.30.4-alpine@sha256:97d490c12ba55b4946b01546d1c3ed324e8d41ab1c9fcb2a616aa470620e5b46
    restart: unless-stopped
    volumes:
      - /srv/invoice-ninja/nginx:/etc/nginx/conf.d:ro
      - app_public:/var/www/html/public:ro
      - app_storage:/var/www/html/storage:ro
    depends_on:
      app:
        condition: service_started
    ports:
      # Loopback only: the host's Caddy is the only thing that reaches 8127.
      - "127.0.0.1:8127:80"

volumes:
  app_public:
  app_storage:
EOF
cd /srv/invoice-ninja && docker compose config >/dev/null && echo "compose OK"
```

Assert: `compose OK`. Upstream's own compose runs these same four and none is optional: the app
will not start without a Redis it can put sessions and jobs in.

## 5. Caddy and TLS

Append the block below with `<DOMAIN>` replaced by the real hostname. Copy first: one syntax
error takes down every other site on the box.

```bash
sudo cp /etc/caddy/Caddyfile /etc/caddy/Caddyfile.before-invoice-ninja
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# Invoice Ninja · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://invoiceninja.github.io/docs/self-host/self-host-installation and
# https://caddyserver.com/docs/automatic-https
#
# Append this to /etc/caddy/Caddyfile with <DOMAIN> replaced by the hostname
# pointed at this box. It is also APP_URL in .env, which the first boot writes
# into the company record as the client-portal domain, so it is on every
# invoice link a client opens.

<DOMAIN> {
	# A JavaScript admin bundle and a JSON API compress well; the PDFs are
	# already compressed, and Caddy leaves those alone.
	encode zstd gzip

	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		X-Frame-Options "SAMEORIGIN"
		Referrer-Policy "strict-origin-when-cross-origin"
		-Server
	}

	# 8127 is the loopback port compose publishes, not open in the firewall.
	# Caddy sets X-Forwarded-Proto, which TRUSTED_PROXIES lets the app read,
	# so its links say https.
	reverse_proxy 127.0.0.1:8127
}
EOF
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
```

Assert: both exit 0. On failure restore /etc/caddy/Caddyfile.before-invoice-ninja, reload, and
report what it said. Caddy gets the certificate on the first request and renews it itself.
`REQUIRE_HTTPS` stays false on purpose: Caddy already redirects 80 to 443, and a second redirect
inside the app is how a proxied Laravel ends in a loop.

## 6. Firewall

Two ports open, both Caddy's, idempotent on a box Prompt Zero configured:

```bash
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 443/udp
sudo ufw status verbose
```

80/tcp answers the ACME challenge and redirects, 443/tcp is the only way in, 443/udp is HTTP/3.
8127 is bound to loopback and 3306, 6379 and 9000 are never published, so none of them belongs
here. Assert: `Status: active`, rules for 80, 443/tcp and 443/udp, none for those four.

## 7. Start and verify

The first boot pulls about 3 GB of images, migrates, seeds reference data, then creates the
account from `IN_USER_EMAIL` and `IN_PASSWORD`. Until it finishes nginx answers 502, which is the
wait working, not a fault.

```bash
cd /srv/invoice-ninja
docker compose pull
docker compose up -d
for i in $(seq 1 40); do code=$(curl -sS -o /dev/null -w '%{http_code}' https://<DOMAIN>/health); echo "$i $code"; [ "$code" = 200 ] && break; sleep 15; done
curl -sS https://<DOMAIN>/health
curl -sS https://<DOMAIN>/ | grep -oE '<title>[^<]*</title>|Version: [0-9.]+'
docker compose exec -T -u www-data app php artisan tinker --execute='echo App\Models\User::where("email","admin@example.com")->count();'
```

Assert all four, printing what you got for each: the loop ends on `200`; the health body is
`{"status":"ok","message":"API is healthy"}`; the grep prints `<title>Invoice Ninja</title>` and
`Version: 5.13.30`, the running container agreeing with the pinned digest; the last command
prints `0`.

That `0` is the security assert. Upstream's account-creation command falls back to the published
address `admin@example.com` and a published password whenever it runs without both options; this
install passes both, so that account must not exist. If it prints `1`, stop and say so: a known
credential is answering on a public hostname. If any of the four misses, stop, run
`docker compose logs --tail 40 app`, and name the likely step: a 502 that never clears is the app
still migrating, a restart loop with a database error is step 3. A running container is not
success.

The first screen at https://<DOMAIN> is the sign-in form: the heading `Login`, an `Email address`
box, a `Password` box, a `Secret` box only self-hosted installs show, and
`Forgot your password?`.

STOP: tell the user to read their password with `grep IN_PASSWORD /srv/invoice-ninja/.env`, put it
in their password manager, sign in at https://<DOMAIN> as `<ADMIN_EMAIL>`, and confirm the
dashboard loads. Wait. Do not continue until they confirm. No mail leaves this install, so that
password-manager entry is the whole recovery story.

## 8. First backup and restore

Three artifacts: the database holds clients, invoices and payments, the storage archive holds
logos and PDFs, and the config archive rebuilds the service around them, `APP_KEY` included.

```bash
cd /srv/invoice-ninja
docker compose exec -T mysql sh -c 'exec mysqldump -u ninja -p"$MYSQL_PASSWORD" --single-transaction --no-tablespaces ninja' | gzip > backups/invoice-ninja-db-$(date +%F).sql.gz
docker compose exec -T app tar -czf - -C /var/www/html storage > backups/invoice-ninja-storage-$(date +%F).tar.gz
sudo tar -czf backups/invoice-ninja-config-$(date +%F).tar.gz -C /srv/invoice-ninja compose.yml .env nginx -C /etc/caddy Caddyfile
ls -lh /srv/invoice-ninja/backups/
```

Assert: all three exist and are non-empty. Print all three sizes. Nothing stops:
`--single-transaction` snapshots InnoDB consistently, and `--no-tablespaces` keeps the dump inside
the `ninja` user's privileges. A backup on the same disk is not a backup, so run this from the
user's machine:

```bash
mkdir -p ~/backups/invoice-ninja
scp vps:/srv/invoice-ninja/backups/* ~/backups/invoice-ninja/
```

To restore: `docker compose down` with no `-v`, because the volumes hold the uploads,
`sudo rm -rf /srv/invoice-ninja/mysql`, recreate it as in step 2, untar the config archive so
.env is back first, `docker compose up -d mysql`, wait for healthy, then pipe `gunzip -c` on the
`.sql.gz` into
`docker compose exec -T mysql sh -c 'exec mysql -u ninja -p"$MYSQL_PASSWORD" ninja'`,
`docker compose up -d`, and feed the storage archive into
`docker compose exec -T app tar -xzf - -C /var/www/html`. The dump and the .env travel together,
because `APP_KEY` decrypts the columns.

## 9. Updating later

New versions are at https://github.com/invoiceninja/invoiceninja/releases, and the image tag is
that tag without its leading `v`. Back up first, then edit the image line in compose.yml to the
new tag and digest:

```bash
cd /srv/invoice-ninja
docker compose pull
docker compose up -d --force-recreate
docker compose logs --tail 40 app
```

The container runs `artisan migrate --force` on the way up, so watch that log until it settles.
`--force-recreate` is there because nginx resolves `app` once at start-up and keeps the address.
Then re-run step 7's checks and confirm the version moved with the tag.

## 10. What will probably go wrong

Mail. This install sets `MAIL_MAILER=log`, upstream's own default for the container, and that
mailer never fails: it writes the message into the application log and reports success. I sent
myself a test invoice, watched a green confirmation appear, and spent twenty minutes hunting a
delivery problem that did not exist. Tell the user that emailing invoices and payment reminders
does nothing until they add a mail provider under Settings, Email Settings, and that until then
an invoice reaches a client as a PDF or a portal link they send themselves.

## 11. Out of scope

- Do not configure SMTP or set any `MAIL_` variable beyond step 3's `MAIL_MAILER=log`. Mail
  belongs in the application's settings screen, not in a container restart.
- Do not set `LICENSE_KEY` or buy the white-label licence. Removing the Invoice Ninja branding
  from client-facing pages and PDFs is a paid annual licence this install ships without.
- Do not switch the database to PostgreSQL and do not add a queue-worker or cron container.
  Upstream supports MySQL and MariaDB only, and supervisord already runs both inside the app.
- Do not set `NORDIGEN_SECRET_ID` or any payment-gateway credential. Bank feeds and card
  processing are separate accounts with separate signups.
No terminal agent? Use the chat fallback — slower, you paste the commands

For ChatGPT or Claude in a browser. The model cannot touch your server, so it hands you one command at a time and you run each one. Same install, more of your evening.

This path is slower: you paste every command yourself, and there is nobody watching the output
but you. If you can run Claude Code, use the other tab.

You are installing Invoice Ninja 5.13.30 on a VPS where Prompt Zero is done: `ssh vps` works,
Docker and Caddy are installed, the firewall is default-deny. Run everything over `ssh vps`
unless a step says otherwise, and replace `<DOMAIN>` with the hostname whose A record already
points at the box and `<ADMIN_EMAIL>` with the address you want to sign in as.

Read this before step 1. `<DOMAIN>` becomes `APP_URL`, and the first boot writes it into the
company record as your client-portal domain, so it is the address on every invoice link a client
opens. Pick the hostname you intend to keep.

## 1. Preflight

```bash
free -m | awk '/^Mem:/ {print $7 " MB available of " $2 " MB"}'
df -BG --output=avail /srv | tail -1
dpkg --print-architecture
dig +short <DOMAIN>
```

You should see: at least `3072` MB available, at least `10` G free, `amd64` or `arm64`, and your
server's IP on the last line.

If you do not: an empty last line means the A record does not exist yet. Add it, wait a minute,
and run `dig +short <DOMAIN>` again, because Caddy cannot get a certificate for a hostname that
does not resolve and failed attempts count against a rate limit you cannot see. If the memory
number is short, this is not a stack to squeeze: MySQL 8.4, php-fpm, two queue workers and a
headless Chrome that appears whenever a PDF renders all want their share. On `arm64` everything
runs, with one gap, the Saxon extension that validates e-invoice XML is built for amd64 only.

## 2. Layout

Paste the whole block at once, including the last line.

```bash
sudo install -d -m 750 -o $(id -u) -g $(id -g) /srv/invoice-ninja /srv/invoice-ninja/backups /srv/invoice-ninja/nginx
sudo install -d -m 700 /srv/invoice-ninja/mysql /srv/invoice-ninja/redis
cat > /srv/invoice-ninja/nginx/invoice-ninja.conf <<'EOF'
# Invoice Ninja · nginx for php-fpm, from https://laravel.com/docs/12.x/deployment#nginx

server {
	listen 80 default_server;
	root /var/www/html/public;
	index index.php;
	client_max_body_size 20M;

	location / {
		try_files $uri $uri/ /index.php?$query_string;
	}

	location ~ \.php$ {
		fastcgi_pass app:9000;
		fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
		include fastcgi_params;
	}
}
EOF
ls -la /srv/invoice-ninja
```

You should see: `backups` and `nginx` owned by you, and `mysql` and `redis` at mode `drwx------`
owned by root.

If you do not: leave `mysql` and `redis` owned by root on purpose. Both images chown their own
data directory the first time they start, and one you have already chowned to yourself makes
MySQL refuse to initialise. There is no `data` directory here: the application's `public` and
`storage` trees live in named volumes the image fills itself, because it rewrites `public` on
every start.

## 3. Secrets

Four secrets, all generated here on the server, all into one file only you can read: the Laravel
application key, the MySQL password for the `ninja` user, the MySQL root password the image
demands before it will initialise, and the password for the one account the first boot creates.
`APP_KEY` has a shape, `base64:` followed by 32 random bytes in base64, which is exactly what
`php artisan key:generate --show` prints inside the container. The line below makes the same
thing without needing a container yet.

Replace `<DOMAIN>` and `<ADMIN_EMAIL>` on their two lines before you paste.

```bash
umask 077
cat > /srv/invoice-ninja/.env <<EOF
APP_URL=https://<DOMAIN>
APP_ENV=production
APP_DEBUG=false
REQUIRE_HTTPS=false
TRUSTED_PROXIES=*
IS_DOCKER=true
FILESYSTEM_DISK=debian_docker
CACHE_DRIVER=redis
SESSION_DRIVER=redis
QUEUE_CONNECTION=redis
REDIS_HOST=redis
DB_CONNECTION=mysql
DB_HOST=mysql
DB_DATABASE=ninja
DB_USERNAME=ninja
MAIL_MAILER=log
IN_USER_EMAIL=<ADMIN_EMAIL>
APP_KEY=base64:$(openssl rand -base64 32)
DB_PASSWORD=$(openssl rand -hex 32)
DB_ROOT_PASSWORD=$(openssl rand -hex 32)
IN_PASSWORD=$(openssl rand -hex 24)
EOF
chmod 600 /srv/invoice-ninja/.env
umask 022
ls -l /srv/invoice-ninja/.env
```

You should see: mode `-rw-------`, your own username twice, and the path.

If you do not: a mode of `-rw-r--r--` means `umask 077` did not take effect, which happens if you
pasted the lines separately in different shells. Run `chmod 600 /srv/invoice-ninja/.env` and carry
on. If the file already existed from an earlier attempt this block has now replaced all four
values, which is fine before the database exists and a problem afterwards: MySQL keeps the
password it was created with, so a changed `DB_PASSWORD` on an existing data directory shows up
as an access-denied loop in the app log rather than as anything about passwords.

Do not paste that file, any of those four values, or any command output containing them into this
chat window. Read your account password once, after step 7, with
`grep IN_PASSWORD /srv/invoice-ninja/.env`, and put it straight into your password manager.
`APP_KEY` is the one to understand: upstream documents it as the key that encrypts and decrypts
datapoints inside the application, so a database restored beside a different key comes back with
columns nobody can read.

## 4. compose.yml

Paste the whole block at once, including the last two lines.

```bash
cat > /srv/invoice-ninja/compose.yml <<'EOF'
# Invoice Ninja · the deterministic fallback. Authored by caniselfhostit from
# the upstream documentation, not copied from a repository:
#   install .. https://invoiceninja.github.io/docs/self-host/self-host-installation
#   env vars . https://invoiceninja.github.io/docs/self-host/env-variables
#   docker ... https://github.com/invoiceninja/dockerfiles/blob/debian/README.md
#
# Four services. The app image is php-fpm under supervisord, which also runs the
# two queue workers and the scheduler, so there is no worker or cron container.
# nginx hands PHP to app:9000; Redis holds sessions, cache and the queue.
# public/ and storage/ are named volumes, not binds: the image ships its own
# public tree and chowns both to www-data. Digests read 2026-08-06, multi-arch.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  mysql:
    image: mysql:8.4.11@sha256:b3b90af2a6552ae30c266fdb7d5dd55f3afb72404bb78d37fe8a23eb857fd3fb
    restart: unless-stopped
    environment:
      MYSQL_DATABASE: ninja
      MYSQL_USER: ninja
      MYSQL_PASSWORD: ${DB_PASSWORD}
      MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}
    volumes:
      - /srv/invoice-ninja/mysql:/var/lib/mysql
    healthcheck:
      test: ["CMD-SHELL", 'mysqladmin ping -h 127.0.0.1 -u ninja -p"$$MYSQL_PASSWORD" --silent']
      interval: 10s
      retries: 30

  redis:
    image: redis:8.10.0-alpine@sha256:978f0e01593e65eed801f2402944efcd936d43b5027e4908a7897baf88ed6241
    restart: unless-stopped
    volumes:
      - /srv/invoice-ninja/redis:/data
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 10s
      retries: 30

  app:
    image: invoiceninja/invoiceninja-debian:5.13.30@sha256:3e8649be15e9fb7d76626d6ab06cd46dabc8dcba5910d77f7f7f8c885e367cac
    restart: unless-stopped
    env_file: /srv/invoice-ninja/.env
    volumes:
      - app_public:/var/www/html/public
      - app_storage:/var/www/html/storage
    depends_on:
      mysql:
        condition: service_healthy
      redis:
        condition: service_healthy
    # No `ports:` on these three: 3306, 6379 and 9000 stay inside the network.

  nginx:
    image: nginx:1.30.4-alpine@sha256:97d490c12ba55b4946b01546d1c3ed324e8d41ab1c9fcb2a616aa470620e5b46
    restart: unless-stopped
    volumes:
      - /srv/invoice-ninja/nginx:/etc/nginx/conf.d:ro
      - app_public:/var/www/html/public:ro
      - app_storage:/var/www/html/storage:ro
    depends_on:
      app:
        condition: service_started
    ports:
      # Loopback only: the host's Caddy is the only thing that reaches 8127.
      - "127.0.0.1:8127:80"

volumes:
  app_public:
  app_storage:
EOF
cd /srv/invoice-ninja && docker compose config >/dev/null && echo "compose OK"
```

You should see: `compose OK` and nothing else.

If you do not: `env file /srv/invoice-ninja/.env not found` means step 3 did not write the file.
`services must be a mapping` means the indentation was lost between the page and your terminal, so
run `rm /srv/invoice-ninja/compose.yml` and paste again in one go. None of these four services is
optional. The app reads its cache, its sessions and its job queue out of Redis and will not start
without one, nginx is the only thing here that speaks HTTP, and upstream's own compose for this
image runs the same four.

## 5. Caddy and TLS

This appends one site block to the Caddy config Prompt Zero installed. Replace `<DOMAIN>` in the
block with your hostname before you paste. The first line takes a copy, because a syntax error
here takes down every other site on the box.

```bash
sudo cp /etc/caddy/Caddyfile /etc/caddy/Caddyfile.before-invoice-ninja
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# Invoice Ninja · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://invoiceninja.github.io/docs/self-host/self-host-installation and
# https://caddyserver.com/docs/automatic-https
#
# Append this to /etc/caddy/Caddyfile with <DOMAIN> replaced by the hostname
# pointed at this box. It is also APP_URL in .env, which the first boot writes
# into the company record as the client-portal domain, so it is on every
# invoice link a client opens.

<DOMAIN> {
	# A JavaScript admin bundle and a JSON API compress well; the PDFs are
	# already compressed, and Caddy leaves those alone.
	encode zstd gzip

	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		X-Frame-Options "SAMEORIGIN"
		Referrer-Policy "strict-origin-when-cross-origin"
		-Server
	}

	# 8127 is the loopback port compose publishes, not open in the firewall.
	# Caddy sets X-Forwarded-Proto, which TRUSTED_PROXIES lets the app read,
	# so its links say https.
	reverse_proxy 127.0.0.1:8127
}
EOF
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
```

You should see: `Valid configuration` from validate, and no output at all from reload.

If you do not: run `sudo cp /etc/caddy/Caddyfile.before-invoice-ninja /etc/caddy/Caddyfile`,
reload, and paste again. `REQUIRE_HTTPS` stays false in .env on purpose: Caddy already redirects
80 to 443, and a second redirect inside the application is how a proxied Laravel install ends up
in a loop that looks like a broken certificate.

## 6. Firewall

```bash
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 443/udp
sudo ufw status verbose
```

You should see: `Status: active`, rules for `80/tcp`, `443/tcp` and `443/udp`, and no rule
mentioning `8127`, `3306`, `6379` or `9000`.

If you do not: delete anything for those four with `sudo ufw delete allow 8127`. 8127 is bound to
127.0.0.1 by the compose file and the other three are never published at all, so none of them has
a host port a firewall rule could apply to. 80/tcp is there to redirect to HTTPS and answer the
ACME challenge, 443/tcp is the only way in, and 443/udp is HTTP/3, which Caddy offers by default.
`Status: inactive` is a different problem: Prompt Zero left this firewall enabled, so something
has turned it off since, and `sudo ufw enable` puts it back before you go any further.

## 7. Start and verify

The first boot pulls about 3 GB of images, runs the Laravel migrations, seeds the reference data
and then creates your account from `IN_USER_EMAIL` and `IN_PASSWORD`. Until that finishes nginx
answers 502.

```bash
cd /srv/invoice-ninja
docker compose pull
docker compose up -d
for i in $(seq 1 40); do code=$(curl -sS -o /dev/null -w '%{http_code}' https://<DOMAIN>/health); echo "$i $code"; [ "$code" = 200 ] && break; sleep 15; done
curl -sS https://<DOMAIN>/health
curl -sS https://<DOMAIN>/ | grep -oE '<title>[^<]*</title>|Version: [0-9.]+'
docker compose exec -T -u www-data app php artisan tinker --execute='echo App\Models\User::where("email","admin@example.com")->count();'
```

You should see, in order: the loop reaching `200`, then
`{"status":"ok","message":"API is healthy"}`, then `<title>Invoice Ninja</title>` and
`Version: 5.13.30` on two lines, then `0`.

If you do not: that `0` is the one worth understanding. Upstream's account-creation command falls
back to the published address `admin@example.com` with a published password whenever it is called
without both options, and this install passes both, so that account should not exist. A `1` there
means a credential printed in public documentation can sign in to your books: stop, do not carry
on, and check that `IN_USER_EMAIL` and `IN_PASSWORD` were both set in .env before the first
start. If the loop never reaches `200`, run `docker compose logs --tail 20 mysql` first, because
a database that never reports healthy holds everything else back, then
`docker compose logs --tail 40 app`. A `Version:` line that does not say `5.13.30` means the
running container is not the digest you pinned in step 4.

The first screen at https://<DOMAIN> is the sign-in form: the heading `Login`, an `Email address`
box, a `Password` box, a `Secret` box that only self-hosted installs show, and a
`Forgot your password?` link. Read your password now with
`grep IN_PASSWORD /srv/invoice-ninja/.env`, put it in your password manager, and sign in as the
address you put in `IN_USER_EMAIL`. There is no password-reset mail on this install, so that
password-manager entry is the whole recovery story.

## 8. First backup and restore

Three artifacts. The database holds your clients, invoices and payments; the storage archive
holds uploaded logos and generated PDFs; the config archive holds what rebuilds the service
around them, `APP_KEY` included.

```bash
cd /srv/invoice-ninja
docker compose exec -T mysql sh -c 'exec mysqldump -u ninja -p"$MYSQL_PASSWORD" --single-transaction --no-tablespaces ninja' | gzip > backups/invoice-ninja-db-$(date +%F).sql.gz
docker compose exec -T app tar -czf - -C /var/www/html storage > backups/invoice-ninja-storage-$(date +%F).tar.gz
sudo tar -czf backups/invoice-ninja-config-$(date +%F).tar.gz -C /srv/invoice-ninja compose.yml .env nginx -C /etc/caddy Caddyfile
ls -lh /srv/invoice-ninja/backups/
```

You should see: three files, the database dump and the config archive a few kilobytes each on a
fresh install and the storage archive rather larger. Nothing goes offline, because
`mysqldump --single-transaction` snapshots InnoDB consistently.

If you do not: a `.sql.gz` of about 20 bytes is an empty dump, which means `mysqldump` failed and
the shell created the file anyway. Run the dump line without `| gzip` to read the error.
`Access denied` there means the password in the container's environment, set from .env when the
container was created, is not the one the database was initialised with: step 3 run twice. The
`--no-tablespaces` flag is not optional: without it `mysqldump` asks for a privilege the
`ninja` user does not have and stops.

A backup on the same disk as the data is not a backup. Run this one on your own machine, not the
server:

```bash
mkdir -p ~/backups/invoice-ninja
scp vps:/srv/invoice-ninja/backups/* ~/backups/invoice-ninja/
```

You should see: three files copied, and all three listed by `ls -lh ~/backups/invoice-ninja/`.

If you do not: `Permission denied (publickey)` means you ran it on the server. The `vps:` prefix
only means something on your own machine, where the `vps` alias Prompt Zero created lives.

Now prove the restore, today, while the only thing at risk is an empty company:

```bash
cd /srv/invoice-ninja
docker compose down
sudo rm -rf /srv/invoice-ninja/mysql
sudo install -d -m 700 /srv/invoice-ninja/mysql
docker compose up -d mysql
sleep 60
gunzip -c /srv/invoice-ninja/backups/invoice-ninja-db-$(date +%F).sql.gz | docker compose exec -T mysql sh -c 'exec mysql -u ninja -p"$MYSQL_PASSWORD" ninja'
docker compose up -d
sleep 60
curl -sS https://<DOMAIN>/health
```

You should see: no output from the restore line, then
`{"status":"ok","message":"API is healthy"}`, then your own account still signing in.

If you do not: `Unknown database 'ninja'` or `Access denied` means MySQL had not finished
initialising, so wait another minute and run the `gunzip` line again. Understand the stakes
before you skip this. Your invoices are rows in that database and the key that decrypts the
encrypted columns is `APP_KEY` in .env, so the dump and the config archive are one backup in two
files and have to travel together.

## 9. Updating later

New versions are listed at https://github.com/invoiceninja/invoiceninja/releases, and the image
tag is the release tag without its leading `v`. Take all three backup artifacts first, then edit
the `image:` line for the app in /srv/invoice-ninja/compose.yml to the new tag and its digest.

```bash
cd /srv/invoice-ninja
docker compose pull
docker compose up -d --force-recreate
docker compose logs --tail 40 app
```

You should see: migration output, then supervisord starting php-fpm and the workers, and no
repeating restart.

If you do not: put the old tag and digest back and run the same three commands. `--force-recreate`
is there because nginx resolves the `app` name once at start-up and keeps that address, so an app
container replaced underneath it produces a 502 that looks like a failed upgrade. Then re-run the
health and version checks from step 7 before you call the update done.

## 10. What will probably go wrong

Mail. This install sets `MAIL_MAILER=log`, upstream's own default for the container, and that
mailer never fails: it writes the message into the application log and reports success. I sent
myself a test invoice, watched a green confirmation appear, and spent twenty minutes hunting a
delivery problem that did not exist. Emailing invoices and payment reminders does nothing until
you add a mail provider under Settings, Email Settings, and until then an invoice reaches a client
as a PDF or a portal link you send yourself.

## 11. Out of scope

- Do not configure SMTP or set any `MAIL_` variable beyond step 3's `MAIL_MAILER=log`. Mail
  belongs in the application's settings screen, where a wrong password is one form field rather
  than a container restart.
- Do not set `LICENSE_KEY` or buy the white-label licence. Removing the Invoice Ninja branding
  from client-facing pages and PDFs is a paid annual licence this install ships without.
- Do not switch the database to PostgreSQL and do not add a queue-worker or cron container.
  Upstream supports MySQL and MariaDB only, and supervisord already runs both inside the app.
- Do not set `NORDIGEN_SECRET_ID` or any payment-gateway credential. Bank feeds and card
  processing are separate accounts with separate signups.

350 lines · 14,943 bytes

What this prompt will do
  1. Preflight
  2. Docker
  3. Layout
  4. Secrets
  5. compose.yml
  6. Nothing is public
  7. Start and verify
  8. First backup and restore
  9. Updating later
  10. What will probably go wrong
  11. Out of scope

Read out of the prompt’s own step headings at build time — if the prompt changes, this list changes with it.

paste it into Claude Code in a terminal on this computer · installs Docker Desktop if it is missing · no server, no domain

You are Claude Code on the user's own computer. There is no server and no Prompt Zero:
everything in this prompt runs on this machine and stays on it.

Run every command on this computer, in the shell you are already in. Nothing in this prompt
uses ssh.

Install Invoice Ninja 5.13.30, with the MySQL and Redis it needs, under ~/selfhost/invoice-ninja,
answering at http://localhost:8127.

## 1. Preflight

Say this first; it decides whether the user wants this install at all. Every invoice link this
creates begins with http://localhost:8127, which means "this computer" wherever it is read, so a
client sent one gets a connection error. They get a private ledger and PDFs they hand over, not a
portal a client opens. Now detect the OS and measure:

```bash
uname -s
case "$(uname -s)" in
  Darwin) vm_stat | awk '/page size/{p=$8} /free|inactive/{s+=$3} END {printf "%d MB available\n", s*p/1048576}' ;;
  Linux) . /etc/os-release && echo "$ID $VERSION_CODENAME"; free -m | awk '/^Mem:/ {print $7 " MB available of " $2 " MB"}' ;;
  MINGW*|MSYS*) powershell -Command "(Get-CimInstance Win32_OperatingSystem).FreePhysicalMemory" | awk '$1+0 {printf "%d MB available\n", $1/1024}' ;;
esac
df -h ~
```

`Darwin` is macOS, `Linux` is Linux, `MINGW` or `MSYS` is Windows under Git Bash; on Linux the
distribution ID and codename print next, for step 2. This stack wants 3072 MB of RAM available
and 10 GB free on the home disk: MySQL 8.4, php-fpm, two queue workers and the headless Chrome
that renders PDFs. All four images are multi-arch. If either number is short, print both and
stop. Do not install and hope.

## 2. Docker

Check before installing anything:

```bash
docker info >/dev/null 2>&1 && echo "docker OK" || echo "docker MISSING"
docker compose version 2>/dev/null || true
```

If that printed `docker OK` and a compose version, skip to step 3.

Otherwise, install Docker for the OS step 1 detected:

- macOS: if `command -v brew` succeeds, run `brew install --cask docker`. If there is no
  Homebrew, STOP: tell the user to download Docker Desktop from
  https://www.docker.com/products/docker-desktop/ and install it, and wait until they
  confirm. Either way, then STOP: tell the user to open Docker Desktop once, accept its
  terms, and wait for the whale icon to say it is running. Do not continue until they
  confirm.
- Windows: run `winget install -e --id Docker.DockerDesktop`. If winget is missing or the
  install fails, STOP: tell the user to download Docker Desktop from the URL above and
  install it, and wait until they confirm. Docker Desktop configures WSL 2 itself and may
  ask for a reboot; if it does, STOP and tell the user to reboot and come back, this
  prompt resumes at this step. Then STOP: have the user open Docker Desktop, accept its
  terms, and confirm it says running.
- Linux, Debian or Ubuntu: install Docker Engine from download.docker.com's apt
  repository, with its signing key saved to a file first, never piped into a shell. The
  fence is guarded, a no-op on anything but a Linux with apt:

```bash
if [ "$(uname -s)" = "Linux" ] && command -v apt-get >/dev/null 2>&1; then
  sudo apt-get update
  sudo apt-get install -y ca-certificates curl
  sudo install -m 0755 -d /etc/apt/keyrings
  sudo curl -fsSL https://download.docker.com/linux/$(. /etc/os-release && echo "$ID")/gpg -o /etc/apt/keyrings/docker.asc
  sudo chmod a+r /etc/apt/keyrings/docker.asc
  echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/$(. /etc/os-release && echo "$ID") $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | sudo tee /etc/apt/sources.list.d/docker.list >/dev/null
  sudo apt-get update
  sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin
  sudo usermod -aG docker "$USER"
fi
```

  Adding the user to the docker group is root-equivalent on this machine; say that to the
  user in one sentence, and tell them the group change lands at their next login.
- Linux, anything else: STOP. Tell the user to install Docker Engine and the compose
  plugin with their distribution's package manager, and to run this prompt again once
  `docker info` works.

Assert: `docker info` exits 0 and `docker compose version` prints a version. Do not
continue without both.

## 3. Layout

```bash
mkdir -p ~/selfhost/invoice-ninja/backups ~/selfhost/invoice-ninja/nginx
cat > ~/selfhost/invoice-ninja/nginx/invoice-ninja.conf <<'EOF'
# Invoice Ninja · nginx for php-fpm, by caniselfhostit, authored from
# https://laravel.com/docs/12.x/deployment#nginx

server {
	listen 80 default_server;
	root /var/www/html/public;
	index index.php;
	client_max_body_size 20M;

	location / {
		try_files $uri $uri/ /index.php?$query_string;
	}

	location ~ \.php$ {
		fastcgi_pass app:9000;
		fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
		include fastcgi_params;
	}
}
EOF
ls -la ~/selfhost/invoice-ninja
```

Assert: `ls -la` shows `backups` and `nginx`, owned by the user. There is no `data` folder:
invoices are rows in MySQL, and `public` and `storage` are volumes.

## 4. Secrets

Four, generated here: the Laravel application key, the MySQL password for `ninja`, the root
password the image demands before it will initialise, and the password for the account the first
boot creates. Print none of them, in chat, in your summary or in a log line. `APP_KEY` has a
shape: `base64:` and 32 random bytes in base64, what `php artisan key:generate --show` prints.

```bash
umask 077
cat > ~/selfhost/invoice-ninja/.env <<EOF
APP_URL=http://localhost:8127
APP_ENV=production
APP_DEBUG=false
REQUIRE_HTTPS=false
TRUSTED_PROXIES=*
IS_DOCKER=true
FILESYSTEM_DISK=debian_docker
CACHE_DRIVER=redis
SESSION_DRIVER=redis
QUEUE_CONNECTION=redis
REDIS_HOST=redis
DB_CONNECTION=mysql
DB_HOST=mysql
DB_DATABASE=ninja
DB_USERNAME=ninja
MAIL_MAILER=log
IN_USER_EMAIL=owner@invoice-ninja.test
APP_KEY=base64:$(openssl rand -base64 32)
DB_PASSWORD=$(openssl rand -hex 32)
DB_ROOT_PASSWORD=$(openssl rand -hex 32)
IN_PASSWORD=$(openssl rand -hex 24)
EOF
chmod 600 ~/selfhost/invoice-ninja/.env
umask 022
ls -l ~/selfhost/invoice-ninja/.env
```

Assert: mode `-rw-------`. Git Bash ships openssl, so these run the same on all three.
`owner@invoice-ninja.test` is the username the user signs in with, and nothing is sent to it. On
Windows the mode bits are advisory; the real boundary is the Windows account.

## 5. compose.yml

```bash
cat > ~/selfhost/invoice-ninja/compose.yml <<'EOF'
# Invoice Ninja · the deterministic fallback for the local path, authored by
# caniselfhostit from the upstream docs, not copied from a repository:
#   https://invoiceninja.github.io/docs/self-host/self-host-installation
#   https://github.com/invoiceninja/dockerfiles/blob/debian/README.md
#
# php-fpm under supervisord (which also runs the two queue workers and the
# scheduler), nginx in front, MySQL, Redis. Named volumes rather than binds:
# all three chown the directory they are given, which a home-directory bind
# cannot grant on Windows. Digests read 2026-08-06.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  mysql:
    image: mysql:8.4.11@sha256:b3b90af2a6552ae30c266fdb7d5dd55f3afb72404bb78d37fe8a23eb857fd3fb
    restart: unless-stopped
    environment:
      MYSQL_DATABASE: ninja
      MYSQL_USER: ninja
      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 -u ninja -p"$$MYSQL_PASSWORD" --silent']
      interval: 10s
      retries: 30

  redis:
    image: redis:8.10.0-alpine@sha256:978f0e01593e65eed801f2402944efcd936d43b5027e4908a7897baf88ed6241
    restart: unless-stopped
    volumes:
      - redis_data:/data
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 10s
      retries: 30

  app:
    image: invoiceninja/invoiceninja-debian:5.13.30@sha256:3e8649be15e9fb7d76626d6ab06cd46dabc8dcba5910d77f7f7f8c885e367cac
    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
    # No `ports:` here, on mysql or on redis: 3306, 6379 and 9000 stay inside.

  nginx:
    image: nginx:1.30.4-alpine@sha256:97d490c12ba55b4946b01546d1c3ed324e8d41ab1c9fcb2a616aa470620e5b46
    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:
        condition: service_started
    ports:
      # Loopback only: no other device can reach 8127.
      - "127.0.0.1:8127:80"

volumes:
  mysql_data:
  redis_data:
  app_public:
  app_storage:
EOF
cd ~/selfhost/invoice-ninja && docker compose config >/dev/null && echo "compose OK"
```

Assert: `compose OK`.

## 6. Nothing is public

No reverse proxy, no certificate, no firewall rule, and each is a decision: no hostname to
resolve, no public name for a certificate to attest, nothing beyond loopback for a firewall to
close. Browsers treat http://localhost as a secure context, so pages needing crypto still work.
8127 answers on this computer only, not the user's phone, not a laptop on the same wifi, not
anyone on the internet. Confirm it:

```bash
grep -n '"127.0.0.1:' ~/selfhost/invoice-ninja/compose.yml
```

Assert: one line, `- "127.0.0.1:8127:80"`. Nothing else publishes a host port.

## 7. Start and verify

The first boot pulls about 3 GB of images, migrates, seeds reference data, then makes the account
from `IN_USER_EMAIL` and `IN_PASSWORD`. Until it finishes nginx answers 502.

```bash
cd ~/selfhost/invoice-ninja
docker compose pull
docker compose up -d
for i in $(seq 1 40); do code=$(curl -sS -o /dev/null -w '%{http_code}' http://localhost:8127/health); echo "$i $code"; [ "$code" = 200 ] && break; sleep 15; done
curl -sS http://localhost:8127/health
curl -sS http://localhost:8127/ | grep -oE '<title>[^<]*</title>|Version: [0-9.]+'
docker compose exec -T -u www-data app php artisan tinker --execute='echo App\Models\User::where("email","admin@example.com")->count();'
```

Assert all four, printing what you got: the loop ends on `200`; the health body is
`{"status":"ok","message":"API is healthy"}`; the grep prints `<title>Invoice Ninja</title>` and
`Version: 5.13.30`, the container agreeing with the pinned digest; the last prints `0`. That `0`
is the security assert: upstream's account-creation command falls back to the published
`admin@example.com` and a published password when called without both options, so with both
passed that account must not exist.

If any of the four misses, stop and name the cause: connection refused means Docker Desktop is
not running, `port is already allocated` means something else holds 8127 (find it with
`lsof -nP -iTCP:8127 -sTCP:LISTEN` and stop until it is free), and otherwise
`docker compose logs --tail 40 app` shows a migration still running or a restart loop pointing at
step 4. A running container is not success.

The first screen at http://localhost:8127 is the sign-in form: the heading `Login`, an
`Email address` box, a `Password` box, a `Secret` box only self-hosted installs show, and a
`Forgot your password?` link.

STOP: tell the user to read their password with `grep IN_PASSWORD ~/selfhost/invoice-ninja/.env`,
put it in their password manager, sign in at http://localhost:8127 as `owner@invoice-ninja.test`,
and confirm the dashboard loads. Wait. Do not continue until they confirm. No mail leaves here,
so that entry is the whole recovery story.

## 8. First backup and restore

Three artifacts: the database has clients, invoices and payments, the storage archive logos and
PDFs, and the config archive what rebuilds the rest, `APP_KEY` included.

```bash
cd ~/selfhost/invoice-ninja
docker compose exec -T mysql sh -c 'exec mysqldump -u ninja -p"$MYSQL_PASSWORD" --single-transaction --no-tablespaces ninja' | gzip > backups/invoice-ninja-db-$(date +%F).sql.gz
docker compose exec -T app tar -czf - -C /var/www/html storage > backups/invoice-ninja-storage-$(date +%F).tar.gz
tar -czf backups/invoice-ninja-config-$(date +%F).tar.gz -C ~/selfhost/invoice-ninja compose.yml .env nginx
ls -lh ~/selfhost/invoice-ninja/backups/
```

Assert: all three exist and are non-empty, and print all three sizes. Nothing stops:
`--single-transaction` snapshots InnoDB consistently.

All three sit on the same disk as the data, which is not a backup: on a laptop the disk and the
machine fail together. Ask for a destination that leaves this computer, a synced folder or a USB
stick, and copy all three there with `cp`; in Git Bash a Windows drive is `/d/Backups`. Assert:
the user confirms all three are there, or say there is no backup.

To restore, in this order: `cd ~/selfhost/invoice-ninja`, untar the config archive there first so
.env is back before any container starts, `docker compose down -v` (the one place `-v` belongs,
because it drops the old volumes on purpose), `docker compose up -d mysql`, wait a minute, pipe
`gunzip -c` on the `.sql.gz` into
`docker compose exec -T mysql sh -c 'exec mysql -u ninja -p"$MYSQL_PASSWORD" ninja'`,
`docker compose up -d`, then the storage archive into
`docker compose exec -T app tar -xzf - -C /var/www/html`. The dump and the .env travel together:
`APP_KEY` decrypts the columns.

## 9. Updating later

New versions are at https://github.com/invoiceninja/invoiceninja/releases; the tag is that tag
without its `v`. Back up first, then edit the image line.

```bash
cd ~/selfhost/invoice-ninja
docker compose pull
docker compose up -d --force-recreate
docker compose logs --tail 40 app
```

The container runs `artisan migrate --force` on the way up, so watch that log until it settles.
`--force-recreate` is there because nginx resolves `app` once at start-up. Re-run step 7.

## 10. What will probably go wrong

The logo. I uploaded one, it looked right on screen, and every PDF came out with a gap where it
should have been. The reason is structural: PDFs are rendered by a headless Chrome inside the app
container, the logo is fetched by absolute URL, and that URL is http://localhost:8127, which
inside the container is the container itself. Tell the user to leave the logo off, or accept that
their PDFs will not carry it.

## 11. Out of scope

- Do not expose this to the internet.
- Do not configure port forwarding on the router.
- Do not add a reverse proxy or TLS.
- Do not rebind 8127 to 0.0.0.0 or point `APP_URL` at a LAN address, which puts a billing system
  with one password on every network the user joins.
- Do not configure SMTP, set a `MAIL_` variable beyond step 4's `MAIL_MAILER=log`, or set
  `LICENSE_KEY`, the paid annual licence that strips Invoice Ninja branding from PDFs.
compose.local.ymlthe services, pinned · local layout71 lines

authored from upstream docs, never pasted · 2,388 bytes

# Invoice Ninja · the deterministic fallback for the local path, authored by
# caniselfhostit from the upstream docs, not copied from a repository:
#   https://invoiceninja.github.io/docs/self-host/self-host-installation
#   https://github.com/invoiceninja/dockerfiles/blob/debian/README.md
#
# php-fpm under supervisord (which also runs the two queue workers and the
# scheduler), nginx in front, MySQL, Redis. Named volumes rather than binds:
# all three chown the directory they are given, which a home-directory bind
# cannot grant on Windows. Digests read 2026-08-06.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  mysql:
    image: mysql:8.4.11@sha256:b3b90af2a6552ae30c266fdb7d5dd55f3afb72404bb78d37fe8a23eb857fd3fb
    restart: unless-stopped
    environment:
      MYSQL_DATABASE: ninja
      MYSQL_USER: ninja
      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 -u ninja -p"$$MYSQL_PASSWORD" --silent']
      interval: 10s
      retries: 30

  redis:
    image: redis:8.10.0-alpine@sha256:978f0e01593e65eed801f2402944efcd936d43b5027e4908a7897baf88ed6241
    restart: unless-stopped
    volumes:
      - redis_data:/data
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 10s
      retries: 30

  app:
    image: invoiceninja/invoiceninja-debian:5.13.30@sha256:3e8649be15e9fb7d76626d6ab06cd46dabc8dcba5910d77f7f7f8c885e367cac
    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
    # No `ports:` here, on mysql or on redis: 3306, 6379 and 9000 stay inside.

  nginx:
    image: nginx:1.30.4-alpine@sha256:97d490c12ba55b4946b01546d1c3ed324e8d41ab1c9fcb2a616aa470620e5b46
    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:
        condition: service_started
    ports:
      # Loopback only: no other device can reach 8127.
      - "127.0.0.1:8127:80"

volumes:
  mysql_data:
  redis_data:
  app_public:
  app_storage:

agent-readable mirror: /self-host/freshbooks.md

The files, if you'd rather do it yourself

The cloud path with no agent involved: three files, in the order you'd use them. The cloud prompt above writes exactly these — if the two ever disagree, the files are the ones CI diffs. The local path ships its own compose file, collapsed under its own prompt.

compose.ymlthe services, pinned71 lines

authored from upstream docs, never pasted · 2,643 bytes

# Invoice Ninja · the deterministic fallback. Authored by caniselfhostit from
# the upstream documentation, not copied from a repository:
#   install .. https://invoiceninja.github.io/docs/self-host/self-host-installation
#   env vars . https://invoiceninja.github.io/docs/self-host/env-variables
#   docker ... https://github.com/invoiceninja/dockerfiles/blob/debian/README.md
#
# Four services. The app image is php-fpm under supervisord, which also runs the
# two queue workers and the scheduler, so there is no worker or cron container.
# nginx hands PHP to app:9000; Redis holds sessions, cache and the queue.
# public/ and storage/ are named volumes, not binds: the image ships its own
# public tree and chowns both to www-data. Digests read 2026-08-06, multi-arch.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  mysql:
    image: mysql:8.4.11@sha256:b3b90af2a6552ae30c266fdb7d5dd55f3afb72404bb78d37fe8a23eb857fd3fb
    restart: unless-stopped
    environment:
      MYSQL_DATABASE: ninja
      MYSQL_USER: ninja
      MYSQL_PASSWORD: ${DB_PASSWORD}
      MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}
    volumes:
      - /srv/invoice-ninja/mysql:/var/lib/mysql
    healthcheck:
      test: ["CMD-SHELL", 'mysqladmin ping -h 127.0.0.1 -u ninja -p"$$MYSQL_PASSWORD" --silent']
      interval: 10s
      retries: 30

  redis:
    image: redis:8.10.0-alpine@sha256:978f0e01593e65eed801f2402944efcd936d43b5027e4908a7897baf88ed6241
    restart: unless-stopped
    volumes:
      - /srv/invoice-ninja/redis:/data
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 10s
      retries: 30

  app:
    image: invoiceninja/invoiceninja-debian:5.13.30@sha256:3e8649be15e9fb7d76626d6ab06cd46dabc8dcba5910d77f7f7f8c885e367cac
    restart: unless-stopped
    env_file: /srv/invoice-ninja/.env
    volumes:
      - app_public:/var/www/html/public
      - app_storage:/var/www/html/storage
    depends_on:
      mysql:
        condition: service_healthy
      redis:
        condition: service_healthy
    # No `ports:` on these three: 3306, 6379 and 9000 stay inside the network.

  nginx:
    image: nginx:1.30.4-alpine@sha256:97d490c12ba55b4946b01546d1c3ed324e8d41ab1c9fcb2a616aa470620e5b46
    restart: unless-stopped
    volumes:
      - /srv/invoice-ninja/nginx:/etc/nginx/conf.d:ro
      - app_public:/var/www/html/public:ro
      - app_storage:/var/www/html/storage:ro
    depends_on:
      app:
        condition: service_started
    ports:
      # Loopback only: the host's Caddy is the only thing that reaches 8127.
      - "127.0.0.1:8127:80"

volumes:
  app_public:
  app_storage:
Caddyfilethe hostname and TLS29 lines

authored from upstream docs, never pasted · 1,049 bytes

# Invoice Ninja · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://invoiceninja.github.io/docs/self-host/self-host-installation and
# https://caddyserver.com/docs/automatic-https
#
# Append this to /etc/caddy/Caddyfile with <DOMAIN> replaced by the hostname
# pointed at this box. It is also APP_URL in .env, which the first boot writes
# into the company record as the client-portal domain, so it is on every
# invoice link a client opens.

<DOMAIN> {
	# A JavaScript admin bundle and a JSON API compress well; the PDFs are
	# already compressed, and Caddy leaves those alone.
	encode zstd gzip

	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		X-Frame-Options "SAMEORIGIN"
		Referrer-Policy "strict-origin-when-cross-origin"
		-Server
	}

	# 8127 is the loopback port compose publishes, not open in the firewall.
	# Caddy sets X-Forwarded-Proto, which TRUSTED_PROXIES lets the app read,
	# so its links say https.
	reverse_proxy 127.0.0.1:8127
}
install.shthe same install, no agent202 lines

authored from upstream docs, never pasted · 8,683 bytes

#!/usr/bin/env bash
# Invoice Ninja · the agent-free install.
#
# Everything prompt.md tells an agent to do, as a script you can read first.
# Run it on the VPS, as a non-root user who is in the docker group:
#
#   DOMAIN_HOST=billing.example.com ADMIN_EMAIL=you@example.com ./install.sh
#
# Authored by caniselfhostit from the upstream documentation:
#   https://invoiceninja.github.io/docs/self-host/self-host-installation
#   https://invoiceninja.github.io/docs/self-host/env-variables
#   https://github.com/invoiceninja/dockerfiles/blob/debian/README.md
#   https://github.com/invoiceninja/dockerfiles/blob/debian/debian/Dockerfile
#
# Four secrets are generated here, on this machine: the Laravel application key,
# the MySQL password, the MySQL root password and the password for the one
# account the first boot creates. All four go into /srv/invoice-ninja/.env with
# mode 600 and none of them is ever printed.
#
# DOMAIN_HOST is also APP_URL, which the first boot writes into the company
# record as the client-portal domain. Choose it once.
#
# NOT YET VERIFIED: no harness run has been recorded against this script.
set -euo pipefail

APP_DIR="${APP_DIR:-/srv/invoice-ninja}"
DOMAIN_HOST="${DOMAIN_HOST:-}"
ADMIN_EMAIL="${ADMIN_EMAIL:-}"

die() { printf 'install.sh: %s\n' "$1" >&2; exit 1; }

# --- 1. Refuse to start on a machine that is not ready -----------------------

[ -n "$DOMAIN_HOST" ] || die "set DOMAIN_HOST to the hostname you pointed at this server, e.g. billing.example.com"
[ -n "$ADMIN_EMAIL" ] || die "set ADMIN_EMAIL to the address you want to sign in as"
command -v docker >/dev/null 2>&1 || die "docker is not installed. Run Prompt Zero first."
docker compose version >/dev/null 2>&1 || die "the docker compose plugin is missing"
command -v caddy >/dev/null 2>&1 || die "caddy is not installed on the host. Run Prompt Zero first."
command -v openssl >/dev/null 2>&1 || die "openssl is not installed"

avail_mb="$(free -m | awk '/^Mem:/ {print $7}')"
[ "$avail_mb" -ge 3072 ] || die "only ${avail_mb} MB of RAM available; MySQL plus php-fpm plus a headless Chrome wants 3072 MB"
avail_gb="$(df -BG --output=avail /srv | tail -1 | tr -dc '0-9')"
[ "$avail_gb" -ge 10 ] || die "only ${avail_gb} GB free on /srv; this install wants 10 GB"

resolved="$(getent hosts "$DOMAIN_HOST" | awk '{print $1; exit}' || true)"
[ -n "$resolved" ] || die "$DOMAIN_HOST does not resolve yet. Add the A record, wait a minute, run this again."

# --- 2. Lay the files out ----------------------------------------------------
#
# mysql/ and redis/ stay owned by root at mode 700: both images chown their own
# data directory on first start. The application's public/ and storage/ trees
# are named volumes, because the image ships its own public tree and refills it
# on every start.

sudo install -d -m 750 -o "$(id -u)" -g "$(id -g)" "$APP_DIR" "$APP_DIR/backups" "$APP_DIR/nginx"
sudo install -d -m 700 "$APP_DIR/mysql" "$APP_DIR/redis"
install -m 0644 "$(dirname "$0")/compose.yml" "$APP_DIR/compose.yml"
install -m 0644 "$(dirname "$0")/Caddyfile" "$APP_DIR/Caddyfile"

cat > "$APP_DIR/nginx/invoice-ninja.conf" <<'NGINXCONF'
# Invoice Ninja · nginx for php-fpm, from https://laravel.com/docs/12.x/deployment#nginx

server {
	listen 80 default_server;
	root /var/www/html/public;
	index index.php;
	client_max_body_size 20M;

	location / {
		try_files $uri $uri/ /index.php?$query_string;
	}

	location ~ \.php$ {
		fastcgi_pass app:9000;
		fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
		include fastcgi_params;
	}
}
NGINXCONF

# --- 3. Generate the four secrets, on the server -----------------------------
#
# APP_KEY is base64: plus 32 random bytes in base64, the same shape
# `php artisan key:generate --show` prints. Read the account password later with
#   grep IN_PASSWORD /srv/invoice-ninja/.env

if [ ! -f "$APP_DIR/.env" ]; then
	umask 077
	cat > "$APP_DIR/.env" <<-ENVFILE
		APP_URL=https://${DOMAIN_HOST}
		APP_ENV=production
		APP_DEBUG=false
		REQUIRE_HTTPS=false
		TRUSTED_PROXIES=*
		IS_DOCKER=true
		FILESYSTEM_DISK=debian_docker
		CACHE_DRIVER=redis
		SESSION_DRIVER=redis
		QUEUE_CONNECTION=redis
		REDIS_HOST=redis
		DB_CONNECTION=mysql
		DB_HOST=mysql
		DB_DATABASE=ninja
		DB_USERNAME=ninja
		MAIL_MAILER=log
		IN_USER_EMAIL=${ADMIN_EMAIL}
		APP_KEY=base64:$(openssl rand -base64 32)
		DB_PASSWORD=$(openssl rand -hex 32)
		DB_ROOT_PASSWORD=$(openssl rand -hex 32)
		IN_PASSWORD=$(openssl rand -hex 24)
	ENVFILE
	chmod 600 "$APP_DIR/.env"
	umask 022
fi

cd "$APP_DIR"
docker compose config >/dev/null

# --- 4. Caddy site block, on the host ----------------------------------------

if ! sudo grep -qF "$DOMAIN_HOST {" /etc/caddy/Caddyfile; then
	sudo cp /etc/caddy/Caddyfile "/etc/caddy/Caddyfile.before-invoice-ninja"
	printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
	sed "s|<DOMAIN>|${DOMAIN_HOST}|g" "$APP_DIR/Caddyfile" | sudo tee -a /etc/caddy/Caddyfile >/dev/null
fi
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy

# --- 5. Ports: two open, and none of 8127, 3306, 6379, 9000 is one of them ---

if command -v ufw >/dev/null 2>&1; then
	echo "==> 80/tcp and 443/tcp for Caddy, 443/udp for HTTP/3; 8127, 3306, 6379 and 9000 stay closed"
	sudo ufw allow 80/tcp
	sudo ufw allow 443/tcp
	sudo ufw allow 443/udp
	sudo ufw status verbose
fi

# --- 6. Start it -------------------------------------------------------------
#
# The first boot runs the Laravel migrations, seeds the reference data and then
# creates the account from IN_USER_EMAIL and IN_PASSWORD. Until that finishes
# nginx answers 502.

docker compose pull
docker compose up -d

echo "==> waiting for https://${DOMAIN_HOST}/health"
for _ in $(seq 1 40); do
	code="$(curl -sS -o /dev/null -w '%{http_code}' "https://${DOMAIN_HOST}/health" || true)"
	[ "$code" = "200" ] && break
	sleep 15
done
[ "${code:-}" = "200" ] || die "/health answered ${code:-nothing}. Check: docker compose logs --tail 40 app"

curl -sS "https://${DOMAIN_HOST}/health" | grep -q '"status":"ok","message":"API is healthy"' \
	|| die "/health answered 200 without the documented body. Check: docker compose logs --tail 40 app"

home="$(curl -sS "https://${DOMAIN_HOST}/")"
printf '%s' "$home" | grep -q '<title>Invoice Ninja</title>' \
	|| die "the first screen is not Invoice Ninja. Check: docker compose logs --tail 40 nginx"
printf '%s' "$home" | grep -q 'Version: 5.13.30' \
	|| die "the running container does not report 5.13.30, so it is not the pinned digest"

# The account upstream's create-account command falls back to when it is called
# without --email and --password must not exist: this install passes both.
shipped="$(docker compose exec -T -u www-data app php artisan tinker \
	--execute='echo App\Models\User::where("email","admin@example.com")->count();' | tr -dc '0-9')"
[ "$shipped" = "0" ] || die "the published admin@example.com account exists. Stop and investigate."

# --- 7. The first backup, before day one ends --------------------------------

STAMP="$(date +%Y%m%d-%H%M%S)"
docker compose exec -T mysql sh -c 'exec mysqldump -u ninja -p"$MYSQL_PASSWORD" --single-transaction --no-tablespaces ninja' \
	| gzip > "$APP_DIR/backups/invoice-ninja-db-${STAMP}.sql.gz"
docker compose exec -T app tar -czf - -C /var/www/html storage > "$APP_DIR/backups/invoice-ninja-storage-${STAMP}.tar.gz"
sudo tar -czf "$APP_DIR/backups/invoice-ninja-config-${STAMP}.tar.gz" -C "$APP_DIR" compose.yml .env nginx -C /etc/caddy Caddyfile
ls -lh "$APP_DIR/backups/"
[ -s "$APP_DIR/backups/invoice-ninja-db-${STAMP}.sql.gz" ] || die "the database dump is empty"
[ -s "$APP_DIR/backups/invoice-ninja-storage-${STAMP}.tar.gz" ] || die "the storage archive is empty"

cat <<-DONE

	Invoice Ninja is answering at https://${DOMAIN_HOST}

	  1. Sign in as ${ADMIN_EMAIL}. Your password is in $APP_DIR/.env, mode
	     600. Read it with
	       grep IN_PASSWORD $APP_DIR/.env
	     and put it in your password manager. It was not printed here, and
	     there is no password-reset mail on this install.
	  2. No mail is delivered. MAIL_MAILER is log, which writes messages to
	     the application log and reports success, so emailing an invoice does
	     nothing until you add a provider under Settings, Email Settings.
	     Until then a client gets a PDF or a portal link you send yourself.
	  3. First backup written to $APP_DIR/backups: a database dump, a storage
	     archive and a config archive. They are on the same disk as the data,
	     which is not a backup. Copy all three off the box tonight, and keep
	     the config archive with the dump: APP_KEY lives in .env and it is
	     what decrypts the encrypted columns.

DONE

What you're signing up for

The part a vendor's comparison page leaves out. None of it is a reason not to do this; all of it is yours the moment you cancel FreshBooks.

  • Source-available, not open source. Invoice Ninja is under the Elastic License 2.0, which is not an OSI licence: you may run it for your own business, modify it and keep the source, but you may not offer it to other people as a hosted or managed service, and you may not disable its licence-key checks. For one business billing its own clients that boundary is nowhere near you.
  • Your PDFs say Invoice Ninja until you pay. The footer on the client-facing pages and the generated PDFs is removed by a paid annual white-label licence, and the admin screens keep the branding even then. It is the one feature gate a self-hoster meets on day one.
  • Mail is a second install. This one sets MAIL_MAILER=log, upstream's own Docker default, which writes messages into a log file and reports success, so clicking Email Invoice appears to work and delivers nothing. Until you configure a provider under Settings, an invoice reaches a client as a PDF or a portal link you send yourself.
  • This is a stack: four containers, a MySQL you back up and a Redis holding sessions and the job queue. The backup is three files that travel together, because APP_KEY in .env is what decrypts the encrypted columns in the dump.
  • No bank feeds and no card processing out of the box. Both are separate accounts you sign up for and wire in yourself, and they are a large part of what an invoicing subscription is actually selling.

Where this came from

“You may not provide the software to third parties as a hosted or managed service, where the service provides users with access to any substantial set of the features or functionality of the software.”

  • The account-creation command Invoice Ninja runs on first boot falls back to the published address admin@example.com with a published password when it is called without an email and a password, which is why this install generates one and passes both. source
  • The app container runs php-fpm, two Laravel queue workers and the scheduler under one supervisord, so a self-hosted install needs no separate worker or cron container. source
  • The image's own health check asks /health through the php-fpm socket and expects the body {"status":"ok","message":"API is healthy"}, which is the string this install asserts on. source
  • Upstream documents APP_KEY as the key used for encryption and decryption of datapoints inside the application, and TRUSTED_PROXIES as what an install behind a proxy needs set to avoid SSL errors. source
  • Removing Invoice Ninja branding from the client-facing pages and the PDFs is a paid annual white-label licence, and the admin interface keeps its branding either way. source

Questions people actually ask

Answered from this page's own data — the same numbers, in sentences.

  • Can I self-host FreshBooks?

    Not FreshBooks itself — the vendor does not ship a version you can run on your own server. What you can self-host is the job people pay it for, and the answer to that is Invoice Ninja. Invoices, quotes, expenses and a client portal on your own domain, with no cap on how many clients you bill. The install is one weekend: 4 containers behind Caddy with automatic TLS, secrets generated on the server rather than in a chat window, and a first backup taken before the agent says it is done, in about 240 minutes. The prompt on this page does it; the compose.yml, Caddyfile and install.sh below do the same install with no agent at all.

  • What replaces FreshBooks?

    Invoice Ninja. Invoices, quotes, expenses and a client portal on your own domain, with no cap on how many clients you bill. The only one here that covers the whole billing loop the way FreshBooks does: clients, quotes, invoices, recurring invoices, expenses, a client portal your customer signs into, and payment-gateway integrations you connect yourself. It is also the heaviest to run, four containers and a MySQL you own, and it is source-available under the Elastic License rather than open source, with an Invoice Ninja footer on client-facing PDFs until you buy the white-label licence. What you get for that is the end of the client cap that decides your FreshBooks tier. Also evaluated and ranked below it: Kimai. The prompt on this page installs Invoice Ninja only. Invoice Ninja is Elastic-2.0-licensed and free; nothing on this page is a hosted service we sell you.

  • What does self-hosting cost compared to FreshBooks?

    3072 MB of RAM and 10 GB of disk — the smallest tier most VPS hosts sell, about $20 a month. Invoice Ninja itself is free and Elastic-2.0-licensed; the bill is the server, plus a domain you probably already own. What you stop paying: FreshBooks Plus, $43/mo — $516 a year.

  • How hard is it really?

    ONE WEEKEND — 3–24 hours. The rule that produced that verdict: four containers. Four services still fits in a weekend, but part of that weekend is spent reading logs to work out which of the four is the one that is wrong. The tier is derived from seven countable facts about the Invoice Ninja install, not from anyone's impression of it, and the whole rubric is published on the methodology page.

  • Can I run Invoice Ninja on my own computer instead of a server?

    Yes — that is the second path in the prompt box above. "On my computer" installs the same Invoice Ninja on the machine you are sitting at: no VPS, no domain, no DNS, and nothing exposed to the internet. It checks for Docker first and installs Docker Desktop if the machine does not have it — macOS, Windows and Linux each get their own step — then binds everything to loopback, so the app answers on http://localhost and only on that computer. The catch: Every invoice link and client-portal address this creates begins with http://localhost:8127, which resolves on this computer and nowhere else, so a client you send one to gets a connection error instead of your invoice. Same discipline as the cloud path: pinned images, secrets generated on the machine, and a first backup taken before the prompt says it is done.

Content last checked 2026-08-06. Verdicts are derived from the published rubric on /methodology; corrections go through the issue tracker.