Make container deploy match the native VPS deploy

Pier rebuilds the container on every commit, so the image is now the real
runtime. Bring it to parity with scripts/deploy-staging-vps.sh and make bad
builds fail loudly instead of silently drifting:

- Entrypoint mirrors the VPS runtime steps: migrate --force, config:cache and
  view:cache (routes are NOT cached because the /up closure route can't be
  serialized), favicon generation, and storage permission fixes.
- Run web + queue:work + schedule:work under supervisor in one container.
  Previously nothing processed the database queue and no scheduler ran, so
  jobs and scheduled tasks never executed.
- Declare /app/storage/app as a VOLUME so a Pier persistent volume keeps
  uploaded attachments across rebuilds (they were on ephemeral disk before).
- Add a HEALTHCHECK on the public /login route so Pier can detect a boot that
  never becomes healthy (/up is IP-restricted and unsuitable from loopback).
- composer install now uses --optimize-autoloader, matching the VPS deploy.
- Bake trustProxies into bootstrap/app.php and drop the fragile sed injection
  that silently no-opped if the file was reformatted.
- Add .dockerignore so local .env/vendor/node_modules are never baked into
  image layers and builds stay small.
- Set PHP_CLI_SERVER_WORKERS so artisan serve handles concurrent requests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015qMh7msL8qSXMA9nSLTY38
This commit is contained in:
Claude 2026-07-14 00:03:46 +00:00
parent f3952b8d55
commit ef083fd478
No known key found for this signature in database
5 changed files with 124 additions and 7 deletions

18
.dockerignore Normal file
View File

@ -0,0 +1,18 @@
# Keep the build context small and avoid baking host-only / secret files
# into image layers. composer install and npm ci regenerate vendor/ and
# node_modules/ inside the build, so the local copies must never be shipped.
.git
.gitignore
.github
node_modules
vendor
.env
.env.*
!.env.example
storage/logs/*
storage/framework/cache/data/*
storage/framework/sessions/*
storage/framework/views/*
.phpunit.result.cache
tests
*.md

View File

@ -1,7 +1,7 @@
FROM node:22-alpine AS node
FROM php:8.4-fpm-alpine
RUN apk add --no-cache icu-libs libzip libpng libjpeg-turbo freetype libxml2 oniguruma curl libpq
RUN apk add --no-cache icu-libs libzip libpng libjpeg-turbo freetype libxml2 oniguruma curl libpq supervisor
RUN apk add --no-cache --virtual .build-deps $PHPIZE_DEPS icu-dev libzip-dev libpng-dev libjpeg-turbo-dev freetype-dev libxml2-dev oniguruma-dev postgresql-dev \
&& docker-php-ext-configure gd --with-freetype --with-jpeg \
@ -15,19 +15,33 @@ COPY --from=node /usr/local/lib/node_modules /usr/local/lib/node_modules
COPY --from=node /usr/local/bin/node /usr/local/bin/node
RUN ln -s /usr/local/lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm
# Let `php artisan serve` handle concurrent requests instead of one at a time.
ENV PHP_CLI_SERVER_WORKERS=4
WORKDIR /app
COPY . .
RUN sed -i '/->withMiddleware(function (Middleware $middleware): void {/a\ $middleware->trustProxies(at: "*");' bootstrap/app.php \
&& composer install --no-dev --no-interaction --no-progress \
RUN composer install --no-dev --optimize-autoloader --no-interaction --no-progress \
&& npm ci --no-audit --no-fund \
&& npm run build \
&& rm -rf node_modules \
&& chmod +x docker/entrypoint.sh \
&& mkdir -p storage/logs storage/framework/cache storage/framework/sessions storage/framework/views storage/app/private/livewire-tmp storage/app/public bootstrap/cache \
&& chmod -R 777 storage bootstrap/cache
# Uploaded attachments live under storage/app (local disk root: storage/app/private).
# Declare it as a volume so a Pier persistent volume can be mounted here and
# files survive container rebuilds.
VOLUME ["/app/storage/app"]
EXPOSE 8080
# Apply any pending database migrations before serving so container-based
# deploys stay in sync with the codebase (the VPS deploy script does this too).
# Fail fast if migrations can't be applied rather than serving a stale schema.
CMD ["sh", "-c", "php artisan migrate --force && php artisan serve --host=0.0.0.0 --port=8080"]
# Liveness probe. /login is public (200); /up is IP-restricted and would 404
# from the loopback interface, so it is not suitable as a container healthcheck.
HEALTHCHECK --interval=30s --timeout=5s --start-period=40s --retries=3 \
CMD curl -fsS http://127.0.0.1:8080/login >/dev/null || exit 1
# entrypoint runs migrations + cache warming, then execs supervisor, which
# supervises the web server, queue worker, and scheduler in one container.
ENTRYPOINT ["/app/docker/entrypoint.sh"]
CMD ["supervisord", "-c", "/app/docker/supervisord.conf"]

View File

@ -29,6 +29,7 @@ return Application::configure(basePath: dirname(__DIR__))
},
)
->withMiddleware(function (Middleware $middleware): void {
$middleware->trustProxies(at: '*');
$middleware->redirectGuestsTo('/login');
$middleware->append(SecurityHeaders::class);
$middleware->web(append: [

37
docker/entrypoint.sh Normal file
View File

@ -0,0 +1,37 @@
#!/bin/sh
# Container entrypoint: mirrors the runtime steps of scripts/deploy-staging-vps.sh
# so the containerized app behaves the same as the native VPS deploy.
# Runs once per container start, before supervisor launches the processes.
set -e
echo "[entrypoint] Clearing stale caches..."
php artisan optimize:clear || true
echo "[entrypoint] Running database migrations..."
php artisan migrate --force
# Cache config and views (same as the VPS deploy). Routes are intentionally
# NOT cached: the /up health route is a closure and cannot be serialized.
echo "[entrypoint] Caching config and views..."
php artisan config:cache
php artisan view:cache
echo "[entrypoint] Generating favicons..."
php scripts/generate-favicons.php || echo "[entrypoint] favicon generation skipped"
# Recreate storage dirs at runtime: the persistent volume mounted at
# storage/app shadows anything created there at build time, so Livewire's
# temp-upload dir must be ensured here or "Attach" fails silently.
echo "[entrypoint] Ensuring storage directories exist and are writable..."
mkdir -p \
storage/app/private/livewire-tmp \
storage/app/public \
storage/framework/cache/data \
storage/framework/sessions \
storage/framework/views \
storage/logs \
bootstrap/cache
chmod -R 775 storage bootstrap/cache || true
echo "[entrypoint] Starting supervisor..."
exec "$@"

47
docker/supervisord.conf Normal file
View File

@ -0,0 +1,47 @@
[supervisord]
nodaemon=true
user=root
logfile=/dev/stdout
logfile_maxbytes=0
pidfile=/tmp/supervisord.pid
; HTTP server. artisan serve is single-threaded per worker; PHP_CLI_SERVER_WORKERS
; (set in the Dockerfile) lets it handle concurrent requests.
[program:web]
command=php artisan serve --host=0.0.0.0 --port=8080
directory=/app
autostart=true
autorestart=true
startretries=10
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
priority=10
; Queue worker (QUEUE_CONNECTION=database). Without this, dispatched jobs
; (broadcast emails, heartbeats) would never be processed.
[program:queue]
command=php artisan queue:work --sleep=3 --tries=3 --max-time=3600
directory=/app
autostart=true
autorestart=true
stopwaitsecs=3630
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
priority=20
; Scheduler. schedule:work is the container-friendly equivalent of a
; "* * * * * schedule:run" cron entry; it stays in the foreground.
[program:scheduler]
command=php artisan schedule:work
directory=/app
autostart=true
autorestart=true
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
priority=30