How to make InvoiceNinja work with Double Nginx (Reverse Proxy)

Hey all,
i am new to this, so pls be nice :smiley:

I am trying to set up InvoiceNinja via Docker on my VPS, running Ubuntu x64.

This is my compose i am deploying via Portainer:

`

x-logging: &default-logging
  driver: json-file
  options:
    max-size: "10m"
    max-file: "3"

services:
  app:
    image: invoiceninja/invoiceninja:latest
    restart: unless-stopped
    env_file:
      - ./stack.env
    volumes:
      - ./stack.env:/var/www/html/.env
      - InvoiceNinja-app_public:/var/www/html/public
      - InvoiceNinja-app_storage:/var/www/html/storage
    networks:
      - app-network
    depends_on:
      mysql:
        condition: service_healthy
      redis:
        condition: service_healthy
    logging: *default-logging

  nginx:
    image: nginx:alpine
    restart: unless-stopped
    ports:
      - "8012:80"  # matches APP_URL=http://localhost:8012
    volumes:
      - InvoiceNinja-nginx:/etc/nginx/conf.d:ro
      - InvoiceNinja-app_public:/var/www/html/public:ro
      - InvoiceNinja-app_storage:/var/www/html/storage:ro
    networks:
      - app-network
    depends_on:
      - app
    logging: *default-logging

  mysql:
    image: mysql:8.0
    restart: unless-stopped
    env_file:
      - ./stack.env
    volumes:
      - InvoiceNinja-mysql_data:/var/lib/mysql
    networks:
      - app-network
    healthcheck:
      test: ["CMD-SHELL", "mysqladmin ping -h 127.0.0.1 -u$${MYSQL_USER} -p$${MYSQL_PASSWORD} || exit 1"]
      interval: 10s
      timeout: 5s
      retries: 12
      start_period: 60s
    logging: *default-logging

  redis:
    image: redis:alpine
    restart: unless-stopped
    # Your .env has REDIS_PASSWORD=null => start Redis without auth
    command: ["redis-server", "--appendonly", "yes"]
    volumes:
      - InvoiceNinja-redis_data:/data
    networks:
      - app-network
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 10s
      timeout: 5s
      retries: 10
      start_period: 20s
    logging: *default-logging

networks:
  app-network:
    driver: bridge

volumes:
  InvoiceNinja-app_public:
    external: true
  InvoiceNinja-app_storage:
    external: true
  InvoiceNinja-mysql_data:
    external: true
  InvoiceNinja-redis_data:
    external: true
  InvoiceNinja-nginx:
    external: true

In my .env file i have set the APP_URL and REQUIRE_HTTPS to true.

My problem here is how the nginx works inside the docker container itself. I am confused If I myself need to PROVIDE a nginx conf FOR the invoice ninja?! This is the first i am seeing that i am forced to give a configured nginx for the docker containers itself. In most cases, u either have it already built in the app itself, or like here, its running as seperate container in the compose BUT its already preconfigured. Here i am being forced to configure it on my own, as seen at

    volumes:
      - InvoiceNinja-nginx:/etc/nginx/conf.d:ro

My problem here is that the information i am finding are outdated partially. There is this https://www.youtube.com/watch?v=xo6a3KtLC2g which is over 3 years old and where the docker compose is not uptodate with the one here: GitHub - invoiceninja/dockerfiles: Docker files for Invoice Ninja

So can someone please tell me or provide me a nginx.conf FOR THE DOCKER HOST itself.

And the second question is my own nginx reverse proxy, which should not be a problem for me to configure once i know how the nginx itself runs in the docker host.
Thank you
Furkan

Hi,

I’m not sure but maybe this info from ChatGPT will help:

1) Compose overview (what you already have is fine)

Your app container runs PHP-FPM (port 9000). Your nginx container serves the Laravel public dir and forwards *.php to app:9000. Make sure both are on the same Docker network.

Key points:

  • nginx mounts /var/www/html/public read-only
  • nginx needs a site config (see next section)
  • No host ports need to be exposed if your outer Nginx will reverse proxy to the inner one on the Docker bridge. If you prefer to expose, your 8012:80 is fine.

2) Inner Nginx (inside the nginx container)

Create /etc/nginx/conf.d/default.conf inside the nginx container. Since you mounted an external volume at /etc/nginx/conf.d:ro, put a file named default.conf into that volume.

default.conf

# /etc/nginx/conf.d/default.conf

server {
    listen 80;
    server_name _;

    # Point to Laravel public dir
    root /var/www/html/public;
    index index.php index.html;

    # Increase for PDF uploads, etc.
    client_max_body_size 50M;

    # Real IP (so logs/laravel see client IP when behind outer proxy)
    # If your outer proxy is on 172.17.0.1 or similar, trust the docker bridge:
    set_real_ip_from 172.16.0.0/12;
    set_real_ip_from 10.0.0.0/8;
    real_ip_header X-Forwarded-For;
    real_ip_recursive on;

    # Serve static first, then fall back to index.php
    location / {
        try_files $uri $uri/ /index.php?$query_string;
    }

    # PHP handling
    location ~ \.php$ {
        # Security: do not serve PHP directly from uploads
        try_files $uri =404;

        include fastcgi_params;

        # Critical: SCRIPT_FILENAME must resolve to the *real* file
        fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
        fastcgi_param DOCUMENT_ROOT $realpath_root;

        # Match the php-fpm in the app service
        fastcgi_pass app:9000;

        fastcgi_buffers 16 16k;
        fastcgi_buffer_size 32k;
        fastcgi_read_timeout 300;
    }

    # Optional: gzip
    gzip on;
    gzip_types text/plain text/css application/json application/javascript application/xml+rss application/xml text/javascript;
}

If you still get a blank page or 502s, check the container logs and ensure the app container really exposes PHP-FPM on 9000 (that’s how the official image is set up).


3) Host / Outer Nginx (reverse proxy)

On your VPS (the host), use this server block. It terminates TLS and passes headers correctly to the inner Nginx.

/etc/nginx/sites-available/invoiceninja.conf

server {
    listen 80;
    server_name invoiceninja.example.com;
    return 301 https://$host$request_uri;
}

server {
    listen 443 ssl http2;
    server_name invoiceninja.example.com;

    # Your certs (or use certbot-managed paths)
    ssl_certificate     /etc/letsencrypt/live/invoiceninja.example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/invoiceninja.example.com/privkey.pem;

    # Proxy to inner nginx
    location / {
        proxy_pass http://127.0.0.1:8012;  # if you publish "8012:80" on the inner nginx
        # OR: proxy_pass http://<docker0-ip>:80; if not publishing host port and talking over bridge

        proxy_set_header Host              $host;
        proxy_set_header X-Real-IP         $remote_addr;
        proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;

        # Timeouts for larger uploads / PDFs
        proxy_read_timeout 300;
        proxy_connect_timeout 300;
        proxy_send_timeout 300;

        # Buffering helps with large PDFs
        proxy_buffering on;
        proxy_buffers 16 16k;
        proxy_buffer_size 32k;
    }

    # Optional: increase allowed body size here too if you proxy uploads
    client_max_body_size 50M;
}
  • If you don’t want to expose 8012 on the host, remove ports: "8012:80" from compose and instead resolve the inner Nginx container IP from the host (e.g., via docker network inspect) and change proxy_pass to that IP. The explicit host-port is simpler while you’re getting started.

4) Important .env (Invoice Ninja) settings

In your stack.env (mounted into /var/www/html/.env):

# Public URL of your site
APP_URL=https://invoiceninja.example.com

# You terminate TLS at the outer proxy:
REQUIRE_HTTPS=true
SESSION_SECURE_COOKIE=true

# Trust the proxy chain so Laravel honors X-Forwarded-Proto/For
# EITHER use a CIDR for docker bridge + your host IP ranges:
TRUSTED_PROXIES=172.16.0.0/12,10.0.0.0/8,127.0.0.1
# (As a quick test you can set TRUSTED_PROXIES=*, but CIDRs are better.)

# Redis (match your compose)
REDIS_HOST=redis
REDIS_PASSWORD=null
REDIS_PORT=6379

# DB (match your compose)
DB_HOST=mysql
DB_DATABASE=...
DB_USERNAME=...
DB_PASSWORD=...

# Upload limits (keeps PHP aligned with nginx)
PHP_MAX_EXECUTION_TIME=300
PHP_MEMORY_LIMIT=512M
PHP_UPLOAD_MAX_FILESIZE=50M
PHP_POST_MAX_SIZE=50M

Why these matter:

  • APP_URL must be https and match your public domain, otherwise you’ll see mixed-content / redirect weirdness.
  • REQUIRE_HTTPS=true is safe when the outer Nginx sends X-Forwarded-Proto https (we set that).
  • TRUSTED_PROXIES lets Laravel respect X-Forwarded-* headers so generated links use https:// and client IPs are correct.

5) Common pitfalls & fixes

  1. Redirect loop or forced http

    • Cause: APP_URL not https, missing X-Forwarded-Proto, or TRUSTED_PROXIES not set.
    • Fix: Set APP_URL=https://…, ensure outer Nginx sets X-Forwarded-Proto, and configure TRUSTED_PROXIES.
  2. Blank screen / 502 on PHP

    • Cause: inner Nginx not pointing to PHP-FPM correctly.
    • Fix: fastcgi_pass app:9000, and ensure SCRIPT_FILENAME uses $realpath_root$fastcgi_script_name.
  3. Uploads fail (PDFs/attachments)

    • Cause: low client_max_body_size (nginx) or PHP post/upload limits.
    • Fix: bump client_max_body_size on both nginx layers and set PHP_UPLOAD_MAX_FILESIZE / PHP_POST_MAX_SIZE in .env.
  4. Wrong client IPs in logs

    • Cause: not trusting proxy or not setting real IP headers.
    • Fix: set_real_ip_from in inner nginx, TRUSTED_PROXIES in .env, and proper proxy_set_header in outer nginx.

6) TL;DR checklist

  • Inner Nginx default.conf points to /var/www/html/public and fastcgi_pass app:9000.
  • Outer Nginx proxies to inner Nginx and sets Host, X-Real-IP, X-Forwarded-For, X-Forwarded-Proto.
  • .env: set APP_URL=https://…, REQUIRE_HTTPS=true, SESSION_SECURE_COOKIE=true, and TRUSTED_PROXIES to your proxy CIDRs.
  • Increase upload/body sizes on both nginx layers and PHP.

If you paste in the two nginx configs above and align the .env values, ā€œdouble-nginxā€ with Invoice Ninja should come up cleanly.

Wait i am confused, isnt there an official nginx exmaple which can be used inside the docker container?
Why do we need to rely on ChatGPT?

The official files can be found here:

I’m not sure if there’s an official example with a reverse proxy, just trying to help in an area I have less experience.

I am aware of that, but there is no nginx conf file, or am i blind?
pls show me
thanks

Have you seen these files:

I did,
but i still get
2025-09-25T20:25:32.253932426Z 2025/09/25 20:25:32 [error] 21#21: *7 directory index of "/var/www/html/public/" is forbidden, client: 172.19.0.1

This docker compose setup is so broken, im wondering how other people do it. If they troubleshoot all errors theirselfes…

Maybe this will help:

Dont see its directly related to mine… but close…

I will give up on this, tool looked cool, but the fact the docker compose being supplied on the official sites are not even fully uptodate and have ton of issues about permissions, nginx confs, i dont know if its worth to think about this,
If the devs wanna update their docker compose and maybe make a built in nginx without providing the conf file on ur own (still the weirdest thing i ever heard), i am out.
Thanks and gl

Shame to hear, I agree the Dockerfile should work out of the box.

If you’d like help from the developers feel free to ask in a discussion on GitHub.

Well lets see…