Skip to content

Invoice Ninja

Invoice Ninja issues invoices and quotes, tracks payments, and gives each client a portal to view and pay them. Four containers: the application with its scheduler and queue workers, a web server, a database, and a cache.

Verified 29 Jul 2026 against 5.13.26

Not exercised: a restore — the backup is configured but has not been rehearsed.

Needs: Traefik running, a domain, and SMTP credentials.

From business/invoiceninja/:

  1. Terminal window
    cp .env.example .env
  2. Set the domain, the timezone, and the address the application will send from:

    APP_TRAEFIK_HOST=invoice.example.com
    TZ=Europe/Vienna
    IN_USER_EMAIL=you@example.com
  3. Tell it which proxy it may believe about the client’s address. Without this the application sees every visitor as the proxy, and abuse detection then measures the wrong thing:

    Terminal window
    docker network inspect proxy-public \
    --format '{{range .IPAM.Config}}{{.Subnet}} {{end}}'

    Copy the result into TRUSTED_PROXIES, comma-separated. With IPv6 enabled there are two.

  4. Set mail. Port 465 is encrypted from the first byte; 587 starts unencrypted and switches over partway:

    MAIL_MAILER=smtp
    MAIL_HOST=smtp.example.com
    MAIL_PORT=465
    MAIL_ENCRYPTION=ssl
    MAIL_USERNAME=your-smtp-login
    MAIL_FROM_ADDRESS=invoices@example.com
  5. Create the secrets. Nothing sensitive goes in .env:

    Terminal window
    mkdir -p .secrets
    openssl rand -base64 32 | tr -d '\n' > .secrets/db_pwd.txt
    openssl rand -base64 32 | tr -d '\n' > .secrets/db_root_pwd.txt
    openssl rand -hex 32 | tr -d '\n' > .secrets/redis_pwd.txt
    openssl rand -base64 24 | tr -d '\n' > .secrets/in_pwd.txt
    printf 'your-smtp-password' > .secrets/mail_pwd.txt
    chmod 600 .secrets/*.txt

    in_pwd.txt is the first administrator’s password. It is read on the first start only — note it down, then change it in the interface.

  6. Let the application generate its own encryption key. It has to exist before the first start, and it must carry the base64: prefix that Laravel’s own generator produces:

    Terminal window
    docker run --rm "invoiceninja/invoiceninja-debian:$(grep '^APP_TAG=' .env | cut -d= -f2)" \
    php artisan key:generate --show | tr -d '\n' > .secrets/app_key.txt
    chmod 600 .secrets/app_key.txt

    The tag comes from the .env you copied in step 1, so the key is generated by the same image the stack will run.

  7. Start it:

    Terminal window
    docker compose up -d
    docker compose logs -f app

    The first start runs migrations and seeds the database — one to three minutes. Wait for Production setup completed, then the four supervisor programs reporting RUNNING.

Terminal window
docker compose ps
docker compose exec app supervisorctl -c /etc/supervisor/supervisord.conf status

Expected: four containers healthy, and php-fpm, both queue workers and the scheduler all RUNNING. The queue workers send the mail and the scheduler raises recurring invoices, so a stack where only php-fpm runs looks fine and quietly does half its job.

Then log in with IN_USER_EMAIL and the password from .secrets/in_pwd.txt, and change it.

Two things carry the state, and one of them is not in a volume:

Where
Database dumped through Borgmatic’s hook, not copied
Attachments, logos, generated PDFs the app_storage volume
The encryption key .secrets/app_key.txt — without it the database restores unreadable
source_directories:
- /srv/blueprint
- /var/lib/docker/volumes/invoiceninja_app_storage/_data
mysql_databases:
- name: ninja
container: invoiceninja-mysql
username: ninja
password: "{credential file /srv/blueprint/business/invoiceninja/.secrets/db_pwd.txt}"
tls: false

No quiescing hook: the dump is consistent on its own. app_public is left out — the image rebuilds it on every start.

Back up before every update. Invoice Ninja migrates its database on start, and a failed migration with no dump behind it is not recoverable.

The stack ships APP_TRAEFIK_ACCESS=acc-tailscale — reachable over your VPN only. That is deliberate for setting up, and it is also a decision you have to revisit: a client portal nobody outside can reach cannot serve clients. Switch to acc-public once the instance is configured and you have sent yourself a test invoice.

The shipped header profile sends X-Frame-Options: DENY, so the client portal cannot be embedded in someone else’s page. If you want customers to see it inside their own site, that is the setting to change — otherwise leave it.

The design preview returns an error, but invoices still arrive — the renderer is running out of shared memory. Docker gives a container 64 MB of /dev/shm and Chromium needs more; the stack sets shm_size: 512m. Invoice PDFs keep working because those are rendered by the queue worker with no request timing out behind them, which makes it look like an interface fault.

Terminal window
docker compose exec app df -h /dev/shm # expect 512M, not 64M

A 404 from the proxy, not a 403 — the web server container is unhealthy, and Traefik drops unhealthy containers from the load balancer. Check docker compose ps before looking at routing.

Mail does not leave — check the port first, as above, then docker compose logs app | grep -i mail.