From 469fb86de8568fde3465a67f168c3c1a5f709f82 Mon Sep 17 00:00:00 2001 From: Benjamin Brummer Date: Thu, 8 Jan 2026 15:17:01 +0100 Subject: [PATCH 1/4] Added aio image with supervisord --- Dockerfile | 27 +++- compose/aio-compose.yaml | 47 ++++++ .../multi-compose.yaml | 2 +- sample.env => compose/sample.env | 0 docker-bake.hcl | 8 + entrypoint.sh | 145 +++++++++++------- supervisord.conf | 51 ++++++ version.sh | 33 ++-- 8 files changed, 235 insertions(+), 78 deletions(-) create mode 100644 compose/aio-compose.yaml rename sample.compose.yaml => compose/multi-compose.yaml (98%) rename sample.env => compose/sample.env (100%) create mode 100644 supervisord.conf diff --git a/Dockerfile b/Dockerfile index 42fe00d3..a0d6fa6a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -29,7 +29,8 @@ ARG php_extra="opcache" ARG saxon_edition="HE" # Create a system user UID/GID=999 -RUN useradd -r ${user} +RUN groupadd -g 999 ${user} && \ + useradd -u 999 -g ${user} -r ${user} # Allow to bind to privileged ports RUN setcap CAP_NET_BIND_SERVICE=+eip /usr/local/bin/frankenphp @@ -77,25 +78,35 @@ COPY --from=prepare-app --chown=${user}:${user} /app /app # Add initialization script COPY --chmod=0755 ./entrypoint.sh /usr/local/bin/entrypoint.sh -USER ${user} - ENV FILESYSTEM_DISK=debian_docker ENV IS_DOCKER=true ENV SNAPPDF_CHROMIUM_PATH=/usr/bin/chromium ENTRYPOINT ["entrypoint.sh"] +FROM base AS aio +ENV LARAVEL_ROLE=aio +HEALTHCHECK --start-period=100s CMD curl -f http://localhost/health || exit 1 +RUN apt-get update && apt-get install -y --no-install-recommends supervisor && rm -rf /var/lib/apt/lists/* +COPY ./supervisord.conf /etc/supervisor/conf.d/supervisord.conf +CMD ["supervisord", "-c", "/etc/supervisor/supervisord.conf"] + FROM base AS app +# STOPSIGNAL SIGQUIT ENV LARAVEL_ROLE=app -HEALTHCHECK --start-period=100s CMD curl -f http://localhost/health +USER ${user} +HEALTHCHECK --start-period=100s CMD curl -f http://localhost/health || exit 1 CMD ["frankenphp", "php-cli", "artisan", "octane:frankenphp"] FROM base AS scheduler ENV LARAVEL_ROLE=scheduler -HEALTHCHECK --start-period=10s CMD pgrep -f schedule:work -CMD ["frankenphp", "php-cli", "artisan", "schedule:work"] +USER ${user} +HEALTHCHECK --interval=60s \ + CMD find /tmp/scheduler_heartbeat -mmin -2 | grep . || exit 1 +CMD [] FROM base AS worker ENV LARAVEL_ROLE=worker -HEALTHCHECK --start-period=10s CMD pgrep -f queue:work -CMD ["frankenphp", "php-cli", "artisan", "queue:work"] +USER ${user} +HEALTHCHECK --start-period=10s CMD pgrep -f queue:work || exit 1 +CMD ["php", "artisan", "queue:work", "--no-interaction"] diff --git a/compose/aio-compose.yaml b/compose/aio-compose.yaml new file mode 100644 index 00000000..57eb6ac6 --- /dev/null +++ b/compose/aio-compose.yaml @@ -0,0 +1,47 @@ +name: invoiceninja-aio + +services: + aio: + image: benbrummer/invoiceninja:latest-aio + restart: unless-stopped + ports: + - "8012:80" # HTTP + env_file: + - ./.env + volumes: + - app_storage:/app/storage + - caddy_data:/data + # - ./php/php.ini:/usr/local/etc/php/conf.d/invoiceninja.ini + depends_on: + mariadb: + condition: service_healthy + valkey: + condition: service_healthy + # tty: true + + mariadb: + image: mariadb:11.8 + restart: unless-stopped + environment: + MARIADB_DATABASE: ${DB_DATABASE} + MARIADB_USER: ${DB_USERNAME} + MARIADB_PASSWORD: ${DB_PASSWORD} + MARIADB_ROOT_PASSWORD: ${DB_ROOT_PASSWORD} + volumes: + - mariadb:/var/lib/mysql + healthcheck: + test: [ "CMD", "healthcheck.sh", "--connect", "--innodb_initialized" ] + + valkey: + image: valkey/valkey:9 + restart: unless-stopped + volumes: + - valkey:/data + healthcheck: + test: [ "CMD", "valkey-cli", "ping" ] + +volumes: + app_storage: + caddy_data: + mariadb: + valkey: diff --git a/sample.compose.yaml b/compose/multi-compose.yaml similarity index 98% rename from sample.compose.yaml rename to compose/multi-compose.yaml index 013b1d81..36d7345f 100644 --- a/sample.compose.yaml +++ b/compose/multi-compose.yaml @@ -13,7 +13,7 @@ services: command: --port=80 --workers=2 # command: --host=example.com --port=443 --workers=2 --https --http-redirect --log-level=info ports: - - "80:80" # HTTP + - "8012:80" # HTTP # - "443:443" # HTTPS # - "443:443/udp" # HTTP/3, Works for chromium based browser, but causes H3_GENERAL_PROTOCOL_ERROR for pdf previews in Firefox env_file: diff --git a/sample.env b/compose/sample.env similarity index 100% rename from sample.env rename to compose/sample.env diff --git a/docker-bake.hcl b/docker-bake.hcl index a2046286..f14a62c3 100644 --- a/docker-bake.hcl +++ b/docker-bake.hcl @@ -20,6 +20,7 @@ variable "MINOR" { group "default" { targets = [ + "aio", "app", "scheduler", "worker" @@ -43,6 +44,13 @@ target _common { pull = true } +target "aio" { + description = "AIO for Invoiceninja Application Image" + inherits = ["_common"] + tags = [for tag in target._common.tags : replace(tag, "-", "-aio")] + target = "aio" +} + target "app" { description = "Invoiceninja Application Image" inherits = ["_common"] diff --git a/entrypoint.sh b/entrypoint.sh index 9befc68d..735f973c 100755 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -1,45 +1,22 @@ -#!/bin/sh -eu - -if [ "--help" = "$1" ]; then - echo [FLAGS] - echo The CMD defined can be extended with flags for artisan commands - echo - echo Available flags can be displaced: - echo docker run --rm benbrummer/invoiceninja:5-octane frankenphp php-cli artisan help octane:frankenphp - echo docker run --rm benbrummer/invoiceninja:5-octane-worker frankenphp php-cli artisan help queue:work - echo docker run --rm benbrummer/invoiceninja:5-octane-scheduler frankenphp php-cli artisan help schedule:work - echo - echo Example: - echo docker run benbrummer/invoiceninja:5-octane-worker --verbose --sleep=3 --tries=3 --max-time=3600 - echo - echo [Deployment] - echo Docker compose is recommended - echo - echo Example: - echo https://github.com/benbrummer/dockerfiles/blob/octane-action/sample.compose.yaml - echo - exit 0 -fi - -case "${LARAVEL_ROLE}" in -app) - if [ "$*" = 'frankenphp php-cli artisan octane:frankenphp' ] || [ "${1#-}" != "$1" ]; then - cmd="frankenphp php-cli artisan octane:frankenphp" +#!/bin/bash -eu +# --- ROLE: aio (Runs as root) --- +if [ "${LARAVEL_ROLE}" = 'aio' ]; then + # Clear and cache config in production + if [ "$*" = 'supervisord -c /etc/supervisor/supervisord.conf' ]; then if [ "$APP_ENV" = "production" ]; then - frankenphp php-cli artisan migrate --force - frankenphp php-cli artisan cache:clear # Clear after the migration - frankenphp php-cli artisan ninja:design-update - frankenphp php-cli artisan optimize + echo "Running production setup..." + runuser -u ninja -- php artisan migrate --force + runuser -u ninja -- php artisan cache:clear + runuser -u ninja -- php artisan ninja:design-update + runuser -u ninja -- php artisan optimize - # If first IN run, it needs to be initialized - if [ "$(frankenphp php-cli artisan tinker --execute='echo Schema::hasTable("accounts") && !App\Models\Account::all()->first();')" = "1" ]; then + # Check if initialization is needed + if [ "$(runuser -u ninja -- php artisan tinker --execute='echo Schema::hasTable("accounts") && !App\Models\Account::all()->first();')" = "1" ]; then echo "Running initialization..." - - frankenphp php-cli artisan db:seed --force - + runuser -u ninja -- php artisan db:seed --force if [ -n "${IN_USER_EMAIL}" ] && [ -n "${IN_PASSWORD}" ]; then - frankenphp php-cli artisan ninja:create-account --email "${IN_USER_EMAIL}" --password "${IN_PASSWORD}" + runuser -u ninja -- php artisan ninja:create-account --email "${IN_USER_EMAIL}" --password "${IN_PASSWORD}" else echo "Initialization failed - Set IN_USER_EMAIL and IN_PASSWORD in .env" exit 1 @@ -47,33 +24,87 @@ app) fi fi fi - ;; + echo "Handing off to supervisord..." + # Fall through to exec "$@" at the bottom -scheduler) - if [ "$*" = 'frankenphp php-cli artisan schedule:work' ] || [ "${1#-}" != "$1" ]; then - cmd="frankenphp php-cli artisan schedule:work" +# --- ROLES: app, worker, scheduler (Run as ninja) --- +else + if [ "--help" = "$1" ]; then + echo [FLAGS] + echo The CMD defined can be extended with flags for artisan commands + echo + echo Available flags can be displaced: + echo docker run --rm benbrummer/invoiceninja:5-app frankenphp php-cli artisan help octane:frankenphp + echo docker run --rm benbrummer/invoiceninja:5-worker php artisan help queue:work + echo docker run --rm benbrummer/invoiceninja:5-scheduler php artisan help schedule:work + echo + echo Example: + echo docker run benbrummer/invoiceninja:5-worker --verbose --sleep=3 --tries=3 --max-time=3600 + echo + echo [Deployment] + echo Docker compose is recommended + echo + echo Example: + echo https://github.com/benbrummer/dockerfiles/blob/main/sample.compose.yaml + echo + exit 0 fi - if [ "$APP_ENV" = "production" ]; then - frankenphp php-cli artisan optimize - fi - ;; + case "${LARAVEL_ROLE}" in + app) + # Check if we should prepend the octane command + if [ $# -eq 0 ] || [[ "$1" == -* ]] || [ "$*" = "frankenphp php-cli artisan octane:frankenphp" ]; then + if [ "$APP_ENV" = "production" ]; then + echo "Running production setup..." + php artisan migrate --force + php artisan cache:clear + php artisan ninja:design-update + php artisan optimize -worker) - if [ "$*" = 'frankenphp php-cli artisan queue:work' ] || [ "${1#-}" != "$1" ]; then - cmd="frankenphp php-cli artisan queue:work" - fi + if [ "$(php artisan tinker --execute='echo Schema::hasTable("accounts") && !App\Models\Account::all()->first();')" = "1" ]; then + echo "Running initialization..." + php artisan db:seed --force + if [ -n "${IN_USER_EMAIL:-}" ] && [ -n "${IN_PASSWORD:-}" ]; then + php artisan ninja:create-account --email "${IN_USER_EMAIL}" --password "${IN_PASSWORD}" + fi + fi + fi - if [ "$APP_ENV" = "production" ]; then - frankenphp php-cli artisan optimize - fi - ;; + # CRITICAL FIX: Prepend the base command if only flags were passed + if [ $# -eq 0 ] || [[ "$1" == -* ]]; then + set -- frankenphp php-cli artisan octane:frankenphp "$@" + fi + fi + ;; + + scheduler) + [ "$APP_ENV" = "production" ] && php artisan optimize + echo "Starting Scheduler loop..." -esac + # Catch signals to exit the loop immediately + trap "echo 'Stopping scheduler...'; exit 0" SIGTERM SIGINT + + while true; do + touch /tmp/scheduler_heartbeat + # Run scheduler in background, output still goes to stdout + php artisan schedule:run --no-interaction "$@" & + wait $! # Wait for the command to finish + + # Sleep in background so the 'trap' can interrupt it + sleep 60 & + wait $! + done + ;; + + worker) + [ "$APP_ENV" = "production" ] && php artisan optimize + echo "Starting Worker..." + if [ $# -eq 0 ] || [[ "$1" == -* ]]; then + set -- php artisan queue:work --no-interaction "$@" + fi + ;; + esac -# Append flag(s) to role cmd -if [ "${1#-}" != "$1" ] && [ -n "$cmd" ]; then - set -- ${cmd} "$@" fi exec "$@" diff --git a/supervisord.conf b/supervisord.conf new file mode 100644 index 00000000..f41349b6 --- /dev/null +++ b/supervisord.conf @@ -0,0 +1,51 @@ +[supervisord] +nodaemon=true +user=root +pidfile=/var/run/supervisord.pid +logfile=/dev/null +logfile_maxbytes=0 + +[program:app] +command=frankenphp php-cli artisan octane:frankenphp --port=80 --workers=2 +user=ninja +priority=5 +autostart=true +autorestart=true +startretries=3 +stdout_logfile=/dev/fd/1 +stdout_logfile_maxbytes=0 +redirect_stderr=true +stopsignal=QUIT +exitcodes=0,2 +stopasgroup=true +killasgroup=true + +[program:worker] +process_name=%(program_name)s_%(process_num)02d +command=php artisan queue:work --sleep=3 --tries=3 --max-time=3600 +user=ninja +numprocs=2 +priority=10 +autostart=true +autorestart=true +startretries=3 +stdout_logfile=/dev/fd/1 +stdout_logfile_maxbytes=0 +redirect_stderr=true +stopsignal=TERM +stopasgroup=true +killasgroup=true + +[program:scheduler] +command=bash -c "while [ true ]; do php artisan schedule:run; sleep 60; done" +user=ninja +priority=15 +autostart=true +autorestart=true +startretries=3 +stdout_logfile=/dev/fd/1 +stdout_logfile_maxbytes=0 +redirect_stderr=true +stopsignal=INT +stopasgroup=true +killasgroup=true diff --git a/version.sh b/version.sh index 6f9c6122..30f5f9b4 100755 --- a/version.sh +++ b/version.sh @@ -1,18 +1,27 @@ #!/bin/bash -export VERSION="$(cat version.txt | tr -d v)" -export MAJOR="$(echo "${VERSION}" | cut -d. -f1)" -export MINOR="${MAJOR}.$(echo "${VERSION}" | cut -d. -f2)" -export URL="https://github.com/invoiceninja/invoiceninja/releases/download/v${VERSION}/invoiceninja.tar.gz" +VERSION="$(cat version.txt | tr -d v)" +export VERSION + +MAJOR="$(echo "${VERSION}" | cut -d. -f1)" +export MAJOR + +MINOR="${MAJOR}.$(echo "${VERSION}" | cut -d. -f2)" +export MINOR + +URL="https://github.com/invoiceninja/invoiceninja/releases/download/v${VERSION}/invoiceninja.tar.gz" +export URL echo "Current version: ${VERSION}" if [ "${GITHUB_ACTIONS}" ]; then - echo "VERSION=${VERSION}" >> "${GITHUB_ENV}" - echo "MAJOR=${MAJOR}" >> "${GITHUB_ENV}" - echo "MINOR=${MINOR}" >> "${GITHUB_ENV}" - echo "URL=${URL}" >> "${GITHUB_ENV}" - echo "VERSION=${VERSION}" >> "${GITHUB_OUTPUT}" - echo "MAJOR=${MAJOR}" >> "${GITHUB_OUTPUT}" - echo "MINOR=${MINOR}" >> "${GITHUB_OUTPUT}" - echo "URL=${URL}" >> "${GITHUB_OUTPUT}" + { + echo "VERSION=${VERSION}" + echo "MAJOR=${MAJOR}" + echo "MINOR=${MINOR}" + echo "URL=${URL}" + echo "VERSION=${VERSION}" + echo "MAJOR=${MAJOR}" + echo "MINOR=${MINOR}" + echo "URL=${URL}" + } >>"${GITHUB_OUTPUT}" fi From 71e881632470962e9255e9efaf512e66d150fbec Mon Sep 17 00:00:00 2001 From: Benjamin Brummer Date: Thu, 8 Jan 2026 14:29:18 +0000 Subject: [PATCH 2/4] added aio target to workflow --- .github/workflows/bake.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/bake.yaml b/.github/workflows/bake.yaml index 57f7afc7..a765445e 100644 --- a/.github/workflows/bake.yaml +++ b/.github/workflows/bake.yaml @@ -47,7 +47,7 @@ jobs: prepare strategy: matrix: - target: [app, scheduler, worker] + target: [aio, app, scheduler, worker] steps: - name: Checkout uses: actions/checkout@v4 @@ -145,7 +145,7 @@ jobs: - build strategy: matrix: - target: [app, scheduler, worker] + target: [aio, app, scheduler, worker] steps: - name: Download meta bake definition From cd6fdb846c764d4ec6d6976cce45fb0179862871 Mon Sep 17 00:00:00 2001 From: Benjamin Brummer Date: Thu, 8 Jan 2026 14:56:01 +0000 Subject: [PATCH 3/4] replace frankenphp php-cli with php --- Dockerfile | 2 +- entrypoint.sh | 6 +++--- supervisord.conf | 5 ++--- 3 files changed, 6 insertions(+), 7 deletions(-) diff --git a/Dockerfile b/Dockerfile index a0d6fa6a..b3307c00 100644 --- a/Dockerfile +++ b/Dockerfile @@ -96,7 +96,7 @@ FROM base AS app ENV LARAVEL_ROLE=app USER ${user} HEALTHCHECK --start-period=100s CMD curl -f http://localhost/health || exit 1 -CMD ["frankenphp", "php-cli", "artisan", "octane:frankenphp"] +CMD ["php", "artisan", "octane:frankenphp"] FROM base AS scheduler ENV LARAVEL_ROLE=scheduler diff --git a/entrypoint.sh b/entrypoint.sh index 735f973c..2d69f7e4 100755 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -34,7 +34,7 @@ else echo The CMD defined can be extended with flags for artisan commands echo echo Available flags can be displaced: - echo docker run --rm benbrummer/invoiceninja:5-app frankenphp php-cli artisan help octane:frankenphp + echo docker run --rm benbrummer/invoiceninja:5-app php artisan help octane:frankenphp echo docker run --rm benbrummer/invoiceninja:5-worker php artisan help queue:work echo docker run --rm benbrummer/invoiceninja:5-scheduler php artisan help schedule:work echo @@ -53,7 +53,7 @@ else case "${LARAVEL_ROLE}" in app) # Check if we should prepend the octane command - if [ $# -eq 0 ] || [[ "$1" == -* ]] || [ "$*" = "frankenphp php-cli artisan octane:frankenphp" ]; then + if [ $# -eq 0 ] || [[ "$1" == -* ]] || [ "$*" = "php artisan octane:frankenphp" ]; then if [ "$APP_ENV" = "production" ]; then echo "Running production setup..." php artisan migrate --force @@ -72,7 +72,7 @@ else # CRITICAL FIX: Prepend the base command if only flags were passed if [ $# -eq 0 ] || [[ "$1" == -* ]]; then - set -- frankenphp php-cli artisan octane:frankenphp "$@" + set -- php artisan octane:frankenphp "$@" fi fi ;; diff --git a/supervisord.conf b/supervisord.conf index f41349b6..ac2d6a55 100644 --- a/supervisord.conf +++ b/supervisord.conf @@ -6,7 +6,7 @@ logfile=/dev/null logfile_maxbytes=0 [program:app] -command=frankenphp php-cli artisan octane:frankenphp --port=80 --workers=2 +command=php artisan octane:frankenphp --port=80 --workers=2 user=ninja priority=5 autostart=true @@ -15,8 +15,7 @@ startretries=3 stdout_logfile=/dev/fd/1 stdout_logfile_maxbytes=0 redirect_stderr=true -stopsignal=QUIT -exitcodes=0,2 + stopasgroup=true killasgroup=true From cf3642687df94b82bbc6a1fc7786023a0c177630 Mon Sep 17 00:00:00 2001 From: Benjamin Brummer Date: Thu, 8 Jan 2026 20:58:03 +0100 Subject: [PATCH 4/4] schedule:work instead of schedule:run --- Dockerfile | 5 ++--- compose/aio-compose.yaml | 2 +- compose/multi-compose.yaml | 1 + entrypoint.sh | 20 +++++--------------- supervisord.conf | 5 +---- 5 files changed, 10 insertions(+), 23 deletions(-) diff --git a/Dockerfile b/Dockerfile index b3307c00..1e4ab442 100644 --- a/Dockerfile +++ b/Dockerfile @@ -101,9 +101,8 @@ CMD ["php", "artisan", "octane:frankenphp"] FROM base AS scheduler ENV LARAVEL_ROLE=scheduler USER ${user} -HEALTHCHECK --interval=60s \ - CMD find /tmp/scheduler_heartbeat -mmin -2 | grep . || exit 1 -CMD [] +HEALTHCHECK --start-period=10s CMD pgrep -f schedule:work || exit 1 +CMD ["php", "artisan", "schedule:work", "--no-interaction"] FROM base AS worker ENV LARAVEL_ROLE=worker diff --git a/compose/aio-compose.yaml b/compose/aio-compose.yaml index 57eb6ac6..a9674f66 100644 --- a/compose/aio-compose.yaml +++ b/compose/aio-compose.yaml @@ -1,4 +1,4 @@ -name: invoiceninja-aio +name: invoiceninja services: aio: diff --git a/compose/multi-compose.yaml b/compose/multi-compose.yaml index 36d7345f..61001a35 100644 --- a/compose/multi-compose.yaml +++ b/compose/multi-compose.yaml @@ -10,6 +10,7 @@ services: app: image: benbrummer/invoiceninja:5-app restart: unless-stopped + stop_grace_period: 30s command: --port=80 --workers=2 # command: --host=example.com --port=443 --workers=2 --https --http-redirect --log-level=info ports: diff --git a/entrypoint.sh b/entrypoint.sh index 2d69f7e4..b13afbb2 100755 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -79,26 +79,16 @@ else scheduler) [ "$APP_ENV" = "production" ] && php artisan optimize - echo "Starting Scheduler loop..." + echo "Starting scheduler ..." - # Catch signals to exit the loop immediately - trap "echo 'Stopping scheduler...'; exit 0" SIGTERM SIGINT - - while true; do - touch /tmp/scheduler_heartbeat - # Run scheduler in background, output still goes to stdout - php artisan schedule:run --no-interaction "$@" & - wait $! # Wait for the command to finish - - # Sleep in background so the 'trap' can interrupt it - sleep 60 & - wait $! - done + if [ $# -eq 0 ] || [[ "$1" == -* ]]; then + set -- php artisan queue:work --no-interaction "$@" + fi ;; worker) [ "$APP_ENV" = "production" ] && php artisan optimize - echo "Starting Worker..." + echo "Starting worker..." if [ $# -eq 0 ] || [[ "$1" == -* ]]; then set -- php artisan queue:work --no-interaction "$@" fi diff --git a/supervisord.conf b/supervisord.conf index ac2d6a55..d63d231b 100644 --- a/supervisord.conf +++ b/supervisord.conf @@ -15,7 +15,6 @@ startretries=3 stdout_logfile=/dev/fd/1 stdout_logfile_maxbytes=0 redirect_stderr=true - stopasgroup=true killasgroup=true @@ -31,12 +30,11 @@ startretries=3 stdout_logfile=/dev/fd/1 stdout_logfile_maxbytes=0 redirect_stderr=true -stopsignal=TERM stopasgroup=true killasgroup=true [program:scheduler] -command=bash -c "while [ true ]; do php artisan schedule:run; sleep 60; done" +command=php artisan schedule:work user=ninja priority=15 autostart=true @@ -45,6 +43,5 @@ startretries=3 stdout_logfile=/dev/fd/1 stdout_logfile_maxbytes=0 redirect_stderr=true -stopsignal=INT stopasgroup=true killasgroup=true