Getting Invoice Ninja to work on Docker

I am pulling my hair out. I am trying to self host InvoiceNinja in Docker and it starts to work, but after a while I get blank screens. The Docker host is running Ubuntu 24.

I am using the latest of everything. This is my current setup:

===
compose.yml:
---
networks:
  internal:
    driver: bridge
  traefik:
    external: true
x-app-common: &a1
  image: invoiceninja/invoiceninja-octane:latest
  restart: unless-stopped
  env_file:
    - ./.env
  volumes:
    - ./.env:/app/.env
    - /srv/invoiceninja/app/storage:/app/storage
    - /srv/invoiceninja/app/public:/app/public
  networks:
    - internal
services:
  db:
    image: mariadb:10.5
    restart: unless-stopped
    environment:
      MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}
      MYSQL_DATABASE: ${DB_DATABASE}
      MYSQL_USER: ${DB_USERNAME}
      MYSQL_PASSWORD: ${DB_PASSWORD}
    healthcheck:
      test:
        - CMD-SHELL
        - mysqladmin ping -h localhost -u$DB_USERNAME -p$DB_PASSWORD
      interval: 10s
      timeout: 5s
      retries: 5
      start_period: 15s
    volumes:
      - /srv/invoiceninja/mariadb:/var/lib/mysql
    networks:
      - internal
  redis:
    image: redis:7-alpine
    restart: unless-stopped
    command:
      - redis-server
      - --requirepass
      - ${REDIS_PASSWORD}
    healthcheck:
      test:
        - CMD-SHELL
        - redis-cli -a $REDIS_PASSWORD ping
      interval: 10s
      timeout: 5s
      retries: 5
      start_period: 5s
    volumes:
      - /srv/invoiceninja/redis:/data
    networks:
      - internal
  app:
    <<: *a1
    depends_on:
      db:
        condition: service_healthy
      redis:
        condition: service_healthy
    command:
      - --port=80
      - --host=0.0.0.0
      - --workers=2
    environment:
      LARAVEL_ROLE: app
    labels:
      - traefik.enable=true
      - traefik.http.routers.invoiceninja.rule=Host(`anonimized.myhost.nl`)
      - traefik.http.routers.invoiceninja.entrypoints=websecure
      - traefik.http.routers.invoiceninja.tls=true
      - traefik.http.routers.invoiceninja.tls.certresolver=letsencrypt
      # HTTP router and redirect middleware removed - HTTPS only
      - traefik.http.services.invoiceninja.loadbalancer.server.port=80
      - traefik.http.services.invoiceninja.loadbalancer.server.scheme=http
    networks:
      - traefik
      - internal
  worker:
    <<: *a1
    depends_on:
      db:
        condition: service_healthy
      redis:
        condition: service_healthy
      app:
        condition: service_healthy
    command: --verbose --sleep=3 --tries=3 --max-time=3600
    environment:
      LARAVEL_ROLE: worker
    healthcheck:
      test:
        - CMD
        - pgrep
        - -f
        - queue:work
      start_period: 10s
  scheduler:
    <<: *a1
    depends_on:
      db:
        condition: service_healthy
      redis:
        condition: service_healthy
      app:
        condition: service_healthy
    command: --verbose
    environment:
      LARAVEL_ROLE: scheduler
    healthcheck:
      test:
        - CMD
        - pgrep
        - -f
        - schedule:work
      start_period: 10s
x-dockge:
  urls:
    - https://anonimized.myhost.nl/
===
.env
---
APP_ENV=production

# Invoice Ninja .env
APP_URL=https://anonimized.myhost.nl
APP_KEY=9iCJq600sP1O1GqN7gBi5Iu4PqrcXc49
APP_DEBUG=false
EXPANDED_LOGGING=true
REQUIRE_HTTPS=false

IS_DOCKER=true

CACHE_DRIVER=redis
QUEUE_CONNECTION=redis
SESSION_DRIVER=redis

TRUSTED_PROXIES=*

# Database settings
DB_HOST=db
DB_DATABASE=ninjadb
DB_USERNAME=ninjauser
DB_PASSWORD=invoiceninja_secure_password
DB_ROOT_PASSWORD=mysql_root_secure_password

# Redis settings
REDIS_HOST=redis
REDIS_PASSWORD=redis_secure_password

# Admin / initial Invoice Ninja account
[email protected]
IN_PASSWORD=supersecret

# Queue & scheduler
QUEUE_CONNECTION=redis
SCHEDULER_ENABLED=true

# Preconfigure install (skip setup wizard)
PRECONFIGURED_INSTALL=true

# Other optional settings
MULTI_DB_ENABLED=false

====

At first it works, but after a while clicking on a function, e.g Clients, it’ll end up with a white screen in the browser. And I see a few javascripts being blocked by the message:

was blocked because of a disallowed MIME type (“text/html”).

I hope someone can help me!

Hi,

Do you see a difference using the desktop/mobile app?

I’m not sure what’s causing the problem, maybe this infor ChatGPT will help:

Welcome, @MakerFrank! Blank pages + “blocked because of a disallowed MIME type (“text/html”)” almost always means the browser asked for a .js file but the server returned HTML (typically a redirect or an error page). With your setup there are a few common culprits—here’s a focused checklist that usually fixes it.


1) Don’t bind-mount /app/public

Right now you have:

- /srv/invoiceninja/app/public:/app/public

Mounting public/ hides the image’s prebuilt assets (/public/js/app.js, mix-manifest.json, etc.). It can work at first (because of cache) and then “flip” to white screens when a refresh requests files that aren’t there, returning an HTML 404/500 → MIME-type block.

Fix: Remove that volume. You only need to persist storage (and .env). Logos/documents live in storage and are symlinked to public/storage.

Keep:

- /srv/invoiceninja/app/storage:/app/storage
- ./.env:/app/.env

Remove:

- /srv/invoiceninja/app/public:/app/public

After changing, restart and (once) recreate the storage symlink:

docker compose exec app php artisan storage:link

2) Make sure HTTPS settings are consistent

You’re using Traefik with TLS. Set these in .env:

APP_URL=https://anonimized.myhost.nl
REQUIRE_HTTPS=true
SESSION_SECURE_COOKIE=true
APP_ENV=production
APP_DEBUG=false

(You already had most—switch REQUIRE_HTTPS=true for an HTTPS terminator like Traefik.)


3) Traefik rule syntax

Traefik v2 rules typically use backticks. What you have can parse, but I’d make it explicit:

labels:
  - traefik.enable=true
  - traefik.http.routers.invoiceninja.rule=Host(`anonimized.myhost.nl`)
  - traefik.http.routers.invoiceninja.entrypoints=websecure
  - traefik.http.routers.invoiceninja.tls=true
  - traefik.http.routers.invoiceninja.tls.certresolver=letsencrypt
  - traefik.http.services.invoiceninja.loadbalancer.server.port=80
  - traefik.http.services.invoiceninja.loadbalancer.server.scheme=http

Also ensure the app service is on Traefik’s network (you already have it).


4) Octane worker hygiene (prevents “works for a while, then breaks”)

Long-running workers can get into a bad state. Add recycling:

app:
  command:
    - --port=80
    - --host=0.0.0.0
    - --workers=2
    - --max-requests=500
    - --max-execution-time=60

Then clear caches after first boot or after env changes:

docker compose exec app php artisan optimize:clear

5) Verify what that “blocked JS” is actually returning

From your machine:

# Replace with the exact JS path the browser is blocking
curl -I https://anonimized.myhost.nl/js/app.js

You should see HTTP/2 200 and content-type: application/javascript.
If you see 302/401/404 or content-type: text/html, that confirms the diagnosis above (public mount, redirect to login, or an error).

Also check logs:

docker compose logs -f app
docker compose exec app tail -n 200 storage/logs/laravel.log

If you see permission errors, fix them:

docker compose exec app chown -R www-data:www-data /app/storage

6) Recommended minimal compose for your stack

networks:
  internal:
    driver: bridge
  traefik:
    external: true

x-app-common: &app_common
  image: invoiceninja/invoiceninja-octane:latest
  restart: unless-stopped
  env_file:
    - ./.env
  volumes:
    - ./.env:/app/.env
    - /srv/invoiceninja/app/storage:/app/storage
  networks:
    - internal

services:
  db:
    image: mariadb:10.5
    restart: unless-stopped
    environment:
      MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}
      MYSQL_DATABASE: ${DB_DATABASE}
      MYSQL_USER: ${DB_USERNAME}
      MYSQL_PASSWORD: ${DB_PASSWORD}
    healthcheck:
      test: ["CMD-SHELL","mysqladmin ping -h localhost -u$DB_USERNAME -p$DB_PASSWORD"]
      interval: 10s
      timeout: 5s
      retries: 5
      start_period: 15s
    volumes:
      - /srv/invoiceninja/mariadb:/var/lib/mysql
    networks: [internal]

  redis:
    image: redis:7-alpine
    restart: unless-stopped
    command: ["redis-server","--requirepass","${REDIS_PASSWORD}"]
    healthcheck:
      test: ["CMD-SHELL","redis-cli -a $REDIS_PASSWORD ping"]
      interval: 10s
      timeout: 5s
      retries: 5
      start_period: 5s
    volumes:
      - /srv/invoiceninja/redis:/data
    networks: [internal]

  app:
    <<: *app_common
    depends_on:
      db: { condition: service_healthy }
      redis: { condition: service_healthy }
    command:
      - --port=80
      - --host=0.0.0.0
      - --workers=2
      - --max-requests=500
      - --max-execution-time=60
    environment:
      LARAVEL_ROLE: app
    labels:
      - traefik.enable=true
      - traefik.http.routers.invoiceninja.rule=Host(`anonimized.myhost.nl`)
      - traefik.http.routers.invoiceninja.entrypoints=websecure
      - traefik.http.routers.invoiceninja.tls=true
      - traefik.http.routers.invoiceninja.tls.certresolver=letsencrypt
      - traefik.http.services.invoiceninja.loadbalancer.server.port=80
      - traefik.http.services.invoiceninja.loadbalancer.server.scheme=http
    networks: [traefik, internal]

  worker:
    <<: *app_common
    depends_on:
      db: { condition: service_healthy }
      redis: { condition: service_healthy }
      app: { condition: service_healthy }
    command: --verbose --sleep=3 --tries=3 --max-time=3600
    environment:
      LARAVEL_ROLE: worker
    healthcheck:
      test: ["CMD","pgrep","-f","queue:work"]
      start_period: 10s

  scheduler:
    <<: *app_common
    depends_on:
      db: { condition: service_healthy }
      redis: { condition: service_healthy }
      app: { condition: service_healthy }
    command: --verbose
    environment:
      LARAVEL_ROLE: scheduler
    healthcheck:
      test: ["CMD","pgrep","-f","schedule:work"]
      start_period: 10s

7) Quick sanity list

  • Remove public/ bind mount :white_check_mark:
  • APP_URL uses https + your exact domain :white_check_mark:
  • REQUIRE_HTTPS=true, SESSION_SECURE_COOKIE=true :white_check_mark:
  • Traefik rule uses Host(\domain`)` :white_check_mark:
  • storage:link present, storage owned by www-data :white_check_mark:
  • Add Octane --max-requests :white_check_mark:
  • curl -I on blocked JS shows application/javascript :white_check_mark:
1 Like

Thanks for your comprehensive answer. I replaced it by your file.

Still having issues though:

  1. The app logs gives:
    [2025-09-24 17:50:00] production.ERROR: The “–max-execution-time” option does not exist. {“exception”:"[object] (Symfony\Component\Console\Exception\RuntimeException(code: 0): The "–max-execution-time" option does not exist. at /app/vendor/symfony/console/Input/ArgvInput.php:226)

I removed that option and the app started as healthy. So I still got an issue:

  1. If I upload a file e.g. the logo it says “OK” but it never appears.
    (I did run docker compose exec app php artisan storage:link, and I did run chown).

I applied everything from your sanity list. Number 7 now gives text/javascript.

Do you see a failed request in the network tab of the browser console for the image?

Nope. No error (http 200).

Do you see the image in the response?

It says Successfully uploaded logo, but the Invoice NInja logo stays.

When I temporarily remove the line:

- /srv/invoiceninja/app/storage:/app/storage

it works.
But I really need this for backing up. Don’t really want a docker volume :frowning:

Sorry I’m not sure, I suggest asking in a discussion on GitHub.

Is there another way to backup something?

And what about the --max-execution-time=60? Do I still need that or is it by accident another param name? Does that need to be on the app or also on the scheduler and worker?

Maybe you can use this tool:

I don’t believe max-execution-time should be needed, you can’t fully trust all ChatGPT advice.

The fix was permissions:

mkdir -p /srv/invoiceninja/app/storage
chown -R 999:999 /srv/invoiceninja/app
chmod -R 775 /srv/invoiceninja/app

1 Like

Glad to hear it’s sorted, thanks for sharing the solution!