In this tutorial, you’ll self-host Invoice Ninja on a Raff Ubuntu 24.04 VM with Docker Compose, MySQL, Redis, Caddy automatic HTTPS, persistent storage, bootstrap user setup, and a first verified backup.
Invoice Ninja is a self-hosted invoicing platform for invoices, quotes, expenses, tasks, payments, clients, and recurring billing workflows. This tutorial deploys Invoice Ninja on Ubuntu 24.04 with Docker Compose, places it behind HTTPS, creates the first admin user, removes bootstrap credentials, creates a test client, and verifies the system before storing business data.
Raff Technologies runs over 10,000 VMs across its compute platform in Vint Hill, Virginia, on AMD EPYC hardware with NVMe storage.
Prerequisites:
- A Raff Ubuntu 24.04 VM
- SSH access with sudo privileges
- A domain or subdomain pointing to your Raff VM, for example
billing.example.com - Ports
80/tcpand443/tcpopen for HTTPS certificate issuance and web access
This tutorial was tested on a Raff VM with 2 vCPU, 4 GB DDR5 RAM, 40 GB NVMe storage, running Ubuntu 24.04.4 LTS.
Tested on Raff infrastructure by Aybars Altınyay, platform engineer and technical writer at Raff Technologies.
Step 1 — Update the Ubuntu server
Update the package index and install the base utilities used in this tutorial.
sudo apt update sudo env DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a apt upgrade -y \ -o Dpkg::Options::=--force-confdef \ -o Dpkg::Options::=--force-confold sudo apt install -y ca-certificates curl gnupg lsb-release ufw dnsutils openssl
📌 Note: If Ubuntu still asks what to do with a modified
/etc/ssh/sshd_configfile, select keep the local version currently installed. This preserves the current SSH login configuration on the Raff VM.
Verify the server is running Ubuntu 24.04:
lsb_release -ds
Expected output:
Ubuntu 24.04.4 LTS
Minor point releases such as Ubuntu 24.04 LTS or Ubuntu 24.04.x LTS are acceptable.
Step 2 — Install Docker and Docker Compose
Install Docker Engine and the Docker Compose plugin from Docker’s official Ubuntu repository.
sudo install -m 0755 -d /etc/apt/keyrings sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg \ -o /etc/apt/keyrings/docker.asc sudo chmod a+r /etc/apt/keyrings/docker.asc sudo tee /etc/apt/sources.list.d/docker.sources > /dev/null <<EOF Types: deb URIs: https://download.docker.com/linux/ubuntu Suites: $(. /etc/os-release && echo "${UBUNTU_CODENAME:-$VERSION_CODENAME}") Components: stable Architectures: $(dpkg --print-architecture) Signed-By: /etc/apt/keyrings/docker.asc EOF sudo apt update sudo apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
Verify Docker and Docker Compose are installed:
sudo docker run --rm hello-world docker compose version
Expected output includes:
Hello from Docker! Docker Compose version v5.1.3
Any current Docker Compose plugin version is acceptable.
Step 3 — Configure DNS and firewall rules
Set your Invoice Ninja domain and verify it resolves to your Raff VM before starting Caddy.
read -rp "Invoice Ninja domain, for example billing.example.com: " INVOICE_NINJA_DOMAIN read -rp "ACME email, for example admin@example.com: " ACME_EMAIL dig +short "$INVOICE_NINJA_DOMAIN"
Expected output:
your.raff.vm.ip.address
📌 Note: Use your own domain or subdomain for production. Temporary wildcard DNS services such as
sslip.ioornip.iocan hit shared certificate rate limits and are only suitable for testing.
Enable the firewall and allow only SSH, HTTP, and HTTPS:
sudo ufw allow OpenSSH sudo ufw allow 80/tcp sudo ufw allow 443/tcp sudo ufw --force enable sudo ufw status numbered
Expected output includes:
Status: active [ 1] OpenSSH ALLOW IN Anywhere [ 2] 80/tcp ALLOW IN Anywhere [ 3] 443/tcp ALLOW IN Anywhere
📌 Note: Invoice Ninja itself will not expose a public host port. Only Caddy listens publicly on ports
80and443.
Step 4 — Create the Invoice Ninja Compose stack
Create the deployment directory, Nginx configuration directory, and backup directory.
sudo mkdir -p /opt/invoice-ninja/{nginx,backups} sudo chown -R "$USER":"$USER" /opt/invoice-ninja cd /opt/invoice-ninja
Generate the application key and database credentials.
APP_KEY="$(sudo docker run --rm invoiceninja/invoiceninja-debian:latest php artisan key:generate --show)" DB_PASSWORD="$(openssl rand -hex 24)" DB_ROOT_PASSWORD="$(openssl rand -hex 24)" INITIAL_ADMIN_PASSWORD="$(openssl rand -hex 16)" printf "%s\n" "$INITIAL_ADMIN_PASSWORD" > .initial_admin_password chmod 600 .initial_admin_password
Create the .env file. Replace the admin email when prompted.
read -rp "Initial Invoice Ninja admin email, for example admin@example.com: " IN_USER_EMAIL cat > .env <<EOF APP_URL=https://${INVOICE_NINJA_DOMAIN} APP_KEY=${APP_KEY} APP_ENV=production APP_DEBUG=false REQUIRE_HTTPS=true PHANTOMJS_PDF_GENERATION=false PDF_GENERATOR=snappdf TRUSTED_PROXIES='*' CACHE_DRIVER=redis QUEUE_CONNECTION=redis SESSION_DRIVER=redis REDIS_HOST=redis REDIS_PASSWORD=null REDIS_PORT=6379 FILESYSTEM_DISK=debian_docker DB_HOST=mysql DB_PORT=3306 DB_DATABASE=ninja DB_USERNAME=ninja DB_PASSWORD=${DB_PASSWORD} DB_ROOT_PASSWORD=${DB_ROOT_PASSWORD} DB_CONNECTION=mysql IN_USER_EMAIL=${IN_USER_EMAIL} IN_PASSWORD=${INITIAL_ADMIN_PASSWORD} MAIL_MAILER=log MAIL_HOST=smtp.example.com MAIL_PORT=587 MAIL_USERNAME=null MAIL_PASSWORD=null MAIL_ENCRYPTION=null MAIL_FROM_ADDRESS=${IN_USER_EMAIL} MAIL_FROM_NAME='Invoice Ninja' MYSQL_ROOT_PASSWORD=${DB_ROOT_PASSWORD} MYSQL_USER=ninja MYSQL_PASSWORD=${DB_PASSWORD} MYSQL_DATABASE=ninja IS_DOCKER=true SCOUT_DRIVER=null ACME_EMAIL=${ACME_EMAIL} INVOICE_NINJA_DOMAIN=${INVOICE_NINJA_DOMAIN} EOF chmod 600 .env
⚠️ Warning: The
.envfile contains database credentials and the temporary bootstrap admin password. Keep it readable only by trusted administrators.
Create the Nginx configuration used by the Invoice Ninja web container.
cat > nginx/invoiceninja.conf <<'EOF' client_max_body_size 20M; client_body_buffer_size 20M; server_tokens off; fastcgi_buffers 32 16K; gzip on; gzip_comp_level 2; gzip_min_length 1M; gzip_proxied any; gzip_types *; EOF
Create the Laravel Nginx site configuration.
cat > nginx/laravel.conf <<'EOF' server { listen 80 default_server; server_name _; root /var/www/html/public; add_header X-Frame-Options "SAMEORIGIN"; add_header X-Content-Type-Options "nosniff"; index index.php; charset utf-8; location / { try_files $uri $uri/ /index.php?$query_string; } location = /favicon.ico { access_log off; log_not_found off; } location = /robots.txt { access_log off; log_not_found off; } error_page 404 /index.php; location ~ \.php$ { fastcgi_pass app:9000; fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name; include fastcgi_params; } location ~ /\.(?!well-known).* { deny all; } } EOF
Create the Docker Compose file.
cat > compose.yaml <<'EOF' services: app: image: invoiceninja/invoiceninja-debian:latest container_name: invoice-ninja-app restart: unless-stopped env_file: - ./.env volumes: - invoice_ninja_app_public:/var/www/html/public - invoice_ninja_app_storage:/var/www/html/storage depends_on: mysql: condition: service_healthy redis: condition: service_healthy networks: - invoice_ninja_net nginx: image: nginx:alpine container_name: invoice-ninja-nginx restart: unless-stopped volumes: - ./nginx:/etc/nginx/conf.d:ro - invoice_ninja_app_public:/var/www/html/public:ro - invoice_ninja_app_storage:/var/www/html/storage:ro depends_on: - app networks: - invoice_ninja_net mysql: image: mysql:8 container_name: invoice-ninja-mysql restart: unless-stopped environment: MYSQL_DATABASE: ${MYSQL_DATABASE} MYSQL_USER: ${MYSQL_USER} MYSQL_PASSWORD: ${MYSQL_PASSWORD} MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD} volumes: - invoice_ninja_mysql_data:/var/lib/mysql healthcheck: test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-uninja", "-p${MYSQL_PASSWORD}"] interval: 10s timeout: 5s retries: 10 networks: - invoice_ninja_net redis: image: redis:alpine container_name: invoice-ninja-redis restart: unless-stopped volumes: - invoice_ninja_redis_data:/data healthcheck: test: ["CMD", "redis-cli", "ping"] interval: 10s timeout: 5s retries: 10 networks: - invoice_ninja_net caddy: image: caddy:2 container_name: invoice-ninja-caddy restart: unless-stopped depends_on: - nginx ports: - "80:80" - "443:443" environment: INVOICE_NINJA_DOMAIN: ${INVOICE_NINJA_DOMAIN} ACME_EMAIL: ${ACME_EMAIL} LOG_FILE: /data/access.log volumes: - ./Caddyfile:/etc/caddy/Caddyfile:ro - invoice_ninja_caddy_data:/data - invoice_ninja_caddy_config:/config networks: - invoice_ninja_net networks: invoice_ninja_net: driver: bridge volumes: invoice_ninja_app_public: name: invoice_ninja_app_public invoice_ninja_app_storage: name: invoice_ninja_app_storage invoice_ninja_mysql_data: name: invoice_ninja_mysql_data invoice_ninja_redis_data: name: invoice_ninja_redis_data invoice_ninja_caddy_data: name: invoice_ninja_caddy_data invoice_ninja_caddy_config: name: invoice_ninja_caddy_config EOF
Create the Caddy reverse proxy configuration.
cat > Caddyfile <<'EOF' { email {$ACME_EMAIL} } {$INVOICE_NINJA_DOMAIN} { log { level INFO output file {$LOG_FILE} { roll_size 10MB roll_keep 10 } } encode zstd gzip header { Strict-Transport-Security "max-age=31536000" X-Content-Type-Options "nosniff" X-Frame-Options "SAMEORIGIN" Referrer-Policy "no-referrer" -Server } reverse_proxy nginx:80 } EOF
Validate the Compose configuration:
sudo docker compose config >/dev/null && echo "Compose config is valid"
Expected output:
Compose config is valid
Step 5 — Start Invoice Ninja
Start the Invoice Ninja stack in detached mode.
cd /opt/invoice-ninja sudo docker compose up -d
Verify the containers are running:
sudo docker compose ps
Expected output includes:
invoice-ninja-app app Up invoice-ninja-caddy caddy Up invoice-ninja-mysql mysql Up invoice-ninja-nginx nginx Up invoice-ninja-redis redis Up
Verify MySQL and Redis are ready:
MYSQL_PASSWORD="$(grep '^MYSQL_PASSWORD=' /opt/invoice-ninja/.env | cut -d= -f2-)" sudo docker compose exec -T mysql mysqladmin ping -h localhost -uninja -p"$MYSQL_PASSWORD" sudo docker compose exec -T redis redis-cli ping
Expected output:
mysqld is alive PONG
Check the Caddy logs for certificate issuance:
sudo docker compose logs --tail=80 caddy
Expected output includes:
certificate obtained successfully
Verify Invoice Ninja loads over HTTPS:
INVOICE_NINJA_DOMAIN="$(grep '^INVOICE_NINJA_DOMAIN=' /opt/invoice-ninja/.env | cut -d= -f2)" curl -L -s -o /dev/null -w '%{http_code} %{url_effective}\n' "https://$INVOICE_NINJA_DOMAIN"
Expected output includes:
200 https://billing.example.com
Step 6 — Log in to Invoice Ninja
Display the initial admin email and temporary password.
grep '^IN_USER_EMAIL=' /opt/invoice-ninja/.env sudo cat /opt/invoice-ninja/.initial_admin_password
Open the Invoice Ninja URL in your browser:
echo "https://$(grep '^INVOICE_NINJA_DOMAIN=' /opt/invoice-ninja/.env | cut -d= -f2)"
Log in using:
Email: value from IN_USER_EMAIL Password: value from /opt/invoice-ninja/.initial_admin_password
⚠️ Warning: Change the initial admin password after the first successful login. Do not use generated test credentials for real business data.
Visible state check:
The browser shows the Invoice Ninja login page over HTTPS. You can log in with the initial admin account. The Invoice Ninja dashboard loads after login.

After logging in, confirm the Invoice Ninja dashboard loads over HTTPS.

Create one test client named Raff Test Client.
Visible state check:
The Clients page shows Raff Test Client. The dashboard remains accessible over HTTPS.
Step 7 — Remove bootstrap credentials
After the first admin account works, remove the bootstrap admin variables from .env.
cd /opt/invoice-ninja sed -i '/^IN_USER_EMAIL=/d;/^IN_PASSWORD=/d' .env sudo docker compose restart app nginx
Verify the bootstrap variables are removed:
if ! grep -E '^(IN_USER_EMAIL|IN_PASSWORD)=' /opt/invoice-ninja/.env; then echo "Bootstrap credentials removed" fi
Expected output:
Bootstrap credentials removed
Verify Invoice Ninja still loads:
INVOICE_NINJA_DOMAIN="$(grep '^INVOICE_NINJA_DOMAIN=' /opt/invoice-ninja/.env | cut -d= -f2)" curl -L -s -o /dev/null -w '%{http_code} %{url_effective}\n' "https://$INVOICE_NINJA_DOMAIN"
Expected output includes:
200
Step 8 — Create the first backup
Create a first backup of the MySQL database and Invoice Ninja storage volumes before adding business data.
cd /opt/invoice-ninja STAMP="$(date +%F-%H%M%S)" DB_NAME="$(grep '^DB_DATABASE=' .env | cut -d= -f2-)" DB_USER="$(grep '^DB_USERNAME=' .env | cut -d= -f2-)" DB_PASSWORD="$(grep '^DB_PASSWORD=' .env | cut -d= -f2-)" sudo docker compose exec -T mysql mysqldump -u"$DB_USER" -p"$DB_PASSWORD" "$DB_NAME" \ | gzip > "backups/invoice-ninja-db-${STAMP}.sql.gz" sudo docker run --rm \ -v invoice_ninja_app_public:/app_public:ro \ -v invoice_ninja_app_storage:/app_storage:ro \ -v /opt/invoice-ninja/backups:/backup \ alpine:3.20 \ tar -czf "/backup/invoice-ninja-files-${STAMP}.tar.gz" -C / app_public app_storage
Verify both backup files exist and are readable:
cd /opt/invoice-ninja LATEST_DB_BACKUP="$(ls -1t backups/invoice-ninja-db-*.sql.gz | head -n 1)" LATEST_FILE_BACKUP="$(ls -1t backups/invoice-ninja-files-*.tar.gz | head -n 1)" ls -lh "$LATEST_DB_BACKUP" "$LATEST_FILE_BACKUP" gzip -t "$LATEST_DB_BACKUP" \ && echo "Database backup archive is valid" sudo tar -tzf "$LATEST_FILE_BACKUP" | head
Expected output includes:
invoice-ninja-db-YYYY-MM-DD-HHMMSS.sql.gz invoice-ninja-files-YYYY-MM-DD-HHMMSS.tar.gz Database backup archive is valid app_public/ app_storage/
⚠️ Warning: Store Invoice Ninja backups off-server. Local backups are useful for quick recovery, but production backups should also exist outside the VM.
Step 9 — Verify the complete Invoice Ninja deployment
Verify HTTPS, container status, database readiness, Redis readiness, bootstrap credential removal, and backup files.
cd /opt/invoice-ninja INVOICE_NINJA_DOMAIN="$(grep '^INVOICE_NINJA_DOMAIN=' .env | cut -d= -f2)" DB_PASSWORD="$(grep '^DB_PASSWORD=' .env | cut -d= -f2-)" echo "Checking HTTPS:" curl -L -s -o /dev/null -w '%{http_code} %{url_effective}\n' "https://$INVOICE_NINJA_DOMAIN" echo "Checking containers:" sudo docker compose ps echo "Checking MySQL:" sudo docker compose exec -T mysql mysqladmin ping -h localhost -uninja -p"$DB_PASSWORD" echo "Checking Redis:" sudo docker compose exec -T redis redis-cli ping echo "Checking bootstrap credentials:" if ! grep -E '^(IN_USER_EMAIL|IN_PASSWORD)=' .env; then echo "Bootstrap credentials removed" fi echo "Checking backups:" ls -1 backups/invoice-ninja-db-*.sql.gz backups/invoice-ninja-files-*.tar.gz | tail -n 4
Expected output includes:
200 https://billing.example.com invoice-ninja-app app Up invoice-ninja-caddy caddy Up invoice-ninja-mysql mysql Up invoice-ninja-nginx nginx Up invoice-ninja-redis redis Up mysqld is alive PONG Bootstrap credentials removed backups/invoice-ninja-db-YYYY-MM-DD-HHMMSS.sql.gz backups/invoice-ninja-files-YYYY-MM-DD-HHMMSS.tar.gz
Complete the browser verification:
1. Open https://your-invoice-ninja-domain. 2. Log in with the admin account. 3. Confirm the Invoice Ninja dashboard loads. 4. Open Clients. 5. Confirm Raff Test Client is visible. 6. Refresh the page and confirm the dashboard still loads over HTTPS.
The Invoice Ninja deployment is complete when HTTPS works, the dashboard loads, MySQL is alive, Redis responds with PONG, bootstrap credentials are removed, backup files exist, and the test client remains visible after refresh.
Cleanup (Optional)
Use this section only if you want to remove Invoice Ninja from the Raff VM.
⚠️ Warning: The following commands permanently delete the Invoice Ninja containers, MySQL data volume, Redis data volume, application storage volumes, Caddy certificate storage, configuration files, backups, and uploaded business data. Back up anything you need before proceeding.
cd /opt/invoice-ninja sudo docker compose down -v sudo rm -rf /opt/invoice-ninja
Close the firewall ports if this VM no longer hosts public web services:
sudo ufw delete allow 80/tcp sudo ufw delete allow 443/tcp sudo ufw status numbered
Expected output no longer lists 80/tcp or 443/tcp rules.
Troubleshooting
HTTPS certificate issuance fails
Cause: The domain does not point to the Raff VM, ports 80/tcp and 443/tcp are blocked, or the certificate service cannot reach Caddy.
Fix:
INVOICE_NINJA_DOMAIN="$(grep '^INVOICE_NINJA_DOMAIN=' /opt/invoice-ninja/.env | cut -d= -f2)" dig +short "$INVOICE_NINJA_DOMAIN" sudo ufw status numbered cd /opt/invoice-ninja sudo docker compose logs --tail=100 caddy
The DNS output must show the Raff VM public IP. The firewall output must allow 80/tcp and 443/tcp.
Invoice Ninja shows a 502 error
Cause: Caddy is running, but the Nginx or app container is not reachable on the Docker network.
Fix:
cd /opt/invoice-ninja sudo docker compose ps sudo docker compose logs --tail=100 nginx sudo docker compose logs --tail=100 app sudo docker compose restart app nginx caddy
Expected output from sudo docker compose ps shows invoice-ninja-app, invoice-ninja-nginx, and invoice-ninja-caddy as Up.
The login page does not accept the initial password
Cause: The initial admin user was already created, the password was changed in the UI, or the bootstrap variables were removed.
Fix:
cd /opt/invoice-ninja grep '^APP_URL=' .env sudo docker compose logs --tail=100 app
Use the password reset flow from the Invoice Ninja web interface, or restore from a known-good backup if this is a test deployment.
MySQL is unhealthy
Cause: The MySQL container is still initializing, the password values were changed after the volume was created, or the database volume is damaged.
Fix:
cd /opt/invoice-ninja sudo docker compose ps sudo docker compose logs --tail=100 mysql DB_PASSWORD="$(grep '^DB_PASSWORD=' .env | cut -d= -f2-)" sudo docker compose exec -T mysql mysqladmin ping -h localhost -uninja -p"$DB_PASSWORD"
Expected output:
mysqld is alive
⚠️ Warning: Do not delete the MySQL volume to fix a password mismatch unless you intentionally want to erase all Invoice Ninja data.
Backup creation fails
Cause: The database credentials do not match the running MySQL container, or the backup directory is missing.
Fix:
cd /opt/invoice-ninja ls -ld backups grep '^DB_' .env sudo docker compose ps sudo docker compose exec -T mysql mysql -uninja -p"$(grep '^DB_PASSWORD=' .env | cut -d= -f2-)" -e "SHOW DATABASES;"
The database list must include:
ninja
Email notifications are not sending
Cause: The tutorial uses MAIL_MAILER=log for a safe first deployment, so outbound email is not configured.
Fix:
Edit /opt/invoice-ninja/.env and replace the mail settings with your SMTP provider’s values.
cd /opt/invoice-ninja grep '^MAIL_' .env
After editing mail settings, restart the app:
sudo docker compose restart app nginx
Expected output from sudo docker compose ps shows the app and Nginx containers as Up.
Conclusion
You now have Invoice Ninja running on a Raff Ubuntu 24.04 VM with Docker Compose, MySQL, Redis, Caddy HTTPS, persistent volumes, bootstrap credentials removed, and a first verified backup created. If you haven’t deployed your Raff VM yet, you can spin one up in 60 seconds at rafftechnologies.com.
Next: How to Install Docker on Ubuntu 24.04
Related: Automate Server Backups with Cron and Rsync on Ubuntu 24.04
Guide: Cloud Server Backup Strategies
