Initial commit: Pireon Admin — headless admin panel and public API
Laravel 13 + Filament 5 admin panel and REST API for a non-profit developer collective, backed by the Pireon OAuth identity server. The public site is a separate, server-rendered application consuming /api/public/v1. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
53
docker/Caddyfile
Normal file
53
docker/Caddyfile
Normal file
@@ -0,0 +1,53 @@
|
||||
{
|
||||
# The panel is the only thing served, and it is behind a login: no reason
|
||||
# for the server to announce its version to anybody scanning.
|
||||
frankenphp
|
||||
order php_server before file_server
|
||||
auto_https off
|
||||
servers {
|
||||
trusted_proxies static private_ranges
|
||||
}
|
||||
}
|
||||
|
||||
{$SERVER_NAME:-:80} {
|
||||
root * /app/public
|
||||
|
||||
encode zstd gzip
|
||||
|
||||
# Uploaded images. `public/storage` is a symlink into the storage volume,
|
||||
# so this is what actually serves what the collective uploads — and it is
|
||||
# the one path an SSR frontend fetches on every page view, so it is cached
|
||||
# hard and never touched by PHP.
|
||||
@media path /storage/*
|
||||
header @media Cache-Control "public, max-age=31536000, immutable"
|
||||
|
||||
# The frontend is a separate application on another origin.
|
||||
@api path /api/*
|
||||
header @api {
|
||||
X-Content-Type-Options nosniff
|
||||
}
|
||||
|
||||
header {
|
||||
X-Content-Type-Options nosniff
|
||||
X-Frame-Options SAMEORIGIN
|
||||
Referrer-Policy strict-origin-when-cross-origin
|
||||
-Server
|
||||
}
|
||||
|
||||
php_server {
|
||||
# Laravel's own health route, so the container can be probed without
|
||||
# touching the database.
|
||||
try_files {path} /index.php
|
||||
}
|
||||
|
||||
# The health probe runs every 30 seconds and would otherwise be most of
|
||||
# the access log. `log_skip` is the directive for this — `exclude` inside
|
||||
# `log` is not valid in Caddy 2.
|
||||
@health path /up
|
||||
log_skip @health
|
||||
|
||||
log {
|
||||
output stdout
|
||||
format json
|
||||
}
|
||||
}
|
||||
91
docker/entrypoint.sh
Normal file
91
docker/entrypoint.sh
Normal file
@@ -0,0 +1,91 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
###############################################################################
|
||||
# Everything that has to be true before the application answers a request.
|
||||
#
|
||||
# Runs in every container built from this image, but not every container is a
|
||||
# service: `docker compose run app php artisan key:generate` has to work on a
|
||||
# stack that has never been started, which is exactly when there is no APP_KEY
|
||||
# and no database yet. So the preparation below applies to the three
|
||||
# long-running commands, and anything else is passed straight through.
|
||||
###############################################################################
|
||||
|
||||
case "$*" in
|
||||
frankenphp*|*queue:work*|*schedule:work*) is_service=true ;;
|
||||
*) is_service=false ;;
|
||||
esac
|
||||
|
||||
# A named volume mounted over storage/ starts as a copy of the image's, but a
|
||||
# bind mount does not, and neither survives a container being recreated with an
|
||||
# empty one. Cheap to guarantee, expensive to debug — and needed by one-off
|
||||
# commands too, since most of them write a log.
|
||||
mkdir -p \
|
||||
storage/app/public \
|
||||
storage/framework/cache/data \
|
||||
storage/framework/sessions \
|
||||
storage/framework/views \
|
||||
storage/logs
|
||||
|
||||
chown -R www-data:www-data storage bootstrap/cache 2>/dev/null || true
|
||||
|
||||
if [ "$is_service" = "false" ]; then
|
||||
exec "$@"
|
||||
fi
|
||||
|
||||
if [ -z "${APP_KEY}" ]; then
|
||||
echo "APP_KEY is not set. Generate one with:" >&2
|
||||
echo " docker compose run --rm --no-deps app php artisan key:generate --show" >&2
|
||||
echo "Without it sessions cannot be decrypted and preview links cannot be signed." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Wait for Postgres rather than crash-looping against it. Compose's own
|
||||
# healthcheck covers the ordinary case; this covers the database restarting
|
||||
# under a running application.
|
||||
if [ "${DB_CONNECTION:-pgsql}" = "pgsql" ]; then
|
||||
tries=0
|
||||
until pg_isready -h "${DB_HOST:-postgres}" -p "${DB_PORT:-5432}" -U "${DB_USERNAME:-pireon}" -q; do
|
||||
tries=$((tries + 1))
|
||||
if [ "$tries" -ge 30 ]; then
|
||||
echo "Database at ${DB_HOST:-postgres}:${DB_PORT:-5432} never became ready." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "Waiting for the database… (${tries}/30)"
|
||||
sleep 2
|
||||
done
|
||||
fi
|
||||
|
||||
# Opt-in rather than automatic: a migration is a change to somebody's data, and
|
||||
# on a multi-container deploy it must run once, not once per container. Set it
|
||||
# on the web service only.
|
||||
if [ "${RUN_MIGRATIONS}" = "true" ]; then
|
||||
echo "Running migrations…"
|
||||
# CACHE_STORE is overridden for this one command, not passed to
|
||||
# `--isolated` — that option's value is only an exit code, never a store.
|
||||
#
|
||||
# It matters because with CACHE_STORE=database the lock table is created by
|
||||
# these very migrations, so on a brand new database the lock would be taken
|
||||
# against a table that does not exist yet and the container would crash-loop
|
||||
# for ever. The file store lives on the shared storage volume, so it still
|
||||
# stops two containers migrating at once.
|
||||
CACHE_STORE=file php artisan migrate --force --isolated
|
||||
|
||||
# The permission catalogue is code, so a deploy that adds one has to sync
|
||||
# it or the new permission exists nowhere.
|
||||
php artisan permissions:sync
|
||||
fi
|
||||
|
||||
# Serves uploaded images from the storage volume. Idempotent, and it has to run
|
||||
# here rather than at build time because the volume is mounted after the image
|
||||
# is built.
|
||||
php artisan storage:link --quiet 2>/dev/null || true
|
||||
|
||||
# Config, routes and views compiled once per container start. This is most of
|
||||
# the difference between an API answering in 4ms and in 40, which matters when
|
||||
# an SSR frontend makes several calls per page view.
|
||||
php artisan config:cache --quiet
|
||||
php artisan route:cache --quiet
|
||||
php artisan view:cache --quiet
|
||||
|
||||
exec "$@"
|
||||
28
docker/php.ini
Normal file
28
docker/php.ini
Normal file
@@ -0,0 +1,28 @@
|
||||
; Production PHP settings.
|
||||
|
||||
expose_php = Off
|
||||
memory_limit = 256M
|
||||
|
||||
; Uploads are images: a phone photo is routinely over 8 MB, and the processor
|
||||
; downsizes them afterwards anyway.
|
||||
upload_max_filesize = 24M
|
||||
post_max_size = 26M
|
||||
max_execution_time = 60
|
||||
|
||||
; The image processor holds a decoded bitmap in memory — a 24 megapixel photo
|
||||
; is roughly 100 MB decoded — and config/media.php refuses anything larger
|
||||
; rather than trying.
|
||||
;
|
||||
; opcache is what makes the difference between an SSR frontend waiting 40ms
|
||||
; and 4ms per API call.
|
||||
opcache.enable = 1
|
||||
opcache.memory_consumption = 128
|
||||
opcache.max_accelerated_files = 20000
|
||||
opcache.validate_timestamps = 0
|
||||
opcache.interned_strings_buffer = 16
|
||||
|
||||
; Errors go to the log, never to the response: this API answers a frontend
|
||||
; that would otherwise render a stack trace into a public page.
|
||||
display_errors = Off
|
||||
log_errors = On
|
||||
error_log = /dev/stderr
|
||||
Reference in New Issue
Block a user