diff --git a/.editorconfig b/.editorconfig
index c0a8a708f9d..c0b2a31418c 100644
--- a/.editorconfig
+++ b/.editorconfig
@@ -21,6 +21,10 @@ trim_trailing_whitespace = false
indent_style = tab
indent_size = 4
+[*.sh]
+indent_style = space
+indent_size = 2
+
[*.yml]
indent_style = space
indent_size = 2
diff --git a/.github/workflows/CICD.yml b/.github/workflows/CICD.yml
index df68069a428..0d58c2b33aa 100644
--- a/.github/workflows/CICD.yml
+++ b/.github/workflows/CICD.yml
@@ -184,7 +184,7 @@ jobs:
vuln-type: 'os,library'
severity: 'CRITICAL,HIGH'
- docker-build:
+ docker_build:
name: 4️⃣ Build Docker Image
runs-on: ubuntu-latest
if: >
@@ -254,6 +254,116 @@ jobs:
build-args: |
NODE_ENV=production
+ docker_legacy_check:
+ name: 3️⃣ Legacy Dockerfile Lint
+ runs-on: ubuntu-latest
+ needs:
+ - phpstan
+ - check_js
+
+ steps:
+ - name: Harden Runner
+ uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
+ with:
+ egress-policy: audit
+
+ - name: Checkout code
+ uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
+
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f #v3.12.0
+
+ - name: Docker Lint
+ uses: hadolint/hadolint-action@2332a7b74a6de0dda2e2221d575162eba76ba5e5 # v3.3.0
+ with:
+ dockerfile: ./Dockerfile-legacy
+ failure-threshold: warning
+
+ - name: Build Docker image locally
+ run: docker build -f Dockerfile-legacy -t lychee:local-legacy .
+
+ - name: Run Trivy vulnerability scanner
+ uses: aquasecurity/trivy-action@b6643a29fecd7f34b3597bc6acb0a98b03d33ff8 # v0.33.1
+ with:
+ image-ref: lychee:local-legacy
+ format: 'table'
+ exit-code: 1
+ ignore-unfixed: true
+ vuln-type: 'os,library'
+ severity: 'CRITICAL,HIGH'
+
+ docker_legacy_build:
+ name: 4️⃣ Build Legacy Docker Image
+ runs-on: ubuntu-latest
+ if: >
+ (github.ref == 'refs/heads/master' && github.event_name == 'push') ||
+ (startsWith(github.ref, 'refs/tags/') && github.event_name == 'push')
+ needs:
+ - docker_legacy_check
+ permissions:
+ contents: read
+ packages: write
+
+ steps:
+ - name: Harden Runner
+ uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
+ with:
+ egress-policy: audit
+
+ - name: Checkout code
+ uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
+
+ - name: Set up QEMU
+ uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
+
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f #v3.12.0
+
+ - name: Log in to GitHub Container Registry
+ uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
+ with:
+ registry: ghcr.io
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+
+ -
+ name: Login to DockerHub
+ uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
+ with:
+ username: ${{ secrets.DOCKERHUB_USERNAME }}
+ password: ${{ secrets.DOCKERHUB_TOKEN }}
+
+ - name: Extract metadata (tags, labels)
+ id: meta
+ uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
+ with:
+ images: |
+ ${{ github.repository }}
+ ghcr.io/${{ github.repository }}
+ flavor: |
+ latest=${{ startsWith(github.ref, 'refs/tags/') }}
+ suffix=-legacy,onlatest=true
+ tags: |
+ # define default branch
+ type=edge,branch=master
+ # branch event
+ type=ref,event=branch
+ # tag event
+ type=ref,event=tag
+
+ - name: Build and push Docker image
+ uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
+ with:
+ push: true
+ file: Dockerfile-legacy
+ platforms: linux/amd64,linux/arm64
+ tags: ${{ steps.meta.outputs.tags }}
+ labels: ${{ steps.meta.outputs.labels }}
+ cache-from: type=gha
+ cache-to: type=gha,mode=max
+ build-args: |
+ NODE_ENV=production
+
createArtifact:
name: 3️⃣ Build Artifact
if: github.ref == 'refs/heads/master' || startsWith(github.ref, 'refs/tags/')
diff --git a/Dockerfile b/Dockerfile
index 4e02c66367a..32b85947ac1 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -84,6 +84,7 @@ RUN apk add --no-cache \
netcat-openbsd \
unzip \
curl \
+ bash \
&& install-php-extensions \
pdo_mysql \
pdo_pgsql \
diff --git a/Dockerfile-legacy b/Dockerfile-legacy
new file mode 100644
index 00000000000..7c780c4b22a
--- /dev/null
+++ b/Dockerfile-legacy
@@ -0,0 +1,171 @@
+# Lychee - Laravel Backend Dockerfile
+# Multi-stage build with Laravel Octane + FrankenPHP
+ARG NODE_ENV=production
+
+# ============================================================================
+# Stage 1: Composer Dependencies
+# ============================================================================
+FROM composer:2.8@sha256:5248900ab8b5f7f880c2d62180e40960cd87f60149ec9a1abfd62ac72a02577c AS composer
+
+WORKDIR /app
+
+# Copy composer files first for layer caching
+COPY composer.json composer.lock ./
+
+# Install dependencies (no dev packages for production)
+# Remove markdown and test directories to slim down the image
+RUN composer install \
+ --no-dev \
+ --no-interaction \
+ --no-progress \
+ --no-scripts \
+ --prefer-dist \
+ --optimize-autoloader \
+ --ignore-platform-reqs \
+ && find vendor \
+ \( -iname "*.md" -o -iname "test" -o -iname "tests" \) \
+ -exec rm -rf {} +
+
+# ============================================================================
+# Stage 2: Node.js Build for Frontend Assets
+# ============================================================================
+FROM node:20-alpine@sha256:658d0f63e501824d6c23e06d4bb95c71e7d704537c9d9272f488ac03a370d448 AS node
+
+# Build argument to control dev vs production build
+ARG NODE_ENV
+ENV NODE_ENV=$NODE_ENV
+
+WORKDIR /app
+
+# Copy package files for layer caching
+COPY package.json package-lock.json ./
+
+# Install dependencies
+RUN npm ci --no-audit
+
+# Copy frontend source
+COPY resources/ ./resources/
+COPY public/ ./public/
+COPY lang/ ./lang/
+COPY vite.config.ts vite.embed.config.ts tsconfig.json ./
+
+# Build frontend assets
+# When NODE_ENV=development, Vite sets import.meta.env.DEV=true
+RUN npm run build
+
+
+FROM debian:bookworm-slim@sha256:d5d3f9c23164ea16f31852f95bd5959aad1c5e854332fe00f7b3a20fcc9f635c AS base
+
+LABEL maintainer="lycheeorg"
+LABEL org.opencontainers.image.title="Lychee"
+LABEL org.opencontainers.image.description="Self-hosted photo management system done right."
+LABEL org.opencontainers.image.authors="LycheeOrg"
+LABEL org.opencontainers.image.vendor="LycheeOrg"
+LABEL org.opencontainers.image.source="https://github.com/LycheeOrg/Lychee"
+LABEL org.opencontainers.image.url="https://lycheeorg.github.io"
+LABEL org.opencontainers.image.documentation="https://lycheeorg.dev/docs"
+LABEL org.opencontainers.image.licenses="MIT"
+LABEL org.opencontainers.image.base.name="debian:bookworm-slim"
+
+# Environment variables
+ENV PUID='1000'
+ENV PGID='1000'
+ENV PHP_TZ=UTC
+
+# https://stackoverflow.com/questions/53377176/change-imagemagick-policy-on-a-dockerfile (for the sed on policy.xml)
+# Install base dependencies, add user and group, clone the repo and install php libraries
+# hadolint ignore=DL3008
+RUN \
+ set -ev && \
+ apt-get update && \
+ apt-get upgrade -qy && \
+ apt-get install -qy --no-install-recommends\
+ ca-certificates \
+ curl \
+ apt-transport-https && \
+ curl -sSLo /tmp/debsuryorg-archive-keyring.deb https://packages.sury.org/debsuryorg-archive-keyring.deb && \
+ dpkg -i /tmp/debsuryorg-archive-keyring.deb && \
+ sh -c 'echo "deb [signed-by=/usr/share/keyrings/deb.sury.org-php.gpg] https://packages.sury.org/php/ bookworm main" > /etc/apt/sources.list.d/php.list' && \
+ apt-get update && \
+ apt-get install -qy --no-install-recommends \
+ adduser \
+ nginx-light \
+ php8.5-mysql \
+ php8.5-pgsql \
+ php8.5-sqlite3 \
+ php8.5-imagick \
+ php8.5-mbstring \
+ php8.5-gd \
+ php8.5-xml \
+ php8.5-zip \
+ php8.5-fpm \
+ php8.5-redis \
+ php8.5-bcmath \
+ php8.5-intl \
+ netcat-openbsd \
+ libimage-exiftool-perl \
+ ffmpeg \
+ jpegoptim \
+ optipng \
+ pngquant \
+ gifsicle \
+ webp \
+ cron \
+ ghostscript && \
+ sed -i 's///g' /etc/ImageMagick-6/policy.xml && \
+ usermod -o -u "$PUID" "www-data" && \
+ groupmod -o -g "$PGID" "www-data" && \
+ echo "* * * * * www-data cd /app && php artisan schedule:run >> /dev/null 2>&1" >> /etc/crontab && \
+ apt-get clean -qy && \
+ rm -rf /var/lib/apt/lists/*
+
+WORKDIR /app
+
+# Copy application code
+COPY --chown=www-data:www-data . .
+
+# Copy vendor from composer stage
+COPY --from=composer --chown=www-data:www-data /app/vendor ./vendor
+
+# Copy built frontend assets from node stage
+COPY --from=node --chown=www-data:www-data /app/public/build ./public/build
+
+# Ensure storage and bootstrap/cache are writable with minimal permissions
+RUN mkdir -p storage/framework/cache \
+ storage/framework/sessions \
+ storage/framework/views \
+ storage/logs \
+ bootstrap/cache \
+ public/dist \
+ && chown -R www-data:www-data storage bootstrap/cache public/dist \
+ && chmod -R 750 storage bootstrap/cache \
+ && chmod -R 755 public/dist \
+ && touch /app/docker_target \
+ && touch /app/public/dist/user.css \
+ && touch /app/public/dist/custom.js \
+ && chown www-data:www-data /app/public/dist/user.css /app/public/dist/custom.js \
+ && chmod 644 /app/public/dist/user.css /app/public/dist/custom.js
+
+# Copy entrypoint and validation scripts
+COPY docker/scripts/entrypoint.sh /usr/local/bin/entrypoint.sh
+COPY docker/scripts/validate-env.sh /usr/local/bin/validate-env.sh
+COPY docker/scripts/create-admin-user.sh /usr/local/bin/create-admin-user.sh
+COPY docker/scripts/permissions-check.sh /usr/local/bin/permissions-check.sh
+COPY docker/scripts/dump-env.sh /usr/local/bin/dump-env.sh
+COPY docker/nginx.conf /etc/nginx/nginx.conf
+RUN chmod +x /usr/local/bin/entrypoint.sh /usr/local/bin/validate-env.sh /usr/local/bin/permissions-check.sh /usr/local/bin/create-admin-user.sh /usr/local/bin/dump-env.sh
+
+# Expose port 8000 (Octane)
+EXPOSE 8000
+
+# Set entrypoint
+ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
+
+
+# RUN chmod +x /entrypoint.sh && \
+# chmod +x /inject.sh && \
+# if [ ! -e /run/php ] ; then mkdir /run/php ; fi
+
+HEALTHCHECK CMD curl --fail http://localhost:8000/ || exit 1
+
+CMD [ "nginx" ]
\ No newline at end of file
diff --git a/docker-compose.yaml b/docker-compose.yaml
index 4423d7ce1e2..4197b981329 100644
--- a/docker-compose.yaml
+++ b/docker-compose.yaml
@@ -3,6 +3,7 @@ services:
# Laravel Backend API
lychee_api:
image: lychee-frankenphp:latest
+ # image: lychee-legacy:latest
# build:
# context: ./app
# dockerfile: Dockerfile
@@ -131,6 +132,7 @@ services:
# to disable it for your Log Viewer.
# Should redis crash, you will no longer be able to access your logs.
LOG_VIEWER_CACHE_DRIVER: "file"
+ SKIP_PERMISSIONS_CHECKS: "yes"
# Queue
QUEUE_CONNECTION: "${QUEUE_CONNECTION:-database}"
@@ -270,7 +272,6 @@ services:
- ./lychee/storage/app:/app/storage/app
- ./lychee/logs:/app/storage/logs
- ./lychee/tmp:/app/storage/tmp
- - .env:/app/.env:ro
depends_on:
lychee_db:
@@ -393,6 +394,8 @@ services:
depends_on:
lychee_db:
condition: service_healthy
+ lychee_api:
+ condition: service_healthy
# Worker health check
# Verifies queue:work process is running
diff --git a/docker/nginx.conf b/docker/nginx.conf
new file mode 100644
index 00000000000..901a45a2e30
--- /dev/null
+++ b/docker/nginx.conf
@@ -0,0 +1,102 @@
+user www-data;
+worker_processes auto;
+daemon off;
+
+error_log /var/log/nginx/error.log;
+error_log stderr;
+
+events {
+ worker_connections 1024;
+}
+
+http {
+ include mime.types;
+ default_type application/octet-stream;
+
+ # Maps to exclude successful Docker health checks from stdout
+ map $remote_addr $loggable_ip {
+ 127.0.0.1 "";
+ default 1;
+ }
+ map $status $loggable_status {
+ 200 "";
+ default 1;
+ }
+
+ log_format main '$remote_addr - $remote_user [$time_local] "$request" '
+ '$status $body_bytes_sent "$http_referer" '
+ '"$http_user_agent" "$http_x_forwarded_for"';
+
+ access_log /var/log/nginx/access.log main;
+ access_log /dev/stdout main if=$loggable_status$loggable_ip;
+
+ sendfile on;
+ keepalive_timeout 65;
+
+ # By default, if the processing of images takes more than 60s,
+ # a 504 Gateway timeout occurs, so we increase the timeout here
+ # to allow procesing of large images or when multiple images are
+ # being processed at the same time. We set max_execution_time
+ # below to the same value.
+ fastcgi_read_timeout 3600;
+
+ # We also set the send timeout since this can otherwise also cause
+ # issues with slow connections
+ fastcgi_send_timeout 3600;
+
+ gzip on;
+
+ server {
+ root /app/public;
+ listen 8000;
+ listen [::]:8000;
+ server_name localhost;
+ client_max_body_size 100M;
+
+ index index.php;
+
+ location = /favicon.ico {
+ access_log off;
+ log_not_found off;
+ }
+ location = /robots.txt {
+ access_log off;
+ log_not_found off;
+ }
+
+ # removes trailing slashes (prevents SEO duplicate content issues)
+ if (!-d $request_filename)
+ {
+ rewrite ^/(.+)/$ /$1 permanent;
+ }
+
+ location / {
+ try_files $uri $uri/ /index.php?$query_string;
+ }
+
+ error_page 404 /index.php;
+
+ # Serve /index.php through PHP
+ location ~ ^/index\.php(/|$) {
+ # Mitigate https://httpoxy.org/ vulnerabilities
+ fastcgi_param HTTP_PROXY "";
+
+ fastcgi_pass unix:/var/run/php/php8.5-fpm.sock;
+ fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
+ fastcgi_param PHP_VALUE "post_max_size=100M
+ max_execution_time=3600
+ upload_max_filesize=100M
+ memory_limit=256M";
+ fastcgi_param PATH /usr/local/bin:/usr/bin:/bin;
+ fastcgi_hide_header X-Powered-By;
+ include fastcgi_params;
+ }
+
+ # Deny access to other .php files, rather than exposing their contents
+ location ~ [^/]\.php(/|$) {
+ return 403;
+ }
+ }
+
+ include /etc/nginx/conf.d/*.conf;
+}
\ No newline at end of file
diff --git a/docker/scripts/create-admin-user.sh b/docker/scripts/create-admin-user.sh
index b7c73a9687d..c12b6b8fb78 100644
--- a/docker/scripts/create-admin-user.sh
+++ b/docker/scripts/create-admin-user.sh
@@ -1,4 +1,4 @@
-#!/bin/sh
+#!/usr/bin/env bash
# shellcheck disable=SC3040
set -euo pipefail
@@ -32,4 +32,4 @@ if [ -n "${ADMIN_USER:-}" ]; then
else
echo "⚠️ WARNING: ADMIN_USER set but no password provided (need ADMIN_PASSWORD or ADMIN_PASSWORD_FILE)"
fi
-fi
\ No newline at end of file
+fi
diff --git a/docker/scripts/dump-env.sh b/docker/scripts/dump-env.sh
new file mode 100755
index 00000000000..191538c6bc6
--- /dev/null
+++ b/docker/scripts/dump-env.sh
@@ -0,0 +1,137 @@
+#!/usr/bin/env bash
+# shellcheck disable=SC3040
+set -euo pipefail
+
+echo "📝 Dumping environment variables to .env file..."
+
+ENV_FILE="/app/.env"
+CONFIG_DIR="/app/config"
+
+# List of system/infrastructure variable prefixes to EXCLUDE
+# These are Docker, system, or shell variables that should NOT go into Laravel's .env
+EXCLUDED_PREFIXES="
+PATH
+HOME
+HOSTNAME
+PWD
+OLDPWD
+SHLVL
+TERM
+USER
+LOGNAME
+SHELL
+LANG
+LC_
+DISPLAY
+DOCKER_
+KUBERNETES_
+K8S_
+COMPOSE_
+_
+"
+
+# Extract all env() calls from Laravel config files to build a list of known variables
+KNOWN_VARS=""
+if [ -d "$CONFIG_DIR" ]; then
+ echo " Scanning $CONFIG_DIR for env() calls..."
+ # Find all env() calls in config files and extract the variable names
+ # Matches: env('VAR_NAME'), env("VAR_NAME"), env('VAR_NAME', default), etc.
+ KNOWN_VARS=$(find "$CONFIG_DIR" -name "*.php" -type f -exec grep -oE "env\(['\"]([A-Z0-9_]+)['\"]" {} \; | sed "s/env(['\"]//g" | sed "s/['\"]//g" | sort -u)
+ var_count=$(echo "$KNOWN_VARS" | wc -w)
+ echo " Found $var_count unique environment variables in config files"
+else
+ echo "⚠️ WARNING: Config directory not found at $CONFIG_DIR"
+fi
+
+# Create or truncate the .env file
+>"$ENV_FILE"
+
+# Add a header comment
+cat >>"$ENV_FILE" <<'EOF'
+# Laravel Environment Configuration
+# Auto-generated from environment variables
+# DO NOT EDIT THIS FILE MANUALLY - changes will be overwritten
+#
+EOF
+
+# Function to check if a variable is known from config files
+# Returns 0 (true) if the variable is found in KNOWN_VARS or matches a known prefix
+is_known_var() {
+ var_name="$1"
+
+ # If we have no known vars, accept everything (fallback mode)
+ [ -z "$KNOWN_VARS" ] && return 0
+
+ # Check for exact match
+ for known in $KNOWN_VARS; do
+ if [ "$var_name" = "$known" ]; then
+ return 0
+ fi
+
+ # Check for prefix match (e.g., APP_NAME is in config, accept APP_DEBUG too)
+ case "$known" in
+ *_*)
+ prefix="${known%%_*}_"
+ case "$var_name" in
+ $prefix*)
+ return 0
+ ;;
+ esac
+ ;;
+ esac
+ done
+
+ return 1
+}
+
+# Function to check if a variable should be excluded
+# Returns 0 (true) if the variable is a system variable that should be excluded
+should_exclude_var() {
+ var_name="$1"
+
+ # Check against excluded prefixes
+ for excluded in $EXCLUDED_PREFIXES; do
+ case "$var_name" in
+ $excluded*)
+ return 0
+ ;;
+ esac
+ done
+
+ return 1
+}
+
+# Export all relevant environment variables
+# We use `env` to get all current environment variables
+env | while IFS='=' read -r name value; do
+ # Skip empty names (shouldn't happen, but be safe)
+ [ -z "$name" ] && continue
+
+ # Check if this variable should be excluded or is not known
+ if ! should_exclude_var "$name" && is_known_var "$name"; then
+ # Handle values that may contain special characters or newlines
+ # We need to properly escape the value for the .env file
+
+ # If value contains newline, quotes, or special chars, wrap in quotes
+ case "$value" in
+ *$'\n'* | *\"* | *\'* | *\$* | *\`* | *\\* | *" "* | *"="*)
+ # Escape backslashes and double quotes
+ escaped_value=$(printf '%s' "$value" | sed 's/\\/\\\\/g; s/"/\\"/g')
+ echo "$name=\"$escaped_value\"" >>"$ENV_FILE"
+ ;;
+ *)
+ # Simple value, no quotes needed
+ echo "$name=$value" >>"$ENV_FILE"
+ ;;
+ esac
+ fi
+done
+
+# Ensure the file is readable
+chmod 644 "$ENV_FILE"
+
+echo "✅ Environment variables written to $ENV_FILE"
+
+# Count how many variables were written
+var_count=$(grep -v '^#' "$ENV_FILE" | grep -v '^$' | wc -l)
+echo " Exported $var_count environment variables"
diff --git a/docker/scripts/entrypoint.sh b/docker/scripts/entrypoint.sh
index 743e0d01c44..3dafd5367ae 100644
--- a/docker/scripts/entrypoint.sh
+++ b/docker/scripts/entrypoint.sh
@@ -1,4 +1,4 @@
-#!/bin/sh
+#!/usr/bin/env bash
# shellcheck disable=SC3040
set -euo pipefail
@@ -7,29 +7,40 @@ echo "🚀 Starting Lychee entrypoint..."
# Run environment validation
/usr/local/bin/validate-env.sh
+# This is commended for now as FrankenPHP uses native env vars
+# And we are double checking that php also has access to them
+# If php is complaining, then we can re-enable this.
+
+# Dump environment variables to .env file for Laravel (only if not using FrankenPHP)
+# if [ ! -f "/app/frankenphp_target" ]; then
+# /usr/local/bin/dump-env.sh
+# else
+# echo "ℹ️ Skipping .env dump (FrankenPHP uses native environment variables)"
+# fi
+
# Wait for database to be ready
if [ "${DB_CONNECTION:-}" = "mysql" ] || [ "${DB_CONNECTION:-}" = "pgsql" ]; then
- echo "⏳ Waiting for database to be ready..."
-
- max_attempts=30
- attempt=0
-
- while [ "$attempt" -lt "$max_attempts" ]; do
- if nc -z "${DB_HOST}" "${DB_PORT}" 2>/dev/null; then
- echo "✅ Database port is open!"
- sleep 2 # Give it a moment to fully initialize
- break
- fi
-
- attempt=$((attempt + 1))
- echo " Attempt $attempt/$max_attempts... (waiting 2s)"
- sleep 2
- done
-
- if [ "$attempt" -eq "$max_attempts" ]; then
- echo "❌ ERROR: Database connection timeout"
- exit 1
+ echo "⏳ Waiting for database to be ready..."
+
+ max_attempts=30
+ attempt=0
+
+ while [ "$attempt" -lt "$max_attempts" ]; do
+ if nc -z "${DB_HOST}" "${DB_PORT}" 2>/dev/null; then
+ echo "✅ Database port is open!"
+ sleep 2 # Give it a moment to fully initialize
+ break
fi
+
+ attempt=$((attempt + 1))
+ echo " Attempt $attempt/$max_attempts... (waiting 2s)"
+ sleep 2
+ done
+
+ if [ "$attempt" -eq "$max_attempts" ]; then
+ echo "❌ ERROR: Database connection timeout"
+ exit 1
+ fi
fi
echo "Validating and setting PUID/PGID"
@@ -38,22 +49,26 @@ PGID=${PGID:-33}
# Validate PUID/PGID are within safe ranges (no root, within system limits)
if [ "$PUID" -lt 33 ] || [ "$PUID" -gt 65534 ]; then
- echo "❌ ERROR: PUID must be between 33 and 65534 (got: $PUID)"
- exit 1
+ echo "❌ ERROR: PUID must be between 33 and 65534 (got: $PUID)"
+ exit 1
fi
if [ "$PGID" -lt 33 ] || [ "$PGID" -gt 65534 ]; then
- echo "❌ ERROR: PGID must be between 33 and 65534 (got: $PGID)"
- exit 1
+ echo "❌ ERROR: PGID must be between 33 and 65534 (got: $PGID)"
+ exit 1
fi
-# Only modify user/group if shadow package is available
-if command -v usermod >/dev/null 2>&1; then
+if pgrep -u www-data >/dev/null; then
+ echo "www-data has running processes; skipping usermod"
+else
+ if command -v usermod >/dev/null 2>&1; then
+ # Only modify user/group if shadow package is available
if [ "$(id -u www-data)" -ne "$PUID" ]; then
- usermod -o -u "$PUID" www-data
+ usermod -o -u "$PUID" www-data
fi
if [ "$(id -g www-data)" -ne "$PGID" ]; then
- groupmod -o -g "$PGID" www-data
+ groupmod -o -g "$PGID" www-data
fi
+ fi
fi
echo " User UID: $(id -u www-data)"
echo " User GID: $(id -g www-data)"
@@ -66,125 +81,131 @@ rm -rf bootstrap/cache/*.php
# Check for /conf/.env file - this indicates misconfiguration
if [ -f "/conf/.env" ]; then
- echo "❌ ERROR: /conf/.env file detected"
- echo " Containers should not have mounted .env files at /conf/.env"
- echo " Please check your docker-compose.yml configuration"
- echo " See https://lycheeorg.github.io/docs/upgrade.html"
- exit 1
+ echo "❌ ERROR: /conf/.env file detected"
+ echo " Containers should not have mounted .env files at /conf/.env"
+ echo " Please check your docker-compose.yml configuration"
+ echo " See https://lycheeorg.github.io/docs/upgrade.html"
+ exit 1
fi
# Detect LYCHEE_MODE and execute appropriate command
LYCHEE_MODE=${LYCHEE_MODE:-web}
case "$LYCHEE_MODE" in
- web)
- echo "🌐 Starting Lychee in web mode..."
-
- # Run database migrations (only in web mode to avoid race conditions)
- echo "🔄 Running database migrations..."
- php artisan migrate --force
-
- # Clear and cache configuration
- echo "🧹 Optimizing application..."
- php artisan config:clear
- php artisan config:cache
- php artisan route:cache
- php artisan view:cache
-
- echo "✅ Application ready!"
-
- # Execute the main command (from Dockerfile CMD: octane:start)
- exec "$@"
- ;;
- worker)
- echo "⚙️ Starting Lychee in worker mode..."
-
- # Check for pending migrations (wait for web container to complete them)
- max_migration_attempts=720 # 1h max (720*5s)
- migration_attempt=0
-
- while [ "$migration_attempt" -lt "$max_migration_attempts" ]; do
- # Check if there are pending migrations
- # php artisan migrate:status returns exit code 0 if all migrations are run
- # We check for "Pending" in the output to detect pending migrations
- if php artisan migrate:status 2>/dev/null | grep -q "Pending"; then
- migration_attempt=$((migration_attempt + 1))
- echo "⏳ Pending migrations detected (attempt $migration_attempt/$max_migration_attempts)"
- echo " Waiting 5 seconds for web container to complete migrations..."
- sleep 5
- else
- echo "✅ All migrations are up to date"
- break
- fi
- done
-
- if [ "$migration_attempt" -eq "$max_migration_attempts" ]; then
- echo "⚠️ WARNING: Migrations still pending after ${max_migration_attempts} attempts (1 hour)"
- echo " Starting worker anyway - this may cause issues if migrations are required"
- fi
-
- echo "🔄 Auto-restart enabled: worker will restart if it exits"
-
- # Get queue configuration from environment
- QUEUE_NAMES=${QUEUE_NAMES:-default}
- WORKER_MAX_TIME=${WORKER_MAX_TIME:-3600}
- QUEUE_CONNECTION=${QUEUE_CONNECTION:-sync}
-
- echo "📋 Queue names: $QUEUE_NAMES"
- echo "⏱️ Max time: ${WORKER_MAX_TIME}s"
- echo "📡 Queue connection: $QUEUE_CONNECTION"
-
- # Warn if using sync driver (not recommended for worker mode)
- if [ "$QUEUE_CONNECTION" = "sync" ]; then
- echo "⚠️ WARNING: QUEUE_CONNECTION=sync is not recommended for worker mode."
- echo " Jobs will run synchronously, defeating the purpose of a queue worker."
- echo " Consider using 'redis' or 'database' for persistent asynchronous queues."
- fi
-
- # Track if we should keep running
- KEEP_RUNNING=true
-
- # Handle graceful shutdown
- trap 'echo "🛑 Received shutdown signal, stopping..."; KEEP_RUNNING=false' TERM INT
-
- # Auto-restart loop: if queue:work exits, restart it
- # This handles memory leak mitigation (max-time) and crash recovery
- while $KEEP_RUNNING; do
- echo "🚀 Starting queue worker ($(date '+%Y-%m-%d %H:%M:%S'))"
-
- # Default exit code to 0
- EXIT_CODE=0
-
- # Run queue worker with standard options
- # --tries=3: retry failed jobs up to 3 times
- # --timeout=3600: kill job if it runs longer than 1 hour
- # --sleep=3: sleep 3 seconds when queue is empty
- # --max-time=$WORKER_MAX_TIME: restart worker after N seconds (memory leak mitigation)
- php artisan queue:work \
- --queue="$QUEUE_NAMES" \
- --tries=3 \
- --timeout=3600 \
- --sleep=3 \
- --max-time="$WORKER_MAX_TIME" || EXIT_CODE=$?
-
- if [ $EXIT_CODE -eq 0 ]; then
- echo "✅ Queue worker exited cleanly (exit code 0)"
- else
- echo "⚠️ Queue worker exited with code $EXIT_CODE"
- fi
-
- # Exit if we received shutdown signal
- if ! $KEEP_RUNNING; then
- echo "👋 Shutting down worker..."
- exit $EXIT_CODE
- fi
-
- echo "⏳ Waiting 5 seconds before restart..."
- sleep 5
- done
- ;;
- *)
- echo "❌ ERROR: Invalid LYCHEE_MODE: $LYCHEE_MODE. Must be 'web' or 'worker'."
- exit 1
- ;;
+web)
+ echo "🌐 Starting Lychee in web mode..."
+
+ # Run database migrations (only in web mode to avoid race conditions)
+ echo "🔄 Running database migrations..."
+ php artisan migrate --force
+
+ # Clear and cache configuration
+ echo "🧹 Optimizing application..."
+ php artisan config:clear
+ php artisan config:cache
+ php artisan route:cache
+ php artisan view:cache
+
+ echo "✅ Application ready!"
+
+ if [ ! -f "/app/frankenphp_target" ]; then
+ # Start PHP-FPM and Nginx for traditional Docker setup
+ echo "🚀 Starting PHP-FPM..."
+ php-fpm8.5
+ fi
+
+ # Execute the main command (from Dockerfile CMD: octane:start)
+ exec "$@"
+ ;;
+worker)
+ echo "⚙️ Starting Lychee in worker mode..."
+
+ # Check for pending migrations (wait for web container to complete them)
+ max_migration_attempts=720 # 1h max (720*5s)
+ migration_attempt=0
+
+ while [ "$migration_attempt" -lt "$max_migration_attempts" ]; do
+ # Check if there are pending migrations
+ # php artisan migrate:status returns exit code 0 if all migrations are run
+ # We check for "Pending" in the output to detect pending migrations
+ if php artisan migrate:status 2>/dev/null | grep -q "Pending"; then
+ migration_attempt=$((migration_attempt + 1))
+ echo "⏳ Pending migrations detected (attempt $migration_attempt/$max_migration_attempts)"
+ echo " Waiting 5 seconds for web container to complete migrations..."
+ sleep 5
+ else
+ echo "✅ All migrations are up to date"
+ break
+ fi
+ done
+
+ if [ "$migration_attempt" -eq "$max_migration_attempts" ]; then
+ echo "⚠️ WARNING: Migrations still pending after ${max_migration_attempts} attempts (1 hour)"
+ echo " Starting worker anyway - this may cause issues if migrations are required"
+ fi
+
+ echo "🔄 Auto-restart enabled: worker will restart if it exits"
+
+ # Get queue configuration from environment
+ QUEUE_NAMES=${QUEUE_NAMES:-default}
+ WORKER_MAX_TIME=${WORKER_MAX_TIME:-3600}
+ QUEUE_CONNECTION=${QUEUE_CONNECTION:-sync}
+
+ echo "📋 Queue names: $QUEUE_NAMES"
+ echo "⏱️ Max time: ${WORKER_MAX_TIME}s"
+ echo "📡 Queue connection: $QUEUE_CONNECTION"
+
+ # Warn if using sync driver (not recommended for worker mode)
+ if [ "$QUEUE_CONNECTION" = "sync" ]; then
+ echo "⚠️ WARNING: QUEUE_CONNECTION=sync is not recommended for worker mode."
+ echo " Jobs will run synchronously, defeating the purpose of a queue worker."
+ echo " Consider using 'redis' or 'database' for persistent asynchronous queues."
+ fi
+
+ # Track if we should keep running
+ KEEP_RUNNING=true
+
+ # Handle graceful shutdown
+ trap 'echo "🛑 Received shutdown signal, stopping..."; KEEP_RUNNING=false' TERM INT
+
+ # Auto-restart loop: if queue:work exits, restart it
+ # This handles memory leak mitigation (max-time) and crash recovery
+ while $KEEP_RUNNING; do
+ echo "🚀 Starting queue worker ($(date '+%Y-%m-%d %H:%M:%S'))"
+
+ # Default exit code to 0
+ EXIT_CODE=0
+
+ # Run queue worker with standard options
+ # --tries=3: retry failed jobs up to 3 times
+ # --timeout=3600: kill job if it runs longer than 1 hour
+ # --sleep=3: sleep 3 seconds when queue is empty
+ # --max-time=$WORKER_MAX_TIME: restart worker after N seconds (memory leak mitigation)
+ php artisan queue:work \
+ --queue="$QUEUE_NAMES" \
+ --tries=3 \
+ --timeout=3600 \
+ --sleep=3 \
+ --max-time="$WORKER_MAX_TIME" || EXIT_CODE=$?
+
+ if [ $EXIT_CODE -eq 0 ]; then
+ echo "✅ Queue worker exited cleanly (exit code 0)"
+ else
+ echo "⚠️ Queue worker exited with code $EXIT_CODE"
+ fi
+
+ # Exit if we received shutdown signal
+ if ! $KEEP_RUNNING; then
+ echo "👋 Shutting down worker..."
+ exit $EXIT_CODE
+ fi
+
+ echo "⏳ Waiting 5 seconds before restart..."
+ sleep 5
+ done
+ ;;
+*)
+ echo "❌ ERROR: Invalid LYCHEE_MODE: $LYCHEE_MODE. Must be 'web' or 'worker'."
+ exit 1
+ ;;
esac
diff --git a/docker/scripts/permissions-check.sh b/docker/scripts/permissions-check.sh
index 9c7471cc820..c7ec08b0646 100644
--- a/docker/scripts/permissions-check.sh
+++ b/docker/scripts/permissions-check.sh
@@ -1,4 +1,4 @@
-#!/bin/sh
+#!/usr/bin/env bash
# shellcheck disable=SC3040
set -euo pipefail
@@ -7,8 +7,8 @@ echo "🔍 Validating permissions..."
# Safely check SKIP_PERMISSIONS_CHECKS
skip_check="${SKIP_PERMISSIONS_CHECKS:-no}"
if [ "$skip_check" = "yes" ] || [ "$skip_check" = "YES" ]; then
- echo "⚠️ WARNING: Skipping permissions check"
- exit 0
+ echo "⚠️ WARNING: Skipping permissions check"
+ exit 0
fi
echo "⏰ Set Permissions (this may take a while)..."
@@ -35,4 +35,4 @@ find /app/storage -type f \( ! -perm 640 \) -exec chmod 640 {} + 2>/dev/null ||
find /app/public/uploads -type f \( ! -perm 644 \) -exec chmod 644 {} + 2>/dev/null || true
find /app/public/dist -type f \( ! -perm 644 \) -exec chmod 644 {} + 2>/dev/null || true
-echo "✅ Permissions set securely"
\ No newline at end of file
+echo "✅ Permissions set securely"
diff --git a/docker/scripts/validate-env.sh b/docker/scripts/validate-env.sh
index 75f6dfe9d21..ee2a0792f27 100644
--- a/docker/scripts/validate-env.sh
+++ b/docker/scripts/validate-env.sh
@@ -1,4 +1,4 @@
-#!/bin/sh
+#!/usr/bin/env bash
# shellcheck disable=SC3040
set -euo pipefail
@@ -10,29 +10,29 @@ echo "🔍 Validating environment variables..."
# Check if APP_KEY exists, with fallback mechanisms
if [ -z "${APP_KEY:-}" ]; then
- # Check if APP_KEY_FILE is set and load from file
- if [ -n "${APP_KEY_FILE:-}" ] && [ -f "$APP_KEY_FILE" ]; then
- APP_KEY=$(cat "$APP_KEY_FILE")
- export APP_KEY
- # Fallback to /app/.env if it exists
- elif [ -f "/app/.env" ]; then
- APP_KEY=$(grep "^APP_KEY=" /app/.env | cut -d= -f2- | tr -d '"' | tr -d "'")
- export APP_KEY
- fi
+ # Check if APP_KEY_FILE is set and load from file
+ if [ -n "${APP_KEY_FILE:-}" ] && [ -f "$APP_KEY_FILE" ]; then
+ APP_KEY=$(cat "$APP_KEY_FILE")
+ export APP_KEY
+ # Fallback to /app/.env if it exists
+ elif [ -f "/app/.env" ]; then
+ APP_KEY=$(grep "^APP_KEY=" /app/.env | cut -d= -f2- | tr -d '"' | tr -d "'")
+ export APP_KEY
+ fi
fi
# Error out if APP_KEY is still empty
if [ -z "${APP_KEY:-}" ]; then
- echo "❌ ERROR: APP_KEY is not set"
- echo " Set it via APP_KEY environment variable, APP_KEY_FILE, or /app/.env"
- exit 1
+ echo "❌ ERROR: APP_KEY is not set"
+ echo " Set it via APP_KEY environment variable, APP_KEY_FILE, or /app/.env"
+ exit 1
fi
# Validate APP_KEY format (should be base64:... for Laravel)
if ! echo "${APP_KEY}" | grep -qE '^base64:.{32,}'; then
- echo "❌ ERROR: APP_KEY must be in format 'base64:...' with sufficient length"
- echo " Generate one with: php artisan key:generate --show"
- exit 1
+ echo "❌ ERROR: APP_KEY must be in format 'base64:...' with sufficient length"
+ echo " Generate one with: php artisan key:generate --show"
+ exit 1
fi
###########################
@@ -40,55 +40,55 @@ fi
###########################
if [ -z "${DB_CONNECTION:-}" ]; then
- echo "❌ ERROR: DB_CONNECTION is not set"
- exit 1
+ echo "❌ ERROR: DB_CONNECTION is not set"
+ exit 1
fi
# Validate DB_CONNECTION value
case "${DB_CONNECTION}" in
- mysql|pgsql|sqlite)
- echo "✅ Valid database connection type: ${DB_CONNECTION}"
- ;;
- *)
- echo "❌ ERROR: DB_CONNECTION must be mysql, pgsql, or sqlite (got: ${DB_CONNECTION})"
- exit 1
- ;;
+mysql | pgsql | sqlite)
+ echo "✅ Valid database connection type: ${DB_CONNECTION}"
+ ;;
+*)
+ echo "❌ ERROR: DB_CONNECTION must be mysql, pgsql, or sqlite (got: ${DB_CONNECTION})"
+ exit 1
+ ;;
esac
# Validate database credentials are set for mysql/pgsql
if [ "${DB_CONNECTION}" = "mysql" ] || [ "${DB_CONNECTION}" = "pgsql" ]; then
- if [ -z "${DB_HOST:-}" ]; then
- echo "❌ ERROR: DB_HOST is required for ${DB_CONNECTION}"
- exit 1
- fi
- if [ -z "${DB_DATABASE:-}" ]; then
- echo "❌ ERROR: DB_DATABASE is required for ${DB_CONNECTION}"
- exit 1
- fi
- if [ -z "${DB_USERNAME:-}" ]; then
- echo "❌ ERROR: DB_USERNAME is required for ${DB_CONNECTION}"
- exit 1
- fi
-
- # Check if DB_PASSWORD exists, with fallback mechanisms
- if [ -z "${DB_PASSWORD:-}" ]; then
- # Check if DB_PASSWORD_FILE is set and load from file
- if [ -n "${DB_PASSWORD_FILE:-}" ] && [ -f "$DB_PASSWORD_FILE" ]; then
- DB_PASSWORD=$(cat "$DB_PASSWORD_FILE")
- export DB_PASSWORD
- # Fallback to /app/.env if it exists
- elif [ -f "/app/.env" ]; then
- DB_PASSWORD=$(grep "^DB_PASSWORD=" /app/.env | cut -d= -f2- | tr -d '"' | tr -d "'")
- export DB_PASSWORD
- fi
+ if [ -z "${DB_HOST:-}" ]; then
+ echo "❌ ERROR: DB_HOST is required for ${DB_CONNECTION}"
+ exit 1
+ fi
+ if [ -z "${DB_DATABASE:-}" ]; then
+ echo "❌ ERROR: DB_DATABASE is required for ${DB_CONNECTION}"
+ exit 1
+ fi
+ if [ -z "${DB_USERNAME:-}" ]; then
+ echo "❌ ERROR: DB_USERNAME is required for ${DB_CONNECTION}"
+ exit 1
+ fi
+
+ # Check if DB_PASSWORD exists, with fallback mechanisms
+ if [ -z "${DB_PASSWORD:-}" ]; then
+ # Check if DB_PASSWORD_FILE is set and load from file
+ if [ -n "${DB_PASSWORD_FILE:-}" ] && [ -f "$DB_PASSWORD_FILE" ]; then
+ DB_PASSWORD=$(cat "$DB_PASSWORD_FILE")
+ export DB_PASSWORD
+ # Fallback to /app/.env if it exists
+ elif [ -f "/app/.env" ]; then
+ DB_PASSWORD=$(grep "^DB_PASSWORD=" /app/.env | cut -d= -f2- | tr -d '"' | tr -d "'")
+ export DB_PASSWORD
fi
+ fi
- # Error out if DB_PASSWORD is still empty
- if [ -z "${DB_PASSWORD:-}" ]; then
- echo "❌ ERROR: DB_PASSWORD is not set"
- echo " Set it via DB_PASSWORD environment variable, DB_PASSWORD_FILE, or /app/.env"
- exit 1
- fi
+ # Error out if DB_PASSWORD is still empty
+ if [ -z "${DB_PASSWORD:-}" ]; then
+ echo "❌ ERROR: DB_PASSWORD is not set"
+ echo " Set it via DB_PASSWORD environment variable, DB_PASSWORD_FILE, or /app/.env"
+ exit 1
+ fi
fi
###########################
@@ -97,42 +97,41 @@ fi
# Check if REDIS_PASSWORD exists, with fallback mechanisms (only if Redis is being used)
if [ -n "${REDIS_HOST:-}" ] || [ -n "${REDIS_PASSWORD:-}" ] || [ -n "${REDIS_PASSWORD_FILE:-}" ]; then
- if [ -z "${REDIS_PASSWORD:-}" ]; then
- # Check if REDIS_PASSWORD_FILE is set and load from file
- if [ -n "${REDIS_PASSWORD_FILE:-}" ] && [ -f "$REDIS_PASSWORD_FILE" ]; then
- REDIS_PASSWORD=$(cat "$REDIS_PASSWORD_FILE")
- export REDIS_PASSWORD
- # Fallback to /app/.env if it exists
- elif [ -f "/app/.env" ]; then
- REDIS_PASSWORD=$(grep "^REDIS_PASSWORD=" /app/.env | cut -d= -f2- | tr -d '"' | tr -d "'")
- export REDIS_PASSWORD
- fi
+ if [ -z "${REDIS_PASSWORD:-}" ]; then
+ # Check if REDIS_PASSWORD_FILE is set and load from file
+ if [ -n "${REDIS_PASSWORD_FILE:-}" ] && [ -f "$REDIS_PASSWORD_FILE" ]; then
+ REDIS_PASSWORD=$(cat "$REDIS_PASSWORD_FILE")
+ export REDIS_PASSWORD
+ # Fallback to /app/.env if it exists
+ elif [ -f "/app/.env" ]; then
+ REDIS_PASSWORD=$(grep "^REDIS_PASSWORD=" /app/.env | cut -d= -f2- | tr -d '"' | tr -d "'")
+ export REDIS_PASSWORD
fi
+ fi
fi
###########################
# ADDITIONAL ENV #
###########################
-
# Validate APP_ENV
if [ -n "${APP_ENV:-}" ]; then
- case "${APP_ENV}" in
- production|staging|development|local|testing)
- echo "✅ Valid environment: ${APP_ENV}"
- ;;
- *)
- echo "⚠️ WARNING: Unusual APP_ENV value: ${APP_ENV}"
- ;;
- esac
+ case "${APP_ENV}" in
+ production | staging | development | local | testing)
+ echo "✅ Valid environment: ${APP_ENV}"
+ ;;
+ *)
+ echo "⚠️ WARNING: Unusual APP_ENV value: ${APP_ENV}"
+ ;;
+ esac
fi
# Security checks
if [ "${APP_ENV:-production}" = "production" ]; then
- if [ "${APP_DEBUG:-false}" = "true" ]; then
- echo "⚠️ WARNING: APP_DEBUG is enabled in production - this exposes sensitive information!"
- fi
+ if [ "${APP_DEBUG:-false}" = "true" ]; then
+ echo "⚠️ WARNING: APP_DEBUG is enabled in production - this exposes sensitive information!"
+ fi
fi
echo "✅ Core variables validated"
-echo "🎉 Environment validation complete!"
\ No newline at end of file
+echo "🎉 Environment validation complete!"