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, 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 written for a Raff VM with 2 vCPU, 4 GB DDR5 RAM, 40 GB NVMe storage, running Ubuntu 24.04 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.
bashsudo 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:
bashlsb_release -ds
Expected output:
textUbuntu 24.04 LTS
Minor point releases such as Ubuntu 24.04.4 LTS are acceptable.
Step 2 — Install Docker and Docker Compose
Install Docker Engine and the Docker Compose plugin from Docker’s official Ubuntu repository.
bashsudo 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:
bashsudo docker run --rm hello-world
docker compose version
Expected output includes:
textHello from Docker!
Docker Compose version v2.x.x
Step 3 — Configure DNS and firewall rules
Set your Invoice Ninja domain and verify it resolves to your Raff VM before starting Caddy.
bashread -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:
textyour.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:
bashsudo ufw allow OpenSSH
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw --force enable
sudo ufw status numbered
Expected output includes:
textStatus: 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.
bashsudo 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.
bashAPP_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.
bashread -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.
bashcat > 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.
bashcat > 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.
bashcat > 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.
bashcat > 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:
bashsudo docker compose config >/dev/null && echo "Compose config is valid"
Expected output:
textCompose config is valid
Step 5 — Start Invoice Ninja
Start the Invoice Ninja stack in detached mode.
bashcd /opt/invoice-ninja
sudo docker compose up -d
Verify the containers are running:
bashsudo docker compose ps
Expected output includes:
textinvoice-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:
bashMYSQL_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:
textmysqld is alive
PONG
Check the Caddy logs for certificate issuance:
bashsudo docker compose logs --tail=80 caddy
Expected output includes:
textcertificate obtained successfully
Verify Invoice Ninja loads over HTTPS:
bashINVOICE_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:
text200 https://billing.example.com
Step 6 — Log in to Invoice Ninja
Display the initial admin email and temporary password.
bashgrep '^IN_USER_EMAIL=' /opt/invoice-ninja/.env
sudo cat /opt/invoice-ninja/.initial_admin_password
Open the Invoice Ninja URL in your browser:
bashecho "https://$(grep '^INVOICE_NINJA_DOMAIN=' /opt/invoice-ninja/.env | cut -d= -f2)"
Log in using:
textEmail: 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:
textThe 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, create one test client named Raff Test Client.
Visible state check:
textThe 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.
bashcd /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:
bashif ! grep -E '^(IN_USER_EMAIL|IN_PASSWORD)=' /opt/invoice-ninja/.env; then
echo "Bootstrap credentials removed"
fi
Expected output:
textBootstrap credentials removed
Verify Invoice Ninja still loads:
bashINVOICE_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:
text200
Step 8 — Create the first backup
Create a first backup of the MySQL database and Invoice Ninja storage volumes before adding business data.
bashcd /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:
bashcd /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:
textinvoice-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.
bashcd /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:
text200 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:
text1. 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.
bashcd /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:
bashsudo 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:
bashINVOICE_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:
bashcd /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:
bashcd /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:
bashcd /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:
textmysqld 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:
bashcd /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:
textninja
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.
bashcd /opt/invoice-ninja
grep '^MAIL_' .env
After editing mail settings, restart the app:
bashsudo 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

