How to Deploy Invoice Ninja on a VPS
Updated Oct 2026
verified on Ubuntu 26.04 · Oct 2026Self-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.
- 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
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.
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:
- Change the password from the generated one to your own, then delete the
IN_USER_EMAILandIN_PASSWORDlines from.env. Upstream's README says they are only needed for the first start. - Set your company details, currency and invoice numbering under Settings before you create the first invoice. Changing numbering later is awkward.
- Configure outgoing email (the
MAIL_*lines in.env, which default to logging mail to a file rather than sending it), then runsudo 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. - 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.