Invoice Ninja is a source-available invoicing, quoting, project and time-tracking application built on Laravel. The self-hosted edition includes every Pro and Enterprise feature found in the hosted product, so a single RamNode VPS gives you an unlimited-client billing platform with no per-seat cost and full control of your customer and payment data.
This guide deploys the official Debian Docker image behind Caddy for automatic TLS, wires up MySQL 8 and Redis, and covers the RamNode-specific detail that trips people up: outbound email.
What you will end up with
- Invoice Ninja v5 running under Docker Compose, updated with two commands
- Automatic Let's Encrypt certificates and HTTP to HTTPS redirection
- Chrome-based PDF generation working correctly on the first invoice
- Laravel queue workers and the scheduler running under Supervisor inside the container
- Nightly database and file backups with a tested restore path
- A locked-down firewall and hardened SSH
Estimated time: 45 minutes.
Prerequisites
RamNode VPS sizing
Invoice Ninja bundles Chrome for PDF rendering. Chrome is the memory hog in this stack, not Laravel. Size accordingly.
| Workload | Recommended plan | Notes |
|---|---|---|
| Single company, under 100 invoices/month | 2 GB RAM, 1 vCPU, 30 GB SSD | Add 2 GB swap. Tight but workable. |
| Multiple companies, regular PDF and e-invoice generation | 4 GB RAM, 2 vCPU, 50 GB SSD | Recommended starting point. |
| Heavy batch invoicing, many concurrent portal users | 8 GB RAM, 4 vCPU | Raise queue worker count. |
Deploy a KVM plan rather than OpenVZ. The Chrome sandbox and Docker both behave far better under KVM.
Other requirements
- A fresh Debian 12 or Ubuntu 24.04 LTS RamNode instance
- A domain or subdomain with an A record pointing at your VPS IPv4 address, and an AAAA record if you use IPv6
- Root or sudo access
- An SMTP relay account. Read the email section below before you go further.
Set your DNS first. Caddy requests a certificate on first start. If the A record has not propagated, the request fails and you wait out a rate limit.
Read this before you start: email on RamNode
RamNode does not permit mail services on its VPS products, and outbound port 25 is blocked. This is not a problem for Invoice Ninja, but it does dictate your architecture.
Do not attempt to run Postfix, Exim, or any local MTA on this box. Instead, relay all outbound mail through a transactional email provider over port 587 or 2525:
- Postmark, Mailgun, or Amazon SES are the three providers Invoice Ninja supports natively, including webhook callbacks that mark bounces and complaints against client contacts
- Brevo, SMTP2GO, or Resend work fine over plain SMTP if you only need delivery
Verify your sending domain with the provider and publish SPF, DKIM, and DMARC records before your first invoice goes out. Invoices sent from an unauthenticated domain land in spam, and clients who never see an invoice do not pay it.
The rest of this guide assumes you have SMTP host, port, username, and password from your chosen provider.
Step 1: Prepare the server
SSH in as root and bring the system current.
apt update && apt upgrade -y
apt install -y ca-certificates curl git ufw fail2banCreate a non-root user for day-to-day work.
adduser deploy
usermod -aG sudo deploy
rsync --archive --chown=deploy:deploy ~/.ssh /home/deploySet the hostname and timezone. Invoice Ninja timestamps invoices, recurring schedules, and the queue scheduler against server time.
hostnamectl set-hostname billing.example.com
timedatectl set-timezone UTCAdd swap if you provisioned a 2 GB plan. Chrome will OOM without it.
fallocate -l 2G /swapfile
chmod 600 /swapfile
mkswap /swapfile
swapon /swapfile
echo '/swapfile none swap sw 0 0' >> /etc/fstab
sysctl -w vm.swappiness=10
echo 'vm.swappiness=10' >> /etc/sysctl.confHarden SSH. Edit /etc/ssh/sshd_config:
PermitRootLogin no
PasswordAuthentication noRestart and confirm you can still get in from a second terminal before you close the first.
systemctl restart sshConfigure the firewall. Only SSH and web ports are exposed. MySQL and Redis never leave the Docker network.
ufw default deny incoming
ufw default allow outgoing
ufw allow OpenSSH
ufw allow 80/tcp
ufw allow 443/tcp
ufw --force enableStep 2: Install Docker
Install Docker Engine and the Compose plugin from the official repository. Do not use the distribution packages; they lag badly.
curl -fsSL https://get.docker.com | sh
usermod -aG docker deploy
systemctl enable --now dockerLog out and back in as deploy, then verify.
docker --version
docker compose versionCap Docker's log growth now rather than after a full disk wakes you up. Create /etc/docker/daemon.json:
{
"log-driver": "json-file",
"log-opts": {
"max-size": "10m",
"max-file": "3"
}
}sudo systemctl restart dockerStep 3: Pull the Invoice Ninja Docker files
The maintained image is invoiceninja/invoiceninja-debian. It ships NGINX, PHP-FPM 8.4, Supervisor, bundled Chrome for PDF generation, the Saxon XSLT 2 engine for e-invoice validation, and OPcache. The default branch of the dockerfiles repository is debian.
cd /opt
sudo git clone https://github.com/invoiceninja/dockerfiles.git -b debian invoiceninja
sudo chown -R deploy:deploy /opt/invoiceninja
cd /opt/invoiceninja/debianYou now have docker-compose.yml, a sample .env, the NGINX config, and the Supervisor config in front of you. The compose file defines four services: app, nginx, mysql, and redis.
Step 4: Generate an APP_KEY
Laravel encrypts stored credentials, API tokens, and payment gateway secrets with APP_KEY. Generate your own. The key shipped in the sample .env is public knowledge.
docker run --rm -it invoiceninja/invoiceninja-debian php artisan key:generate --showCopy the entire output including the base64: prefix.
Back this key up immediately, alongside your database dumps. Lose it and every encrypted value in the database becomes unrecoverable, including your gateway API keys.
Step 5: Configure the environment
Open /opt/invoiceninja/debian/.env and replace the shipped values.
nano .envSet the following, generating unique passwords for each:
# Application
APP_URL=https://billing.example.com
APP_KEY=base64:YOUR_GENERATED_KEY_HERE
APP_ENV=production
APP_DEBUG=false
REQUIRE_HTTPS=true
TRUSTED_PROXIES='*'
PDF_GENERATOR=snappdf
PHANTOMJS_PDF_GENERATION=false
IS_DOCKER=true
# Cache, queue, and session all on Redis
CACHE_DRIVER=redis
QUEUE_CONNECTION=redis
SESSION_DRIVER=redis
REDIS_HOST=redis
REDIS_PORT=6379
REDIS_PASSWORD=null
FILESYSTEM_DISK=debian_docker
SCOUT_DRIVER=null
# Database
DB_CONNECTION=mysql
DB_HOST=mysql
DB_PORT=3306
DB_DATABASE=ninja
DB_USERNAME=ninja
DB_PASSWORD=REPLACE_WITH_STRONG_PASSWORD
DB_ROOT_PASSWORD=REPLACE_WITH_DIFFERENT_STRONG_PASSWORD
# MySQL container variables must mirror the DB_ values above
MYSQL_DATABASE=ninja
MYSQL_USER=ninja
MYSQL_PASSWORD=REPLACE_WITH_STRONG_PASSWORD
MYSQL_ROOT_PASSWORD=REPLACE_WITH_DIFFERENT_STRONG_PASSWORD
# First admin account, created on first boot
IN_USER_EMAIL=admin@example.com
IN_PASSWORD=REPLACE_WITH_STRONG_PASSWORD
# Outbound mail via your relay provider
MAIL_MAILER=smtp
MAIL_HOST=smtp.postmarkapp.com
MAIL_PORT=587
MAIL_USERNAME=your_relay_username
MAIL_PASSWORD=your_relay_password
MAIL_ENCRYPTION=tls
MAIL_FROM_ADDRESS='billing@example.com'
MAIL_FROM_NAME='Example Co Billing'Three details matter more than the rest:
MYSQL_PASSWORDmust equalDB_PASSWORD, andMYSQL_ROOT_PASSWORDmust equalDB_ROOT_PASSWORD. The compose file feeds theMYSQL_*pair to the database container and theDB_*pair to the application. A mismatch produces an access-denied loop that looks like a broken image.APP_DEBUG=falseandREQUIRE_HTTPS=trueare mandatory in production. The sample file ships with debug enabled, which leaks stack traces containing environment values to anyone who triggers an error.IN_USER_EMAILandIN_PASSWORDcreate your first admin on first boot only. If you leave them unset, the container createsadmin@example.comwith the passwordchangeme!. Set them.
Lock the file down. It contains every secret in the deployment.
chmod 600 .envStep 6: Put Caddy in front
The shipped compose file binds NGINX to port 80 with no TLS. Add Caddy as a reverse proxy for automatic Let's Encrypt certificates, renewal, and HTTP redirection.
Create /opt/invoiceninja/debian/docker-compose.override.yml:
services:
nginx:
ports: !override
- "127.0.0.1:8080:80"
caddy:
image: caddy:2-alpine
restart: unless-stopped
network_mode: host
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
- caddy_config:/config
volumes:
caddy_data:
caddy_config:Binding NGINX to 127.0.0.1:8080 means the application is unreachable from the internet except through Caddy. Nothing serves plain HTTP.
Create /opt/invoiceninja/debian/Caddyfile:
billing.example.com {
reverse_proxy 127.0.0.1:8080
encode gzip
request_body {
max_size 100MB
}
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains"
X-Content-Type-Options "nosniff"
Referrer-Policy "strict-origin-when-cross-origin"
}
}Raise the upload ceiling in NGINX to match. The shipped nginx/invoiceninja.conf caps bodies at 10 MB, which rejects larger expense receipts and document uploads.
sed -i 's/client_max_body_size 10M;/client_max_body_size 100M;/' nginx/invoiceninja.conf
sed -i 's/client_body_buffer_size 10M;/client_body_buffer_size 100M;/' nginx/invoiceninja.confStep 7: Start the stack
cd /opt/invoiceninja/debian
docker compose pull
docker compose up -dFirst boot takes a few minutes. The entrypoint runs migrations, seeds the database, creates your admin account, and caches the config. Watch it happen:
docker compose logs -f appYou are looking for Production setup completed followed by Starting supervisord.... Once you see those, confirm every service is healthy:
docker compose psBrowse to https://billing.example.com and log in with the IN_USER_EMAIL and IN_PASSWORD you set.
Remove the bootstrap credentials from .env now that the account exists:
sed -i '/^IN_USER_EMAIL=/d;/^IN_PASSWORD=/d' .envStep 8: Verify PDF generation and email
These are the two things that actually break. Test both before you invoice a real client.
PDF generation. Create a test client, create an invoice, and click Download. If you get a PDF, Chrome is working. If you get an error or an empty file, check memory pressure first:
docker compose exec app free -m
docker stats --no-streamChrome needs roughly 500 MB of headroom to render. If you are on a 2 GB plan with no swap, this is your problem.
Email. Send that test invoice to an address you control. If it does not arrive:
docker compose logs app | grep -i mailConfirm your relay is not blocking on an unverified sending domain, and confirm you are on port 587 or 2525 rather than 25.
Check that the queue is draining, since Invoice Ninja dispatches email through Laravel queues:
docker compose exec app php artisan queue:monitor defaultSupervisor runs two queue workers and the scheduler inside the app container by default. You do not need a host cron entry.
Step 9: Set up backups
Your invoicing system holds your accounts receivable. Back it up as if the business depends on it, because it does.
Create /opt/invoiceninja/backup.sh:
#!/bin/bash
set -euo pipefail
BACKUP_DIR="/var/backups/invoiceninja"
STAMP=$(date +%Y%m%d-%H%M%S)
COMPOSE_DIR="/opt/invoiceninja/debian"
mkdir -p "$BACKUP_DIR"
cd "$COMPOSE_DIR"
# shellcheck disable=SC1091
source .env
# Database
docker compose exec -T mysql mysqldump \
-u root -p"${DB_ROOT_PASSWORD}" \
--single-transaction --quick --routines --triggers \
"${DB_DATABASE}" | gzip > "$BACKUP_DIR/db-$STAMP.sql.gz"
# Uploaded files and generated documents
docker run --rm \
-v debian_app_storage:/data:ro \
-v "$BACKUP_DIR":/backup \
alpine tar czf "/backup/storage-$STAMP.tar.gz" -C /data .
# Environment file, which holds APP_KEY
cp .env "$BACKUP_DIR/env-$STAMP.bak"
find "$BACKUP_DIR" -type f -mtime +14 -delete
echo "Backup complete: $STAMP"Confirm the volume name before you trust the script. Compose prefixes volumes with the project directory name, so debian_app_storage is correct for a checkout at /opt/invoiceninja/debian.
docker volume ls | grep app_storageMake it executable and schedule it:
sudo chmod +x /opt/invoiceninja/backup.sh
sudo crontab -e30 2 * * * /opt/invoiceninja/backup.sh >> /var/log/invoiceninja-backup.log 2>&1Ship the results off-box to RamNode Object Storage, Backblaze B2, or any S3-compatible target with rclone. A backup that lives only on the machine it is backing up is not a backup.
Test your restore. Untested backups fail at the worst possible moment.
gunzip < /var/backups/invoiceninja/db-TIMESTAMP.sql.gz | \
docker compose exec -T mysql mysql -u root -p"${DB_ROOT_PASSWORD}" ninjaStep 10: Updating
Invoice Ninja ships releases frequently. Back up first, every time.
cd /opt/invoiceninja/debian
/opt/invoiceninja/backup.sh
git pull
docker compose pull
docker compose up -d
docker compose logs -f appThe entrypoint runs migrations and refreshes the public asset directory automatically on start. To pin a specific release rather than tracking latest, set TAG in .env:
TAG=5.13.23Prune old images periodically so the disk does not fill:
docker image prune -afTroubleshooting
Blank white screen after login. Almost always a stale config cache or a TRUSTED_PROXIES problem behind the reverse proxy. Confirm TRUSTED_PROXIES='*' and APP_URL matches your real HTTPS URL exactly, including scheme and no trailing slash, then rebuild the cache:
docker compose exec app php artisan optimize:clear
docker compose exec app php artisan optimize
docker compose restart appMySQL access denied on first boot. Your DB_PASSWORD and MYSQL_PASSWORD do not match, or you changed the password after the data volume was created. The MySQL image only reads those variables when initializing an empty volume. Either fix the password inside the running database or destroy the volume and start clean:
docker compose down -v
docker compose up -dThat deletes all data. Only do it on a fresh install.
PDFs fail or hang. Check memory, then confirm Chrome is present:
docker compose exec app google-chrome-stable --versionEmails silently never send. Check the queue rather than the mail config. If Redis is unhealthy the jobs never run:
docker compose exec app php artisan queue:failed
docker compose logs redisClient portal links point to the wrong host. APP_URL is wrong. Fix it, then run php artisan optimize:clear followed by php artisan optimize.
Certificate issuance fails. Confirm DNS resolves to your VPS and that ports 80 and 443 are open in UFW. Read the Caddy log:
docker compose logs caddyWhere to go next
- Payment gateways. Configure Stripe, PayPal, or GoCardless under Settings, Online Payments. Gateway keys are encrypted with
APP_KEY, which is one more reason to back that key up. - Bank feeds. Set
NORDIGEN_SECRET_IDandNORDIGEN_SECRET_KEYin.envfor GoCardless bank account syncing. - Currency conversion. Add an
OPENEXCHANGE_APP_IDfor automatic daily exchange rate updates. - Delivery webhooks. Point Postmark, Mailgun, or SES callbacks at your install so bounces and complaints mark client contacts correctly and your dashboard email status stays honest.
- White labeling. A $40 per year license removes Invoice Ninja branding from client-facing pages.
- Monitoring. Add an uptime check against the login page and an alert on disk usage. Chrome plus generated PDFs plus MySQL binlogs grow faster than you expect.
Your Invoice Ninja install is now running with automatic TLS, working PDF generation, a compliant outbound mail path, and tested backups on a RamNode VPS.
