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.

Last run on a host 8 Nov 2026 · data restored from a backup

Pinned at 5.13.53 (released 2 Nov 2026) · the newest upstream release

In the upstream images: 3 CRITICAL · 95 HIGH with a published fix — per image

To try it: Docker, nothing else. See Try it locally.

To deploy it behind the proxy: Traefik running, a domain, and SMTP credentials.

On your own machine, with no proxy, domain or certificate. From business/invoiceninja/:

Terminal window
cp .env.local.example .env.local
# The secrets ship empty; generate the ones this stack needs
APP_TAG=$(sed -n 's/^APP_TAG=//p' .env.local)
{
echo "DB_PASSWORD=$(openssl rand -base64 32 | tr -d '\n')"
echo "DB_ROOT_PASSWORD=$(openssl rand -base64 32 | tr -d '\n')"
echo "REDIS_PASSWORD=$(openssl rand -hex 32)"
echo "IN_PASSWORD=$(openssl rand -base64 24 | tr -d '\n')"
echo "APP_KEY=$(docker run --rm "invoiceninja/invoiceninja-debian:$APP_TAG" php artisan key:generate --show | tr -d '\n')"
} >> .env.local
docker compose -f docker-compose.local.yml --env-file .env.local up -d

Then open http://localhost:8080. Stop it again with the same command and down.

Invoices, quotes, reminders and payment receipts all leave the system by mail. Configure it during setup, not when the first invoice is due.

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[email protected]
  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 invoiceninja-edge \
    --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[email protected]

    MAIL_FROM_ADDRESS is the full address here, not just the part before the @. Many hosting companies block port 465 outbound; if yours does, use MAIL_PORT=587 with MAIL_ENCRYPTION=tls.

  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.

This covers what the backup needs to know about Invoice Ninja. Installing Borgmatic, the repository, the keys and the schedule are on Backup and restore.

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/docker
- /var/lib/docker/volumes/invoiceninja_app_storage/_data
mysql_databases:
- name: ninja
container: invoiceninja-mysql
username: ninja
password: "{credential file /srv/docker/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=access-vpn — 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 access-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.

Invoice Ninja runs its database migrations on start; within the v5 series updates are cumulative and need no intermediate version.

  1. Read the release notes and check the dockerfiles repository for nginx or compose changes.

  2. Back up the database, the storage volume and .secrets/app_key.txt.

  3. Raise APP_TAG in .env, then:

    Terminal window
    docker compose pull && docker compose up -d
    docker compose logs app --follow
  4. Log in and open a recent invoice — the data and its PDF should be intact.

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 whether port 465 is open from the host:

Terminal window
openssl s_client -connect smtp.example.com:465 -brief </dev/null

CONNECTION ESTABLISHED means the port is open; otherwise use MAIL_PORT=587 with MAIL_ENCRYPTION=tls. If the port is fine, read docker compose logs app | grep -i mail.