Invoicing Platform
    Self-Hosted

    Deploy Invoice Ninja on a VPS

    Self-host Invoice Ninja v5 on a RamNode VPS with Docker Compose, MySQL 8, Redis, and Caddy TLS — invoicing, quoting, time tracking, and PDF generation with all Pro and Enterprise features.

    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.

    WorkloadRecommended planNotes
    Single company, under 100 invoices/month2 GB RAM, 1 vCPU, 30 GB SSDAdd 2 GB swap. Tight but workable.
    Multiple companies, regular PDF and e-invoice generation4 GB RAM, 2 vCPU, 50 GB SSDRecommended starting point.
    Heavy batch invoicing, many concurrent portal users8 GB RAM, 4 vCPURaise 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.

    shell
    apt update && apt upgrade -y
    apt install -y ca-certificates curl git ufw fail2ban

    Create a non-root user for day-to-day work.

    shell
    adduser deploy
    usermod -aG sudo deploy
    rsync --archive --chown=deploy:deploy ~/.ssh /home/deploy

    Set the hostname and timezone. Invoice Ninja timestamps invoices, recurring schedules, and the queue scheduler against server time.

    shell
    hostnamectl set-hostname billing.example.com
    timedatectl set-timezone UTC

    Add swap if you provisioned a 2 GB plan. Chrome will OOM without it.

    shell
    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.conf

    Harden SSH. Edit /etc/ssh/sshd_config:

    shell
    PermitRootLogin no
    PasswordAuthentication no

    Restart and confirm you can still get in from a second terminal before you close the first.

    shell
    systemctl restart ssh

    Configure the firewall. Only SSH and web ports are exposed. MySQL and Redis never leave the Docker network.

    shell
    ufw default deny incoming
    ufw default allow outgoing
    ufw allow OpenSSH
    ufw allow 80/tcp
    ufw allow 443/tcp
    ufw --force enable

    Step 2: Install Docker

    Install Docker Engine and the Compose plugin from the official repository. Do not use the distribution packages; they lag badly.

    shell
    curl -fsSL https://get.docker.com | sh
    usermod -aG docker deploy
    systemctl enable --now docker

    Log out and back in as deploy, then verify.

    shell
    docker --version
    docker compose version

    Cap Docker's log growth now rather than after a full disk wakes you up. Create /etc/docker/daemon.json:

    shell
    {
      "log-driver": "json-file",
      "log-opts": {
        "max-size": "10m",
        "max-file": "3"
      }
    }
    shell
    sudo systemctl restart docker

    Step 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.

    shell
    cd /opt
    sudo git clone https://github.com/invoiceninja/dockerfiles.git -b debian invoiceninja
    sudo chown -R deploy:deploy /opt/invoiceninja
    cd /opt/invoiceninja/debian

    You 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.

    shell
    docker run --rm -it invoiceninja/invoiceninja-debian php artisan key:generate --show

    Copy 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.

    shell
    nano .env

    Set the following, generating unique passwords for each:

    shell
    # 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:

    1. MYSQL_PASSWORD must equal DB_PASSWORD, and MYSQL_ROOT_PASSWORD must equal DB_ROOT_PASSWORD. The compose file feeds the MYSQL_* pair to the database container and the DB_* pair to the application. A mismatch produces an access-denied loop that looks like a broken image.
    2. APP_DEBUG=false and REQUIRE_HTTPS=true are mandatory in production. The sample file ships with debug enabled, which leaks stack traces containing environment values to anyone who triggers an error.
    3. IN_USER_EMAIL and IN_PASSWORD create your first admin on first boot only. If you leave them unset, the container creates admin@example.com with the password changeme!. Set them.

    Lock the file down. It contains every secret in the deployment.

    shell
    chmod 600 .env

    Step 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:

    shell
    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:

    shell
    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.

    shell
    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.conf

    Step 7: Start the stack

    shell
    cd /opt/invoiceninja/debian
    docker compose pull
    docker compose up -d

    First boot takes a few minutes. The entrypoint runs migrations, seeds the database, creates your admin account, and caches the config. Watch it happen:

    shell
    docker compose logs -f app

    You are looking for Production setup completed followed by Starting supervisord.... Once you see those, confirm every service is healthy:

    shell
    docker compose ps

    Browse 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:

    shell
    sed -i '/^IN_USER_EMAIL=/d;/^IN_PASSWORD=/d' .env

    Step 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:

    shell
    docker compose exec app free -m
    docker stats --no-stream

    Chrome 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:

    shell
    docker compose logs app | grep -i mail

    Confirm 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:

    shell
    docker compose exec app php artisan queue:monitor default

    Supervisor 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:

    shell
    #!/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.

    shell
    docker volume ls | grep app_storage

    Make it executable and schedule it:

    shell
    sudo chmod +x /opt/invoiceninja/backup.sh
    sudo crontab -e
    shell
    30 2 * * * /opt/invoiceninja/backup.sh >> /var/log/invoiceninja-backup.log 2>&1

    Ship 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.

    shell
    gunzip < /var/backups/invoiceninja/db-TIMESTAMP.sql.gz | \
      docker compose exec -T mysql mysql -u root -p"${DB_ROOT_PASSWORD}" ninja

    Step 10: Updating

    Invoice Ninja ships releases frequently. Back up first, every time.

    shell
    cd /opt/invoiceninja/debian
    /opt/invoiceninja/backup.sh
    git pull
    docker compose pull
    docker compose up -d
    docker compose logs -f app

    The 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:

    shell
    TAG=5.13.23

    Prune old images periodically so the disk does not fill:

    shell
    docker image prune -af

    Troubleshooting

    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:

    shell
    docker compose exec app php artisan optimize:clear
    docker compose exec app php artisan optimize
    docker compose restart app

    MySQL 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:

    shell
    docker compose down -v
    docker compose up -d

    That deletes all data. Only do it on a fresh install.

    PDFs fail or hang. Check memory, then confirm Chrome is present:

    shell
    docker compose exec app google-chrome-stable --version

    Emails silently never send. Check the queue rather than the mail config. If Redis is unhealthy the jobs never run:

    shell
    docker compose exec app php artisan queue:failed
    docker compose logs redis

    Client 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:

    shell
    docker compose logs caddy

    Where 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_ID and NORDIGEN_SECRET_KEY in .env for GoCardless bank account syncing.
    • Currency conversion. Add an OPENEXCHANGE_APP_ID for 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.