Skip to content

How to Deploy Invoice Ninja on a VPS

Updated Oct 2026

verified on Ubuntu 26.04 · Oct 2026
We earn commissions when you shop through the links below. Full disclosure →

Self-host Invoice Ninja on a VPS — the Laravel invoicing and billing app, run from upstream's Docker Compose stack with MySQL and Redis behind Caddy HTTPS, with a restore-tested backup and a pinned-image upgrade path.

Before you start
  • A VPS with 2 GB+ RAM (the catalog's stated minimum for Invoice Ninja; the stack also runs MySQL, Redis, nginx and a headless Chrome for PDFs)
  • A fresh Ubuntu 24.04 or 26.04 server with root/sudo SSH access
  • A domain or subdomain you can point at the server
Need a box for this guide? Kamatera's free tier lets you spin one up now.Start free on Kamatera → (opens in new tab)

What Invoice Ninja is

Invoice Ninja is an invoicing and billing app for freelancers and small businesses: invoices, quotes, recurring billing, expenses, a client portal and payments through online gateways, with desktop and mobile apps that work against a self-hosted server. It is a PHP / Laravel application on MySQL and Redis, and it is rated 2 / 5 to deploy.

A note on the license, because it is not the usual one: Invoice Ninja is source-available under the Elastic License 2.0, which is not an OSI-approved open source license. You may run it for your own business and read and modify the code, but you may not offer it to others as a hosted service. According to the catalog entry, the self-hosted code includes the features that the hosted plans sell as Pro and Enterprise, and removing the Invoice Ninja branding from client-facing documents needs a paid white-label license. Check the license against your needs before you commit; the catalog's other invoicing app, Akaunting, is also source-available (BSL-1.1), not OSI open source.

This guide picks up from "you have a server" and ends with Invoice Ninja on HTTPS, a tested backup and an upgrade routine. For help choosing a provider and tier, see Best VPS for self-hosting.

Server sizing

The catalog lists 2 GB of RAM as the minimum, and its own install measurement, on a 2-vCPU test box, was roughly 1.0 GB of RAM and about 5.7 GB of disk. In this guide's verification run (a 2-vCPU, 8 GB test box, a brand-new instance, idle after setup), docker stats showed about 575 MB for the application container, 500 MB for MySQL, and under 25 MB each for Redis, nginx and Caddy, roughly 1.1 GB in total before the operating system. Real use with many invoices and PDF rendering adds to that. The disk figure is dominated by the images: the application image alone was about 4 GB on disk, because it bundles Chrome for PDF generation, and the database and public-file volumes added roughly 0.5 GB. Plan on a server with at least 20 GB of disk even for a small business.

The PDF step is the memory spike to plan for: rendering an invoice launches a headless Chrome inside the application container. If PDFs fail on a small server, memory is the first thing to check.

Prepare the server

Update the system and install the few tools this guide uses:

sudo apt-get update && sudo apt-get -y upgrade
sudo apt-get install -y curl openssl ufw dnsutils git

If you're logged in as root, create a non-root sudo user and continue as that user:

adduser deploy
usermod -aG sudo deploy

Then open only SSH and the two ports the reverse proxy needs:

sudo ufw allow OpenSSH
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw --force enable

Invoice Ninja will be reachable only through Caddy. That matters on a Docker host: a published container port goes around ufw entirely, so the override file below rebinds upstream's web port to 127.0.0.1, and Caddy becomes the only public door.

Where to host itaffiliate disclosure
Hetzner Cloudrun it on
2 vCPU · 4 GB RAM · 80 GB SSD · $23.59/mo
Get Hetzner Cloud (opens in new tab)
Kamaterafree trial
1 vCPU · 1 GB RAM · 20 GB SSD · $4.00/mo
Start free on Kamatera → (opens in new tab)
DigitalOceanalso works on
1 vCPU · 1 GB RAM · 25 GB SSD · $6.00/mo
Deploy on DigitalOcean → (opens in new tab)

Paid link — we earn a commission if you shop through it.

Install Docker

If Docker isn't on the server yet, the convenience script installs Docker Engine and the Compose plugin (the Docker and Compose on Ubuntu guide covers the details):

command -v docker >/dev/null || curl -fsSL https://get.docker.com | sudo sh
sudo usermod -aG docker "$USER"
sudo docker compose version

The group change applies on your next login; until then the commands below use sudo docker.

Install Invoice Ninja

Invoice Ninja's Docker setup lives in a separate repository, on a branch named debian. Clone it, which gives you upstream's Compose file, the nginx configuration and a sample .env:

[ -d ~/invoiceninja ] || git clone --depth 1 -b debian https://github.com/invoiceninja/dockerfiles.git ~/invoiceninja
cd ~/invoiceninja/debian
ls docker-compose.yml .env

The sample .env ships with public passwords and a public APP_KEY, so they must be replaced before the first start. This block sets your address and administrator email (this guide uses billing.example.com and you@example.com; replace both), generates fresh random secrets, pins the image version, and turns debug mode off. The guard looks for a sample password: once it is replaced, re-running the block changes nothing, which matters because regenerating APP_KEY or the database passwords on a live install would lock the application out of its own data.

cd ~/invoiceninja/debian
DOMAIN=billing.example.com
ADMIN_EMAIL=you@example.com
if grep -q '^DB_ROOT_PASSWORD=ninjaAdm1nPassword' .env; then
  DBPW=$(openssl rand -hex 16)
  DBROOT=$(openssl rand -hex 16)
  sed -i \
    -e "s|^APP_URL=.*|APP_URL=https://$DOMAIN|" \
    -e "s|^APP_KEY=.*|APP_KEY=base64:$(openssl rand -base64 32)|" \
    -e "s|^APP_DEBUG=.*|APP_DEBUG=false|" \
    -e "s|^REQUIRE_HTTPS=.*|REQUIRE_HTTPS=true|" \
    -e "s|^IN_USER_EMAIL=.*|IN_USER_EMAIL=$ADMIN_EMAIL|" \
    -e "s|^IN_PASSWORD=.*|IN_PASSWORD=$(openssl rand -hex 12)|" \
    -e "s|^DB_PASSWORD=.*|DB_PASSWORD=$DBPW|" \
    -e "s|^MYSQL_PASSWORD=.*|MYSQL_PASSWORD=$DBPW|" \
    -e "s|^DB_ROOT_PASSWORD=.*|DB_ROOT_PASSWORD=$DBROOT|" \
    -e "s|^MYSQL_ROOT_PASSWORD=.*|MYSQL_ROOT_PASSWORD=$DBROOT|" \
    .env
  printf 'COMPOSE_PROJECT_NAME=invoiceninja\nTAG=5.13.47\nDOMAIN=%s\n' "$DOMAIN" >> .env
  chmod 600 .env
fi
grep -c -E '^(APP_KEY|IN_PASSWORD|DB_PASSWORD|TAG|DOMAIN)=' .env

COMPOSE_PROJECT_NAME keeps the volume names predictable (they start with invoiceninja_), and TAG pins the application image so an upgrade is a deliberate edit.

Rather than editing upstream's Compose file, which would make later git pull updates conflict, put your changes in an override file that Compose merges automatically. It rebinds the web port to loopback, pins MySQL and Redis to explicit versions, and adds Caddy:

cd ~/invoiceninja/debian
cat > docker-compose.override.yml <<'EOF'
services:
  nginx:
    ports: !override ["127.0.0.1:8080:80"]
  mysql:
    image: mysql:8.4
  redis:
    image: redis:7-alpine
  caddy:
    image: caddy:2
    restart: unless-stopped
    ports: ["80:80", "443:443"]
    environment:
      DOMAIN: ${DOMAIN}
    volumes: ["./Caddyfile:/etc/caddy/Caddyfile:ro", "caddy_data:/data"]
    depends_on: [nginx]
volumes:
  caddy_data:
EOF
cat > Caddyfile <<'EOF'
{$DOMAIN} {
    encode zstd gzip
    reverse_proxy nginx:80
}
EOF

Start it and wait for the application to answer. The first boot runs the database migrations and creates your administrator account, and the image's health check allows a long start period, so the loop below is generous but still bounded:

cd ~/invoiceninja/debian
sudo docker compose pull
sudo docker compose up -d
code=000
for i in $(seq 1 80); do
  code=$(curl -s -o /dev/null -w '%{http_code}' -H 'X-Forwarded-Proto: https' http://127.0.0.1:8080/health)
  [ "$code" = "200" ] && break
  sleep 5
done
echo "Invoice Ninja answered HTTP $code"
sudo docker compose ps --format 'table {{.Service}}\t{{.Status}}'

HTTP 200 and every service up means the stack works. If it never answers, jump to Troubleshooting.

HTTPS + domain

Create an A record for your hostname (say billing.example.com) pointing at the server's public IP, and confirm it resolves before Caddy tries to get a certificate:

dig +short billing.example.com

Caddy is already running. It requests the certificate as soon as the name resolves and renews it on its own (see Automatic HTTPS with Caddy for how that works). Check that HTTPS answers, retrying while the certificate is issued:

. ~/invoiceninja/debian/.env
for i in $(seq 1 20); do
  out=$(curl -s -o /dev/null -w '%{http_code} %{ssl_verify_result}' "https://$DOMAIN/health") && [ "${out%% *}" = "200" ] && break
  sleep 5
done
echo "HTTPS status and certificate check: $out"

A second number of 0 means the certificate verified. APP_URL must be this exact HTTPS address, because Invoice Ninja builds every link in invoices and emails from it.

First login and hardening

The first start created your administrator from the IN_USER_EMAIL and IN_PASSWORD values in .env. Print the generated password once and store it in your password manager:

grep -E '^IN_(USER_EMAIL|PASSWORD)=' ~/invoiceninja/debian/.env

You can confirm the account works from the terminal, without a browser, by asking the login API for a token. A JSON reply with your user's details means the credentials are valid:

cd ~/invoiceninja/debian
. ./.env
curl -s -X POST "https://$DOMAIN/api/v1/login" \
  -H 'X-Requested-With: XMLHttpRequest' -H 'Content-Type: application/json' \
  -d "{\"email\":\"$IN_USER_EMAIL\",\"password\":\"$IN_PASSWORD\"}" | head -c 200; echo

The password is on the curl command line, so it is briefly visible in ps output to other users of the machine. That is acceptable on a single-admin server you just built; don't repeat the pattern on a shared host.

Then open https://billing.example.com, sign in, and:

  1. Change the password from the generated one to your own, then delete the IN_USER_EMAIL and IN_PASSWORD lines from .env. Upstream's README says they are only needed for the first start.
  2. Set your company details, currency and invoice numbering under Settings before you create the first invoice. Changing numbering later is awkward.
  3. Configure outgoing email (the MAIL_* lines in .env, which default to logging mail to a file rather than sending it), then run sudo docker compose up -d. Invoices emailed to clients depend on it. This was not tested in this guide's verification run, because it needs a live mail provider.
  4. Set up a payment gateway only after HTTPS and email work; gateways call back to your public address.

Backups

State lives in two places: the MySQL database and the app_storage volume (uploaded documents, logos and generated files). The public volume is repopulated from the image on every start, so it does not need a backup. Start with a database dump:

cd ~/invoiceninja/debian
sudo docker compose exec -T mysql sh -c \
  'mysqldump --single-transaction --no-tablespaces -u"$MYSQL_USER" -p"$MYSQL_PASSWORD" "$MYSQL_DATABASE"' \
  > ~/invoiceninja-db-$(date +%F).sql
chmod 600 ~/invoiceninja-db-$(date +%F).sql
test -s ~/invoiceninja-db-$(date +%F).sql && ls -lh ~/invoiceninja-db-*.sql

Then archive the storage volume and the configuration. The configuration archive holds .env, with the APP_KEY and database passwords, and the dump holds your clients' data, so the commands restrict everything to its owner:

cd ~/invoiceninja/debian
sudo docker run --rm -v invoiceninja_app_storage:/data -v "$HOME":/backup alpine \
  tar czf "/backup/invoiceninja-storage-$(date +%F).tar.gz" -C /data .
sudo tar czf ~/invoiceninja-config-$(date +%F).tar.gz docker-compose.override.yml Caddyfile .env
sudo chmod 600 ~/invoiceninja-storage-*.tar.gz ~/invoiceninja-config-*.tar.gz
ls -lh ~/invoiceninja-storage-*.tar.gz ~/invoiceninja-config-*.tar.gz

Copy these files off the server, to encrypted object storage or another machine, and run the set from cron. The APP_KEY matters: Invoice Ninja encrypts some stored values with it (gateway credentials, for example), so a database restored with a different key cannot read them. A backup you have never restored is a guess, so prove the dump loads. This starts a throwaway MySQL container, imports the dump and counts the users:

cd ~
sudo docker rm -f in-restore-test >/dev/null 2>&1
sudo docker run -d --name in-restore-test -e MYSQL_ROOT_PASSWORD=scratch mysql:8.4 >/dev/null
M="sudo docker exec -e MYSQL_PWD=scratch in-restore-test"
for i in $(seq 1 40); do
  $M mysqladmin ping -h 127.0.0.1 -uroot --silent && break
  sleep 3
done
$M mysql -uroot -e "CREATE DATABASE ninja"
sudo docker exec -i -e MYSQL_PWD=scratch in-restore-test mysql -uroot ninja < ~/invoiceninja-db-$(date +%F).sql
$M mysql -uroot -N -e "SELECT COUNT(*) FROM ninja.users"
sudo docker rm -f in-restore-test >/dev/null

It should print 1. The check proves the dump loads and holds your account; it does not prove the storage archive restores. For a real restore onto a new server, install Docker, clone the repository again, unpack the config archive over it, start only the database (sudo docker compose up -d mysql), pipe the dump into it with sudo docker compose exec -T mysql sh -c 'mysql -u"$MYSQL_USER" -p"$MYSQL_PASSWORD" "$MYSQL_DATABASE"', unpack the storage archive into the app_storage volume, then start everything.

Upgrades

Because the image tag is pinned, an upgrade is an edit. Check Invoice Ninja's release notes, take a backup (previous section), change TAG in .env, then pull and recreate:

cd ~/invoiceninja/debian
sudo docker compose pull
sudo docker compose up -d
sudo docker compose logs --tail 20 app

The container runs database migrations and refreshes the public volume automatically on start, and migrations are one-way, which is why the backup comes first. To take changes to upstream's Compose file, run git pull in the clone and read the diff first; your override file and .env are untracked or separate, so they survive. Keep the mysql tag pinned too; a new MySQL major is a dump-and-restore job, not a tag bump.

Troubleshooting

The site returns 502 through Caddy, or never answers. The application container has a long start period while it migrates the database. Check sudo docker compose ps and sudo docker compose logs app. A database connection error usually means .env passwords changed after the MySQL volume was created; restore the original .env, or remove the volumes if the install holds no data yet.

Links and emails use the wrong address, or the login redirects in a loop. APP_URL must be the exact public HTTPS address, with REQUIRE_HTTPS=true. Fix .env, then run sudo docker compose up -d.

PDFs fail or the page hangs on "generating". The headless Chrome inside the application container needs memory and a working network path. Check sudo docker stats for the app container, and sudo docker compose logs app for Chrome errors.

Caddy never gets a certificate. Confirm dig +short billing.example.com returns the server's IP and that your provider's network-level firewall, not just ufw, allows inbound 80 and 443. A blocked port 80 silently breaks the ACME challenge.

Recurring invoices or reminders never go out. The container runs a queue worker and a scheduler under supervisor; if they have crashed, app shows errors in its logs. Mail not being configured is the more common cause.

Verification + next steps

You're done when https://billing.example.com loads with a valid certificate, you can sign in with a password that is not the generated one, docker compose ps shows every service up after a reboot (unless-stopped brings them back), and you have restored a backup onto a scratch box at least once.

From there, add your first client and invoice, set up a payment gateway, and install the desktop or mobile app against your own address. See the Invoice Ninja entry for catalog details and Best VPS for self-hosting for the ranked server picks.

Next steps

How to self-host Invoice Ninja →Automatic HTTPS with Caddy →Run Claude Code with Ollama on Your Own VPS →Deploy Coolify on a VPS →How to Deploy Actual Budget on a VPS →How to Deploy AFFiNE on a VPS →How to Deploy AnythingLLM on a VPS →How to Deploy Appwrite on a VPS →How to Deploy Audiobookshelf on a VPS →How to Deploy Authelia on a VPS →How to Deploy authentik on a VPS →How to Deploy Baserow on a VPS →How to Deploy Beszel on a VPS →How to Deploy Bitwarden on a VPS →How to Deploy BookStack on a VPS →How to Deploy CapRover on a VPS →How to Deploy Checkmate on a VPS →How to Deploy copyparty on a VPS →How to Deploy Directus on a VPS →How to Deploy Discourse on a VPS →How to Deploy docker-mailserver on a VPS →How to Deploy Docmost on a VPS →How to Deploy Dokku on a VPS →How to Deploy Dokploy on a VPS →How to Deploy Firefly III on a VPS →How to Deploy Flarum on a VPS →How to Deploy Forgejo on a VPS →How to Deploy Gatus on a VPS →How to Deploy Ghost on a VPS →How to Deploy Ghostfolio on a VPS →How to Deploy Gitea on a VPS →How to Deploy a GitHub Actions Self-Hosted Runner on a VPS →How to Deploy GitLab on a VPS →How to Deploy GlitchTip on a VPS →How to Deploy Grafana on a VPS →How to Deploy Graylog on a VPS →How to Deploy Headscale on a VPS →How to Deploy Healthchecks on a VPS →How to Deploy Home Assistant on a VPS →How to Deploy Immich on a VPS →How to Deploy Infisical on a VPS →How to Deploy Jan on a VPS →How to Deploy Jellyfin on a VPS →How to Deploy Kanboard on a VPS →How to Deploy Karakeep on a VPS →How to Deploy Keycloak on a VPS →How to Deploy Leantime on a VPS →How to Deploy LibreChat on a VPS →How to Deploy Linkwarden on a VPS →How to Deploy LocalAI on a VPS →How to Deploy Mailcow on a VPS →How to Deploy Mailu on a VPS →How to Deploy Matomo on a VPS →How to Deploy Mattermost on a VPS →How to Deploy Meilisearch on a VPS →How to Deploy Memos on a VPS →How to Deploy a Minecraft Server on a VPS →How to Deploy n8n on a VPS →How to Deploy Navidrome on a VPS →How to Deploy NetBird on a VPS →How to Deploy Netdata on a VPS →How to Deploy Nextcloud on a VPS →How to Deploy Next.js to a VPS →How to Deploy Nginx Proxy Manager on a VPS →How to Deploy NocoDB on a VPS →How to Deploy ntfy on a VPS →How to Deploy Odoo on a VPS →How to Deploy Ollama on a VPS →How to Deploy ONLYOFFICE Docs on a VPS →How to Deploy Open WebUI on a VPS →How to Deploy OpenHands on a VPS →How to Deploy OpenObserve on a VPS →How to Deploy OpenProject on a VPS →How to Deploy Outline on a VPS →How to Deploy Pangolin on a VPS →How to Deploy Paperless-ngx on a VPS →How to Deploy Passbolt on a VPS →How to Deploy Plane on a VPS →How to Deploy Plausible Analytics on a VPS →How to Deploy Plex Media Server on a VPS →How to Deploy Pocket ID on a VPS →How to Deploy PocketBase on a VPS →How to Deploy Prometheus on a VPS →How to Deploy Psono on a VPS →How to Deploy Radarr on a VPS →How to Deploy Rocket.Chat on a VPS →How to Deploy SigNoz on a VPS →How to Deploy Sonarr on a VPS →How to Deploy Stalwart on a VPS →How to Deploy Stirling-PDF on a VPS →How to Deploy Supabase on a VPS →How to Deploy Synapse on a VPS →How to Deploy Taiga on a VPS →How to Deploy TeamPass on a VPS →How to Deploy Tinyauth on a VPS →How to Deploy Traefik on a VPS →How to Deploy Trilium on a VPS →How to Deploy Twenty CRM on a VPS →How to Deploy Umami on a VPS →How to Deploy Uptime Kuma on a VPS →How to Deploy Vaultwarden on a VPS →How to Deploy Vikunja on a VPS →How to Deploy wg-easy on a VPS →How to Deploy Wiki.js on a VPS →How to Deploy WordPress on a VPS →How to Deploy Zabbix on a VPS →How to Deploy Zitadel on a VPS →How to Deploy Zulip on a VPS →Docker & Compose on Ubuntu 26.04 →Building AI Workflows with n8n →Install Open WebUI with Ollama →Adding AI-Powered Insights to Plausible Analytics →Building AI-Powered Apps with Supabase and pgvector →

We use analytics cookies (Google Analytics, PostHog) to see which guides are useful. No ad networks, no cross-site tracking. See our privacy policy.