From af4942b86d4a37dccb1f0770f741c1f979d93f6d Mon Sep 17 00:00:00 2001 From: Timothy Date: Tue, 3 Nov 2026 18:00:01 +0000 Subject: [PATCH 01/20] Add Docker deployment to feature list --- .dockerignore | 19 +++ .env.docker.example | 30 ++++ .github/workflows/docker.yml | 57 +++++++ .gitignore | 2 + Dockerfile | 51 ++++++ bootstrap/app.php | 5 + compose.yaml | 70 +++++++++ docker/apache.conf | 11 ++ docker/entrypoint.sh | 29 ++++ docker/php.ini | 8 + docs/DOCKER.md | 293 +++++++++++++++++++++++++++++++++++ readme.md | 4 + tests/docker-smoke.py | 63 ++++++++ 13 files changed, 642 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.docker.example create mode 100644 .github/workflows/docker.yml create mode 100644 Dockerfile create mode 100644 compose.yaml create mode 100644 docker/apache.conf create mode 100644 docker/entrypoint.sh create mode 100644 docker/php.ini create mode 100644 docs/DOCKER.md create mode 100644 tests/docker-smoke.py diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..cc7ec03c --- /dev/null +++ b/.dockerignore @@ -0,0 +1,19 @@ +.git +.env +.env.* +!.env.example +node_modules +vendor +public/build +public/hot +public/storage +bootstrap/cache/*.php +storage/** +!storage/**/.gitignore +config/renewal.php +config/renewr.php +*.log +*.sql.gz +*-backup.sql +*.tar.gz +backups diff --git a/.env.docker.example b/.env.docker.example new file mode 100644 index 00000000..a787f657 --- /dev/null +++ b/.env.docker.example @@ -0,0 +1,30 @@ +APP_NAME=phpIP +COMPANY_NAME="My Company" +APP_ENV=production +APP_DEBUG=false +APP_KEY= +APP_URL=http://localhost:8000 +APP_BIND_ADDRESS=127.0.0.1 +APP_PORT=8000 +APP_LOCALE=en_US +APP_TIMEZONE=America/Chicago +# Optional comma-separated IPs/CIDRs of TLS-terminating reverse proxies. +TRUSTED_PROXIES= + +DB_DATABASE=phpip +DB_USERNAME=phpip +DB_PASSWORD= +MYSQL_ROOT_PASSWORD= + +# Log mail locally until SMTP and recipients are configured. +MAIL_MAILER=log +MAIL_FROM_ADDRESS=noreply@example.com +MAIL_FROM_NAME=phpIP +MAIL_TO= + +# Configure integrations before enabling the scheduler profile. +OPS_APP_KEY= +OPS_SECRET= +RENEWR_API_URL=https://demo-api.renewr.io/api/external/patent/with-renewal-event-and-fees +RENEWR_API_KEY= +SHAREPOINT_ENABLED=false diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml new file mode 100644 index 00000000..7048dc5b --- /dev/null +++ b/.github/workflows/docker.yml @@ -0,0 +1,57 @@ +name: Docker + +on: + pull_request: + push: + branches: [main, master] + workflow_dispatch: + +permissions: + contents: read + +jobs: + smoke: + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v4 + - name: Create isolated test configuration + run: | + python3 - <<'PY' + import base64, pathlib, secrets + text = pathlib.Path('.env.docker.example').read_text() + values = { + 'APP_KEY': 'base64:' + base64.b64encode(secrets.token_bytes(32)).decode(), + 'DB_PASSWORD': secrets.token_hex(24), + 'MYSQL_ROOT_PASSWORD': secrets.token_hex(24), + } + for key, value in values.items(): + text = text.replace(key + '=\n', key + '=' + value + '\n') + pathlib.Path('.env.docker').write_text(text) + PY + - name: Build and initialize + run: | + docker compose --env-file .env.docker config --quiet + docker compose --env-file .env.docker build app + docker compose --env-file .env.docker up -d --wait db + docker compose --env-file .env.docker run --rm app php artisan migrate --force + docker compose --env-file .env.docker run --rm app php artisan db:seed --force + docker compose --env-file .env.docker up -d --wait app + docker compose --env-file .env.docker exec -T app php artisan config:show renewal.invoice.captions + docker compose --env-file .env.docker exec -T app php artisan config:show renewr.url + - name: Verify application and container replacement + run: | + docker compose --env-file .env.docker exec -T app sh -c 'test -z "$MYSQL_ROOT_PASSWORD"' + python3 tests/docker-smoke.py + docker compose --env-file .env.docker exec -T app php artisan schedule:list + docker compose --env-file .env.docker exec -T --user www-data app sh -c 'printf docker-persistence > storage/app/docker-smoke-check' + docker compose --env-file .env.docker up -d --wait --force-recreate app + docker compose --env-file .env.docker exec -T --user www-data app sh -c 'test "$(cat storage/app/docker-smoke-check)" = docker-persistence && rm storage/app/docker-smoke-check' + python3 tests/docker-smoke.py + docker compose --env-file .env.docker run --rm app php artisan migrate --force + - name: Container diagnostics + if: failure() + run: docker compose --env-file .env.docker logs --tail=100 + - name: Remove isolated test stack + if: always() + run: docker compose --env-file .env.docker --profile scheduler down -v diff --git a/.gitignore b/.gitignore index 2a8d84d1..0753bde7 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,8 @@ Homestead.json Homestead.yaml .env +.env.docker +/compose.override.yaml /nbproject/private/ /config/renewal.php .DS_Store \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..2239d909 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,51 @@ +# syntax=docker/dockerfile:1 +FROM node:22-bookworm-slim AS assets +WORKDIR /app +COPY package.json yarn.lock ./ +RUN --mount=type=secret,id=proxy_ca \ + if [ -f /run/secrets/proxy_ca ]; then export NODE_EXTRA_CA_CERTS=/run/secrets/proxy_ca; fi; \ + yarn install --frozen-lockfile +COPY resources ./resources +COPY vite.config.js ./ +RUN yarn build + +FROM php:8.4-apache-bookworm AS php-base +RUN apt-get update && apt-get install -y --no-install-recommends \ + default-mysql-client libfreetype6-dev libjpeg62-turbo-dev libpng-dev \ + libicu-dev libonig-dev libxml2-dev libzip-dev unzip \ + && docker-php-ext-configure gd --with-freetype --with-jpeg \ + && docker-php-ext-install -j"$(nproc)" gd intl mbstring pdo_mysql zip opcache \ + && a2enmod rewrite \ + && sed -i 's/^Listen 80$/Listen 8080/' /etc/apache2/ports.conf \ + && rm -rf /var/lib/apt/lists/* +COPY --from=composer:2 /usr/bin/composer /usr/local/bin/composer +WORKDIR /var/www/html +COPY --chmod=0644 docker/apache.conf /etc/apache2/sites-available/000-default.conf +COPY --chmod=0644 docker/php.ini /usr/local/etc/php/conf.d/phpip.ini +COPY --chmod=0755 docker/entrypoint.sh /usr/local/bin/phpip-entrypoint +ENTRYPOINT ["/bin/sh", "/usr/local/bin/phpip-entrypoint"] +CMD ["apache2-foreground"] +EXPOSE 8080 + +FROM php-base AS dependencies +COPY composer.json composer.lock ./ +RUN --mount=type=secret,id=proxy_ca \ + if [ -f /run/secrets/proxy_ca ]; then export COMPOSER_CAFILE=/run/secrets/proxy_ca; fi; \ + composer install --no-dev --no-scripts --prefer-dist --no-interaction --no-progress + +FROM php-base AS application +COPY . . +COPY --from=dependencies /var/www/html/vendor ./vendor +COPY --from=assets /app/public/build ./public/build +# Use the importing account as definer, rather than phpip@localhost from the dump. +RUN chmod -R u=rwX,go=rX . \ + && cp config/renewal.example.php config/renewal.php \ + && cp config/renewr.example.php config/renewr.php \ + && sed -i 's/DEFINER=`[^`]*`@`[^`]*`//g' database/schema/mysql-schema.sql \ + && mkdir -p storage/app/public storage/app/cache storage/framework/cache/data \ + storage/framework/sessions storage/framework/views storage/logs bootstrap/cache \ + && composer dump-autoload --no-dev --optimize --no-interaction \ + && ln -s /var/www/html/storage/app/public public/storage \ + && chown -R www-data:www-data storage bootstrap/cache +HEALTHCHECK --interval=30s --timeout=5s --start-period=20s \ + CMD curl --fail --silent http://127.0.0.1:8080/up || exit 1 diff --git a/bootstrap/app.php b/bootstrap/app.php index 8cda3bdb..705a5565 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -12,6 +12,11 @@ health: '/up', ) ->withMiddleware(function (Middleware $middleware) { + // Only trust forwarded HTTPS headers from explicitly configured proxies. + if ($trustedProxies = env('TRUSTED_PROXIES')) { + $middleware->trustProxies(at: $trustedProxies); + } + $middleware->web(append: [ SetLocale::class, // Other web middleware... diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 00000000..d870ee50 --- /dev/null +++ b/compose.yaml @@ -0,0 +1,70 @@ +name: phpip + +x-application: &application + image: phpip:local + build: + context: . + env_file: .env.docker + environment: + DB_CONNECTION: mysql + DB_HOST: db + DB_PORT: 3306 + DB_SOCKET: "" + # .env.docker also feeds MySQL initialization; don't pass its root secret to PHP. + MYSQL_ROOT_PASSWORD: "" + LOG_CHANNEL: stderr + CACHE_DRIVER: file + SESSION_DRIVER: file + QUEUE_CONNECTION: sync + volumes: + - app-storage:/var/www/html/storage + depends_on: + db: + condition: service_healthy + +services: + app: + <<: *application + ports: + - "${APP_BIND_ADDRESS:-127.0.0.1}:${APP_PORT:-8000}:8080" + restart: unless-stopped + + scheduler: + <<: *application + command: ["scheduler"] + profiles: ["scheduler"] + healthcheck: + disable: true + restart: unless-stopped + # The Apache base image uses SIGWINCH; the console worker needs SIGTERM. + stop_signal: SIGTERM + stop_grace_period: 60s + + db: + image: mysql:8.4 + environment: + MYSQL_DATABASE: ${DB_DATABASE:-phpip} + MYSQL_USER: ${DB_USERNAME:-phpip} + MYSQL_PASSWORD: ${DB_PASSWORD:?Set DB_PASSWORD in .env.docker} + MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:?Set MYSQL_ROOT_PASSWORD in .env.docker} + command: + - --character-set-server=utf8mb4 + - --collation-server=utf8mb4_0900_ai_ci + - --log-bin-trust-function-creators=1 + # Keep MySQL usable on small test Droplets while retaining stored routines. + - --innodb-buffer-pool-size=64M + - --max-connections=20 + - --performance-schema=OFF + volumes: + - db-data:/var/lib/mysql + healthcheck: + test: ["CMD-SHELL", "MYSQL_PWD=\"$$MYSQL_PASSWORD\" mysql --protocol=TCP -h 127.0.0.1 -u\"$$MYSQL_USER\" \"$$MYSQL_DATABASE\" -e 'SELECT 1' >/dev/null 2>&1"] + interval: 5s + timeout: 5s + retries: 30 + start_period: 30s + restart: unless-stopped + +volumes: + db-data: + app-storage: diff --git a/docker/apache.conf b/docker/apache.conf new file mode 100644 index 00000000..239c22d5 --- /dev/null +++ b/docker/apache.conf @@ -0,0 +1,11 @@ +ServerName localhost + + DocumentRoot /var/www/html/public + + Options -Indexes +FollowSymLinks + AllowOverride All + Require all granted + + ErrorLog /proc/self/fd/2 + CustomLog /proc/self/fd/1 combined + diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh new file mode 100644 index 00000000..7f47e334 --- /dev/null +++ b/docker/entrypoint.sh @@ -0,0 +1,29 @@ +#!/bin/sh +set -eu + +# Named volumes need these directories on both first boot and subsequent boots. +if [ "$(id -u)" = 0 ]; then + mkdir -p storage/app/public storage/app/cache storage/framework/cache/data \ + storage/framework/sessions storage/framework/views storage/logs bootstrap/cache + chown -R www-data:www-data storage bootstrap/cache +fi + +case "${1:-}" in + apache2-foreground|scheduler) + if [ -z "${APP_KEY:-}" ]; then + echo 'Set a persistent APP_KEY in .env.docker before starting phpIP.' >&2 + exit 1 + fi + ;; +esac + +if [ "${1:-}" = scheduler ]; then + exec su -s /bin/sh www-data -c 'exec php artisan schedule:work --no-interaction' +fi + +# Run console commands as the web user so shared files stay writable by Apache. +if [ "${1:-}" = php ] && [ "${2:-}" = artisan ] && [ "$(id -u)" = 0 ]; then + exec su -s /bin/sh www-data -c 'exec "$@"' -- sh "$@" +fi + +exec docker-php-entrypoint "$@" diff --git a/docker/php.ini b/docker/php.ini new file mode 100644 index 00000000..55435d14 --- /dev/null +++ b/docker/php.ini @@ -0,0 +1,8 @@ +expose_php = Off +memory_limit = 256M +upload_max_filesize = 20M +post_max_size = 24M +opcache.enable = 1 +opcache.validate_timestamps = 0 +log_errors = On +error_log = /proc/self/fd/2 diff --git a/docs/DOCKER.md b/docs/DOCKER.md new file mode 100644 index 00000000..da550825 --- /dev/null +++ b/docs/DOCKER.md @@ -0,0 +1,293 @@ +# Running phpIP with Docker + +The stack contains PHP 8.4 with Apache, MySQL 8.4, and an optional Laravel +scheduler. Composer dependencies and Vite assets are built into the image; +PHP, Composer, Node, and MySQL do not need to be installed on the host. +Use Docker Engine/Desktop with Compose v2 and BuildKit. +The bundled MySQL defaults are tuned for a small test Droplet (one CPU and about +512 MiB RAM); larger installations can increase its buffer pool and connections. + +## DigitalOcean App Platform + +App Platform builds the `Dockerfile` but does not start services from this +repository's `compose.yaml`. The `DB_HOST=db` setting is supplied by Compose's +app service; it cannot resolve in an App Platform container by itself. For App +Platform, attach a MySQL database component or another reachable MySQL service +and set `DB_HOST`, `DB_PORT`, `DB_DATABASE`, `DB_USERNAME`, and `DB_PASSWORD` in +the app component from that database's connection details. Use its private host +when both components are in the same DigitalOcean network. Do not set +`DB_HOST=127.0.0.1` unless MySQL actually runs inside the app container. + +After setting the database connection, run `php artisan migrate --force` in an +App Platform console or one-off job. Run `php artisan db:seed --force` only for +a new test database; it creates the initial test user. App Platform runs the +web container only, so schedule tasks separately or use the Compose setup on a +Droplet, which starts both the app and database services together. + +## Ubuntu Droplet setup + +Install Docker Engine, Compose v2, and Git. On Ubuntu 24.04 the Compose package +is named `docker-compose-v2`: + +```sh +sudo apt update +sudo apt install -y docker.io docker-compose-v2 git +sudo systemctl enable --now docker +docker compose version +``` + +Clone the repository (use the PR branch instead of `master` if testing before +the Docker changes are merged): + +```sh +git clone https://github.com/srdco/phpip.git ~/phpip +cd ~/phpip +cp .env.docker.example .env.docker +chmod 600 .env.docker +``` + +Set `APP_KEY` in `.env.docker` to `base64:` followed by the output of +`openssl rand -base64 32`. Set distinct `DB_PASSWORD` and `MYSQL_ROOT_PASSWORD` +values, for example with `openssl rand -hex 24` twice. Set your company name, +locale, timezone, and `APP_URL` for your domain. Keep the key and database +passwords stable. Leave `APP_BIND_ADDRESS=127.0.0.1` when an HTTPS reverse proxy +will forward requests to the Droplet's port 8000. Compose does not configure +DNS or HTTPS. For TLS termination at that proxy, set `TRUSTED_PROXIES` to its +Docker-facing IP (or CIDR) so Laravel accepts its forwarded HTTPS header. Leave +this unset when accessing the app directly without a reverse proxy. To inspect +the Compose bridge gateway, run `docker network inspect phpip_default` and use +the gateway shown there only if that is the address your proxy uses to reach +the app. + +On a 512 MiB Droplet, check memory and swap using `free -h` and `swapon --show` +before building. If swap is empty, add 2 GiB of swap for the Node asset build +and PHP extension compilation: + +```sh +sudo fallocate -l 2G /swapfile +sudo chmod 600 /swapfile +sudo mkswap /swapfile +sudo swapon /swapfile +echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstab +``` + +Build the application, initialize the database, and start the web service: + +```sh +docker compose --env-file .env.docker build app +docker compose --env-file .env.docker up -d --wait db +docker compose --env-file .env.docker run --rm app php artisan migrate --force +docker compose --env-file .env.docker run --rm app php artisan db:seed --force +docker compose --env-file .env.docker up -d --wait app +docker compose --env-file .env.docker ps +``` + +If MySQL reports `--initialize specified but the data directory has files in +it` on first boot, an interrupted initialization may have left partial files +in the database volume. Inspect its logs: + +```sh +docker compose --env-file .env.docker logs --tail=100 db +``` + +Only for a brand-new test setup with no migrations or needed data, remove the +failed database volume and retry: + +```sh +docker compose --env-file .env.docker down +docker volume ls --filter name=phpip_db-data +docker volume rm phpip_db-data +docker compose --env-file .env.docker up -d --wait db +``` + +This removes only the database volume. `docker compose down -v` also deletes +application storage; never remove volumes containing data you need. + +After the app is healthy, configure your domain's HTTPS reverse proxy to forward +to `http://127.0.0.1:8000`, then open the domain set in `APP_URL`. A fresh seed +creates `phpipuser` / `changeme`; change that password immediately. The +scheduler remains disabled until explicitly enabled. + +## Local Docker setup + +From the repository root: + +```sh +cp .env.docker.example .env.docker +chmod 600 .env.docker +``` + +Edit `.env.docker`. Set distinct random `DB_PASSWORD` and `MYSQL_ROOT_PASSWORD` +values, and set `APP_KEY` to `base64:` followed by the output of +`openssl rand -base64 32`. Keep this key across updates and restores. Set your +company name, URL, locale, and timezone. The Docker environment file is ignored +by Git and excluded from image builds; your existing `.env` is not used by the +containers. + +Always pass `--env-file .env.docker`: Compose uses it both for database settings +and container configuration. Omitting it may select settings from a host `.env`. + +```sh +docker compose --env-file .env.docker build app +docker compose --env-file .env.docker up -d --wait db +docker compose --env-file .env.docker run --rm app php artisan migrate --force +docker compose --env-file .env.docker run --rm app php artisan db:seed --force +docker compose --env-file .env.docker up -d --wait app +``` + +Open . Apache listens on container port 8080 for +DigitalOcean App Platform compatibility; Compose maps it to host port 8000 by +default. A fresh seed creates the upstream initial login `phpipuser` / `changeme`; +change its password immediately. Seeding is an explicit +first-install action, never an automatic container startup action. Do not seed +an existing installation or import sample data into a real portfolio. + +The web port binds to localhost by default. MySQL has no published host port. +`APP_BIND_ADDRESS` and `APP_PORT` control the web binding. For a public deployment, +configure HTTPS and the correct application URL and proxy handling before +exposing the application. + +## Database details + +Use MySQL 8.4 for this stack. Current migrations require `utf8mb4_0900_ai_ci` and +rebuild stored functions, procedures, views, and triggers. Old installation +documentation describing MySQL 5.7 or older MariaDB versions is not applicable. + +The image removes explicit `DEFINER` clauses from its copy of the baseline +schema. Fresh database objects therefore belong to the configured importing +account, instead of the dump's `phpip@localhost` account. The checked-in schema +is unchanged. The application account has privileges on its database only; +`log_bin_trust_function_creators=1` is set on the bundled MySQL server to permit +routine/trigger creation without granting global administration privileges. + +For an existing installation, restore a backup into an isolated database first, +review imported object definers, then run `migrate --force`. Do not point this +stack at the live database as a first test. Containers never automatically +migrate or reset the database. + +Changing database credentials in `.env.docker` does not change accounts in an +already initialized MySQL volume. Update those accounts explicitly and recreate +the application containers when rotating credentials. + +## Scheduler and integrations + +Mail defaults to the application log. Configure SMTP, recipients, OPS, Renewr, +and any SharePoint settings in `.env.docker` as needed; see `.env.example` and +the integration guides for available options. Then enable the scheduler: + +```sh +docker compose --env-file .env.docker --profile scheduler up -d scheduler +docker compose --env-file .env.docker exec scheduler php artisan schedule:list +``` + +The existing schedule syncs Renewr on Mondays at 03:00 and sends task reminders +at 06:00, using `APP_TIMEZONE`. The scheduler runs as `www-data` and shares the +application storage volume, including file cache locks and Renewr sync state. +Run one scheduler service per installation. Queues remain synchronous; a queue +worker or Redis service is not required for this configuration. + +Stop it with `docker compose --env-file .env.docker stop scheduler`. Recreate +containers after environment changes; `restart` alone does not reload values. + +## Updates and checks + +Back up the database, storage, and environment file first. Stop the scheduler +while applying updates, rebuild the image, run migrations once, then recreate +the services: + +```sh +docker compose --env-file .env.docker stop scheduler +docker compose --env-file .env.docker build app +docker compose --env-file .env.docker run --rm app php artisan migrate --force +docker compose --env-file .env.docker up -d --wait app +# If scheduling was enabled: +docker compose --env-file .env.docker --profile scheduler up -d scheduler +``` + +For a production update, take the app out of service during migrations and plan +rollback using the pre-update backup; rebuilding an older image does not undo +database changes. + +```sh +docker compose --env-file .env.docker ps +docker compose --env-file .env.docker logs --tail=100 app +docker compose --env-file .env.docker exec app php artisan migrate:status +curl --fail http://localhost:8000/up +``` + +The image's `/up` health check establishes that Laravel boots; it does not prove +database or third-party integration health. Verify login, matter/event workflows, +renewal calculations, and integrations against an isolated copy before rollout. +The runtime image excludes development dependencies, including PHPUnit. + +The Docker GitHub Actions workflow builds a fresh stack, migrates and seeds it, +checks HTTP login and compiled assets, and replaces the app container to check +database and storage persistence. The same HTTP check can be run against a +disposable seeded stack with `python3 tests/docker-smoke.py`. This is a container +smoke check, not the full application test suite. + +The image creates active `config/renewal.php` and `config/renewr.php` from their +tracked example files. This gives renewal pages usable defaults and lets the +Renewr URL and key use the documented environment variables. Private host copies +of either active config file are excluded from the image build. To supply a +custom `config/renewal.php`, copy and edit the example on the host, then create +`compose.override.yaml` (ignored by Git) to mount it into both services: + +```sh +cp config/renewal.example.php config/renewal.php +``` + +```yaml +services: + app: + volumes: + - ./config/renewal.php:/var/www/html/config/renewal.php:ro + scheduler: + volumes: + - ./config/renewal.php:/var/www/html/config/renewal.php:ro +``` + +The host `config/renewal.php` is ignored by Git; keep installation-specific +billing credentials out of the image. + +## Persistence and backups + +The `db-data` volume stores MySQL data. The `app-storage` volume stores application +files, file sessions/cache, and Renewr sync state. `docker compose down` preserves +them; **`down -v` deletes them**. Keep `.env.docker` in your secure backup as well. + +Create a logical database backup without putting the password in shell history: + +```sh +docker compose --env-file .env.docker exec -T db sh -c \ + 'MYSQL_PWD="$MYSQL_PASSWORD" exec mysqldump -u"$MYSQL_USER" --no-tablespaces --single-transaction --routines --triggers "$MYSQL_DATABASE"' \ + > phpip-backup.sql +``` + +Keep backups outside the repository/build context. To back up storage, stop the +app and scheduler briefly so their files do not change while archiving: + +```sh +docker compose --env-file .env.docker stop app scheduler +docker compose --env-file .env.docker run --rm --no-deps --entrypoint tar app \ + -czf - -C /var/www/html storage > phpip-storage.tar.gz +docker compose --env-file .env.docker up -d app +# Restart the scheduler too if it was enabled. +``` + +Practice restoring both backups into a separate Compose project before relying +on them. Restore the original application key with the data. + +## Builds behind a managed HTTPS proxy + +Build steps accept an optional BuildKit `proxy_ca` secret. Pass a combined CA +bundle when the environment requires its own CA, keeping TLS verification on: + +```sh +docker build --secret id=proxy_ca,src=/etc/ssl/certs/ca-certificates.crt -t phpip:local . +``` + +This secret is used for Yarn and Composer and is not stored in image layers. +Use the normal Compose startup commands afterwards. Runtime API calls behind +such a proxy also need that environment's proxy and read-only CA configuration; +build-time trust does not configure runtime trust. diff --git a/readme.md b/readme.md index 50f6208a..9a633a1c 100644 --- a/readme.md +++ b/readme.md @@ -10,6 +10,10 @@ Head for the [Wiki](https://github.com/jjdejong/phpip/wiki) for further informat # New features +## 2026-11-02 Docker Compose setup + +phpIP can now run as an optional Docker Compose stack with PHP 8.4, MySQL 8.4, persistent database and application storage, and an optional Laravel scheduler. Existing non-Docker installations are unchanged. See the [Docker guide](docs/DOCKER.md) for setup and upgrade instructions. + ## 2026-04-23 USPTO ODP fallback/enrichment for US family members Family import now uses a dedicated provider orchestration service. EPO OPS remains the primary family source, while USPTO ODP can enrich US family members with missing title, applicant, inventor, or procedure data. If OPS cannot return a family for a US application, phpIP can fall back to USPTO ODP to prepare a single US matter from the available ODP record. diff --git a/tests/docker-smoke.py b/tests/docker-smoke.py new file mode 100644 index 00000000..343f7676 --- /dev/null +++ b/tests/docker-smoke.py @@ -0,0 +1,63 @@ +"""HTTP smoke check for an isolated, freshly seeded Docker installation. + +Usage: python3 tests/docker-smoke.py [http://localhost:8000] +Uses the seed account; never run against a live portfolio. +""" + +import http.cookiejar +from html.parser import HTMLParser +import sys +import urllib.parse +import urllib.request + + +class LoginPage(HTMLParser): + def __init__(self): + super().__init__() + self.token = None + self.assets = set() + + def handle_starttag(self, tag, attrs): + attrs = dict(attrs) + if tag == "input" and attrs.get("name") == "_token": + self.token = attrs.get("value") + for name in ("src", "href"): + value = attrs.get(name, "") + if "/build/assets/" in value: + self.assets.add(value) + + +base = (sys.argv[1] if len(sys.argv) > 1 else "http://localhost:8000").rstrip("/") +# Ignore host proxy settings for this local-only smoke check. +opener = urllib.request.build_opener( + urllib.request.ProxyHandler({}), + urllib.request.HTTPCookieProcessor(http.cookiejar.CookieJar()), +) + + +def get(path): + return opener.open(urllib.parse.urljoin(base + "/", path), timeout=15) + + +with get("/up") as response: + assert response.status == 200, "Laravel health check failed" +with get("/login") as response: + page = LoginPage() + page.feed(response.read().decode()) +assert page.token, "Login page has no CSRF token" +assert page.assets, "Login page has no compiled Vite assets" +for asset in page.assets: + # APP_URL may differ from the address used by this check. + path = urllib.parse.urlsplit(asset).path + with get(path) as response: + assert response.status == 200, f"Compiled asset unavailable: {path}" + +payload = urllib.parse.urlencode( + {"_token": page.token, "login": "phpipuser", "password": "changeme"} +).encode() +with opener.open(base + "/login", data=payload, timeout=15) as response: + assert "/login" not in response.url, "Seeded account could not log in" +with get("/home") as response: + assert urllib.parse.urlsplit(response.url).path == "/home", "Session was not retained" + assert response.status == 200, "Authenticated dashboard failed" +print("Passed: Laravel health, login page, compiled assets, database-backed login, persisted session, dashboard.") From d31973bf0b16b1307edede374a466f71143fcacf Mon Sep 17 00:00:00 2001 From: srdco Date: Tue, 3 Nov 2026 13:09:55 -0600 Subject: [PATCH 02/20] Align MySQL timezone with application --- compose.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/compose.yaml b/compose.yaml index d870ee50..a30d1fce 100644 --- a/compose.yaml +++ b/compose.yaml @@ -43,11 +43,13 @@ services: db: image: mysql:8.4 environment: + TZ: ${APP_TIMEZONE:-UTC} MYSQL_DATABASE: ${DB_DATABASE:-phpip} MYSQL_USER: ${DB_USERNAME:-phpip} MYSQL_PASSWORD: ${DB_PASSWORD:?Set DB_PASSWORD in .env.docker} MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:?Set MYSQL_ROOT_PASSWORD in .env.docker} command: + - --default-time-zone=SYSTEM - --character-set-server=utf8mb4 - --collation-server=utf8mb4_0900_ai_ci - --log-bin-trust-function-creators=1 From 695e96017c038f20ae404f2de43973a93ea5884e Mon Sep 17 00:00:00 2001 From: srdco Date: Tue, 3 Nov 2026 13:10:04 -0600 Subject: [PATCH 03/20] Document App Platform key and proxy settings --- docs/DOCKER.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/docs/DOCKER.md b/docs/DOCKER.md index da550825..0bb57405 100644 --- a/docs/DOCKER.md +++ b/docs/DOCKER.md @@ -18,6 +18,15 @@ the app component from that database's connection details. Use its private host when both components are in the same DigitalOcean network. Do not set `DB_HOST=127.0.0.1` unless MySQL actually runs inside the app container. +Also configure a persistent `APP_KEY`, `APP_URL` (your public `https://` URL), +and `APP_TIMEZONE` in the app component. Generate the key with +`php artisan key:generate --show` in a local checkout and retain the same value +across deployments. The container exits at startup if `APP_KEY` is missing. +For App Platform's TLS-terminating ingress, set `TRUSTED_PROXIES=*` so Laravel +recognizes the forwarded HTTPS scheme. Use this wildcard only while the app is +reachable through App Platform's ingress; if you expose the container through +another network path, configure the proxy IPs or CIDRs instead. + After setting the database connection, run `php artisan migrate --force` in an App Platform console or one-off job. Run `php artisan db:seed --force` only for a new test database; it creates the initial test user. App Platform runs the @@ -152,6 +161,10 @@ exposing the application. Use MySQL 8.4 for this stack. Current migrations require `utf8mb4_0900_ai_ci` and rebuild stored functions, procedures, views, and triggers. Old installation documentation describing MySQL 5.7 or older MariaDB versions is not applicable. +The bundled MySQL container uses `APP_TIMEZONE` (default `America/Chicago`) for +server-local timestamps such as trigger-generated `NOW()` values, matching the +application. If you change the timezone after initialization, recreate the +database container so the server adopts the updated timezone. The image removes explicit `DEFINER` clauses from its copy of the baseline schema. Fresh database objects therefore belong to the configured importing From 97c4555e7ae2a5b131011c2a5108bd32dcf8ed4f Mon Sep 17 00:00:00 2001 From: srdco Date: Tue, 3 Nov 2026 13:33:51 -0600 Subject: [PATCH 04/20] Address App Platform deployment review feedback --- .env.docker.example | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.env.docker.example b/.env.docker.example index a787f657..60f49289 100644 --- a/.env.docker.example +++ b/.env.docker.example @@ -16,6 +16,10 @@ DB_USERNAME=phpip DB_PASSWORD= MYSQL_ROOT_PASSWORD= +# Set a unique value before the first seed when APP_ENV=production. +# Non-production seeds fall back to the documented `changeme` password. +INITIAL_USER_PASSWORD= + # Log mail locally until SMTP and recipients are configured. MAIL_MAILER=log MAIL_FROM_ADDRESS=noreply@example.com From 9444806eb41c036ab823ecc439f78e91063ac94b Mon Sep 17 00:00:00 2001 From: srdco Date: Tue, 3 Nov 2026 13:33:58 -0600 Subject: [PATCH 05/20] Address App Platform deployment review feedback --- compose.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/compose.yaml b/compose.yaml index a30d1fce..ee61d581 100644 --- a/compose.yaml +++ b/compose.yaml @@ -10,6 +10,8 @@ x-application: &application DB_HOST: db DB_PORT: 3306 DB_SOCKET: "" + # MySQL's server-local zone follows APP_TIMEZONE via TZ and SYSTEM. + DB_TIMEZONE: SYSTEM # .env.docker also feeds MySQL initialization; don't pass its root secret to PHP. MYSQL_ROOT_PASSWORD: "" LOG_CHANNEL: stderr From 6550e561061980bf9a9f2c648550e58ad18b752c Mon Sep 17 00:00:00 2001 From: srdco Date: Tue, 3 Nov 2026 13:34:03 -0600 Subject: [PATCH 06/20] Address App Platform deployment review feedback --- config/database.php | 1 + 1 file changed, 1 insertion(+) diff --git a/config/database.php b/config/database.php index cec243b8..3ef586b3 100644 --- a/config/database.php +++ b/config/database.php @@ -56,6 +56,7 @@ 'collation' => 'utf8mb4_0900_ai_ci', 'prefix' => '', 'prefix_indexes' => true, + 'timezone' => env('DB_TIMEZONE', env('APP_TIMEZONE', 'UTC')), 'strict' => false, 'engine' => null, 'options' => extension_loaded('pdo_mysql') ? array_filter([ From 48fd896ceec5d980cd769fce9e5b6a9bdb823f2c Mon Sep 17 00:00:00 2001 From: srdco Date: Tue, 3 Nov 2026 13:34:10 -0600 Subject: [PATCH 07/20] Address App Platform deployment review feedback --- database/seeders/ActorTableSeeder.php | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/database/seeders/ActorTableSeeder.php b/database/seeders/ActorTableSeeder.php index 7aa1d204..92219c5f 100644 --- a/database/seeders/ActorTableSeeder.php +++ b/database/seeders/ActorTableSeeder.php @@ -4,11 +4,14 @@ use Illuminate\Database\Seeder; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Hash; class ActorTableSeeder extends Seeder { public function run() { + $initialPassword = env('INITIAL_USER_PASSWORD'); + DB::table('actor')->insertOrIgnore([ [ 'id' => 1, @@ -53,7 +56,7 @@ public function run() 'first_name' => null, 'display_name' => null, 'login' => 'phpipuser', - 'password' => '$2y$10$auLQHQ3EIsg90hqnQsA1huhks3meaxwfWWEvJtD8R38jzwNN6y3zO', + 'password' => Hash::make($initialPassword ?: 'changeme'), 'default_role' => 'DBA', 'function' => null, 'parent_id' => null, From 573ba98483322ebfb17d4250e0596e9af99ffb6b Mon Sep 17 00:00:00 2001 From: srdco Date: Tue, 3 Nov 2026 13:34:15 -0600 Subject: [PATCH 08/20] Address App Platform deployment review feedback --- database/seeders/DatabaseSeeder.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index 487d9788..c081d322 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -8,6 +8,10 @@ class DatabaseSeeder extends Seeder { public function run() { + if (app()->environment('production') && empty(env('INITIAL_USER_PASSWORD'))) { + throw new \RuntimeException('Set INITIAL_USER_PASSWORD before seeding the production database.'); + } + $this->call(CountryTableSeeder::class); // Translatable table seeders (insert multi-language JSON directly) From cea3f0f9db5385707f1dafee7f031a9ed29210c8 Mon Sep 17 00:00:00 2001 From: srdco Date: Tue, 3 Nov 2026 13:34:20 -0600 Subject: [PATCH 09/20] Address App Platform deployment review feedback --- docs/DOCKER.md | 52 +++++++++++++++++++++++++++++++++++++------------- 1 file changed, 39 insertions(+), 13 deletions(-) diff --git a/docs/DOCKER.md b/docs/DOCKER.md index 0bb57405..123f2872 100644 --- a/docs/DOCKER.md +++ b/docs/DOCKER.md @@ -27,11 +27,33 @@ recognizes the forwarded HTTPS scheme. Use this wildcard only while the app is reachable through App Platform's ingress; if you expose the container through another network path, configure the proxy IPs or CIDRs instead. -After setting the database connection, run `php artisan migrate --force` in an -App Platform console or one-off job. Run `php artisan db:seed --force` only for -a new test database; it creates the initial test user. App Platform runs the -web container only, so schedule tasks separately or use the Compose setup on a -Droplet, which starts both the app and database services together. +Laravel sets each MySQL session's timezone from `APP_TIMEZONE` by default; set +optional `DB_TIMEZONE` only if the database should use a different zone. The +configured zone must be available in MySQL's timezone tables. Compose sets +`DB_TIMEZONE=SYSTEM` because its MySQL server is configured with `APP_TIMEZONE`. +For an App Platform database, the session setting makes trigger-generated +`NOW()` values follow the app's timezone even when the managed server itself +uses UTC. + +On a new database, run both `php artisan migrate --force` and +`php artisan db:seed --force` in an App Platform console or one-off job. The +baseline seeder is required for production too: it loads countries, roles, event +and matter types, templates, and the initial account, but does not load sample +matters. Before seeding a production database, set `INITIAL_USER_PASSWORD` to a +unique secret in the app component; production seeding stops if it is missing. +After the seed succeeds, remove that environment variable and sign in as +`phpipuser` with the password you supplied, then change it in the app. Never +seed an existing production database as an upgrade step. + +App Platform does not load `.env.docker`, so set `MAIL_MAILER=log` explicitly +for the default safe, non-sending behavior; messages appear in the app's +runtime logs. Password reset and renewal messages will not reach recipients +until SMTP is configured. To send mail, configure `MAIL_MAILER=smtp` plus the +SMTP host, port, username, password, encryption, and sender values in the app +component. +App Platform runs the web container only, so schedule tasks separately or use +the Compose setup on a Droplet, which starts both the app and database services +together. ## Ubuntu Droplet setup @@ -58,7 +80,8 @@ chmod 600 .env.docker Set `APP_KEY` in `.env.docker` to `base64:` followed by the output of `openssl rand -base64 32`. Set distinct `DB_PASSWORD` and `MYSQL_ROOT_PASSWORD` values, for example with `openssl rand -hex 24` twice. Set your company name, -locale, timezone, and `APP_URL` for your domain. Keep the key and database +locale, timezone, and `APP_URL` for your domain. Set `INITIAL_USER_PASSWORD` to +a unique value before running the seed command. Keep the key and database passwords stable. Leave `APP_BIND_ADDRESS=127.0.0.1` when an HTTPS reverse proxy will forward requests to the Droplet's port 8000. Compose does not configure DNS or HTTPS. For TLS termination at that proxy, set `TRUSTED_PROXIES` to its @@ -113,9 +136,10 @@ This removes only the database volume. `docker compose down -v` also deletes application storage; never remove volumes containing data you need. After the app is healthy, configure your domain's HTTPS reverse proxy to forward -to `http://127.0.0.1:8000`, then open the domain set in `APP_URL`. A fresh seed -creates `phpipuser` / `changeme`; change that password immediately. The -scheduler remains disabled until explicitly enabled. +to `http://127.0.0.1:8000`, then open the domain set in `APP_URL`. Sign in as +`phpipuser` with the value supplied in `INITIAL_USER_PASSWORD`, change it in the +app, then remove the variable from `.env.docker` and recreate the app container. +The scheduler remains disabled until explicitly enabled. ## Local Docker setup @@ -129,9 +153,10 @@ chmod 600 .env.docker Edit `.env.docker`. Set distinct random `DB_PASSWORD` and `MYSQL_ROOT_PASSWORD` values, and set `APP_KEY` to `base64:` followed by the output of `openssl rand -base64 32`. Keep this key across updates and restores. Set your -company name, URL, locale, and timezone. The Docker environment file is ignored -by Git and excluded from image builds; your existing `.env` is not used by the -containers. +company name, URL, locale, and timezone. If `APP_ENV=production`, set a unique +`INITIAL_USER_PASSWORD` before the first seed. The Docker environment file is +ignored by Git and excluded from image builds; your existing `.env` is not used +by the containers. Always pass `--env-file .env.docker`: Compose uses it both for database settings and container configuration. Omitting it may select settings from a host `.env`. @@ -146,7 +171,8 @@ docker compose --env-file .env.docker up -d --wait app Open . Apache listens on container port 8080 for DigitalOcean App Platform compatibility; Compose maps it to host port 8000 by -default. A fresh seed creates the upstream initial login `phpipuser` / `changeme`; +default. Sign in as `phpipuser` with the value supplied in +`INITIAL_USER_PASSWORD` (or `changeme` when seeding outside production) and change its password immediately. Seeding is an explicit first-install action, never an automatic container startup action. Do not seed an existing installation or import sample data into a real portfolio. From 99dcdd84acef736dd579a5cc14a1a936add577c6 Mon Sep 17 00:00:00 2001 From: srdco Date: Tue, 3 Nov 2026 14:19:09 -0600 Subject: [PATCH 10/20] Fix Docker review follow-up findings --- .github/workflows/docker.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 7048dc5b..8076e2a3 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -18,16 +18,19 @@ jobs: - name: Create isolated test configuration run: | python3 - <<'PY' - import base64, pathlib, secrets + import base64, os, pathlib, secrets text = pathlib.Path('.env.docker.example').read_text() values = { 'APP_KEY': 'base64:' + base64.b64encode(secrets.token_bytes(32)).decode(), 'DB_PASSWORD': secrets.token_hex(24), 'MYSQL_ROOT_PASSWORD': secrets.token_hex(24), + 'INITIAL_USER_PASSWORD': secrets.token_urlsafe(24), } for key, value in values.items(): text = text.replace(key + '=\n', key + '=' + value + '\n') pathlib.Path('.env.docker').write_text(text) + with pathlib.Path(os.environ['GITHUB_ENV']).open('a') as env_file: + env_file.write('INITIAL_USER_PASSWORD=' + values['INITIAL_USER_PASSWORD'] + '\n') PY - name: Build and initialize run: | From 6249f34c37da9b2a91d0a64c3544d96e8e788f51 Mon Sep 17 00:00:00 2001 From: srdco Date: Tue, 3 Nov 2026 14:19:15 -0600 Subject: [PATCH 11/20] Fix Docker review follow-up findings --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 2239d909..73b3e962 100644 --- a/Dockerfile +++ b/Dockerfile @@ -11,7 +11,7 @@ RUN yarn build FROM php:8.4-apache-bookworm AS php-base RUN apt-get update && apt-get install -y --no-install-recommends \ - default-mysql-client libfreetype6-dev libjpeg62-turbo-dev libpng-dev \ + default-mysql-client gosu libfreetype6-dev libjpeg62-turbo-dev libpng-dev \ libicu-dev libonig-dev libxml2-dev libzip-dev unzip \ && docker-php-ext-configure gd --with-freetype --with-jpeg \ && docker-php-ext-install -j"$(nproc)" gd intl mbstring pdo_mysql zip opcache \ From ef11a37d72bd93bee32505849984f33b7108426b Mon Sep 17 00:00:00 2001 From: srdco Date: Tue, 3 Nov 2026 14:19:21 -0600 Subject: [PATCH 12/20] Fix Docker review follow-up findings --- config/database.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/database.php b/config/database.php index 3ef586b3..62ee235b 100644 --- a/config/database.php +++ b/config/database.php @@ -56,7 +56,7 @@ 'collation' => 'utf8mb4_0900_ai_ci', 'prefix' => '', 'prefix_indexes' => true, - 'timezone' => env('DB_TIMEZONE', env('APP_TIMEZONE', 'UTC')), + 'timezone' => env('DB_TIMEZONE'), 'strict' => false, 'engine' => null, 'options' => extension_loaded('pdo_mysql') ? array_filter([ From 882d9ba5c8e992f446f58354114ae8c9ea5c7d54 Mon Sep 17 00:00:00 2001 From: srdco Date: Tue, 3 Nov 2026 14:19:26 -0600 Subject: [PATCH 13/20] Fix Docker review follow-up findings --- docker/entrypoint.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index 7f47e334..5b5d4a9c 100644 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -18,12 +18,12 @@ case "${1:-}" in esac if [ "${1:-}" = scheduler ]; then - exec su -s /bin/sh www-data -c 'exec php artisan schedule:work --no-interaction' + exec gosu www-data php artisan schedule:work --no-interaction fi # Run console commands as the web user so shared files stay writable by Apache. if [ "${1:-}" = php ] && [ "${2:-}" = artisan ] && [ "$(id -u)" = 0 ]; then - exec su -s /bin/sh www-data -c 'exec "$@"' -- sh "$@" + exec gosu www-data "$@" fi exec docker-php-entrypoint "$@" From 7c66dff034d846f0b1cd064f9b34b865ca39da69 Mon Sep 17 00:00:00 2001 From: srdco Date: Tue, 3 Nov 2026 14:19:32 -0600 Subject: [PATCH 14/20] Fix Docker review follow-up findings --- docs/DOCKER.md | 25 ++++++++++++++++--------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/docs/DOCKER.md b/docs/DOCKER.md index 123f2872..0bd2dfc9 100644 --- a/docs/DOCKER.md +++ b/docs/DOCKER.md @@ -27,13 +27,13 @@ recognizes the forwarded HTTPS scheme. Use this wildcard only while the app is reachable through App Platform's ingress; if you expose the container through another network path, configure the proxy IPs or CIDRs instead. -Laravel sets each MySQL session's timezone from `APP_TIMEZONE` by default; set -optional `DB_TIMEZONE` only if the database should use a different zone. The -configured zone must be available in MySQL's timezone tables. Compose sets +Existing MySQL installs retain their previous session timezone behavior unless +`DB_TIMEZONE` is set. To align trigger-generated `NOW()` values on App Platform, +set `DB_TIMEZONE` to the same IANA timezone as `APP_TIMEZONE`; the name must be +available in the managed database's timezone tables. Compose sets `DB_TIMEZONE=SYSTEM` because its MySQL server is configured with `APP_TIMEZONE`. -For an App Platform database, the session setting makes trigger-generated -`NOW()` values follow the app's timezone even when the managed server itself -uses UTC. +If the managed database does not have timezone tables loaded, configure its +server timezone to match `APP_TIMEZONE` and leave `DB_TIMEZONE` unset. On a new database, run both `php artisan migrate --force` and `php artisan db:seed --force` in an App Platform console or one-off job. The @@ -67,16 +67,23 @@ sudo systemctl enable --now docker docker compose version ``` -Clone the repository (use the PR branch instead of `master` if testing before -the Docker changes are merged): +After the Docker changes merge, clone the canonical upstream repository: ```sh -git clone https://github.com/srdco/phpip.git ~/phpip +git clone https://github.com/jjdejong/phpip.git ~/phpip cd ~/phpip cp .env.docker.example .env.docker chmod 600 .env.docker ``` +Before the Docker changes merge, use the contributor fork and branch instead +of the upstream clone above: + +```sh +git clone --branch codex/docker-upstream-only https://github.com/srdco/phpip.git ~/phpip +cd ~/phpip +``` + Set `APP_KEY` in `.env.docker` to `base64:` followed by the output of `openssl rand -base64 32`. Set distinct `DB_PASSWORD` and `MYSQL_ROOT_PASSWORD` values, for example with `openssl rand -hex 24` twice. Set your company name, From 539d5e69e973a448709ef21fcb9b688d0689286b Mon Sep 17 00:00:00 2001 From: srdco Date: Tue, 3 Nov 2026 14:19:38 -0600 Subject: [PATCH 15/20] Fix Docker review follow-up findings --- tests/docker-smoke.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tests/docker-smoke.py b/tests/docker-smoke.py index 343f7676..959cecc1 100644 --- a/tests/docker-smoke.py +++ b/tests/docker-smoke.py @@ -6,6 +6,7 @@ import http.cookiejar from html.parser import HTMLParser +import os import sys import urllib.parse import urllib.request @@ -53,7 +54,11 @@ def get(path): assert response.status == 200, f"Compiled asset unavailable: {path}" payload = urllib.parse.urlencode( - {"_token": page.token, "login": "phpipuser", "password": "changeme"} + { + "_token": page.token, + "login": "phpipuser", + "password": os.environ.get("INITIAL_USER_PASSWORD", "changeme"), + } ).encode() with opener.open(base + "/login", data=payload, timeout=15) as response: assert "/login" not in response.url, "Seeded account could not log in" From 0429a6cb722aee5fc2997f8e420b4fd1f78e492a Mon Sep 17 00:00:00 2001 From: srdco Date: Tue, 3 Nov 2026 14:28:08 -0600 Subject: [PATCH 16/20] Clarify App Platform logging and backup paths --- docs/DOCKER.md | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/docs/DOCKER.md b/docs/DOCKER.md index 0bd2dfc9..7c7168e7 100644 --- a/docs/DOCKER.md +++ b/docs/DOCKER.md @@ -45,12 +45,12 @@ After the seed succeeds, remove that environment variable and sign in as `phpipuser` with the password you supplied, then change it in the app. Never seed an existing production database as an upgrade step. -App Platform does not load `.env.docker`, so set `MAIL_MAILER=log` explicitly -for the default safe, non-sending behavior; messages appear in the app's -runtime logs. Password reset and renewal messages will not reach recipients -until SMTP is configured. To send mail, configure `MAIL_MAILER=smtp` plus the -SMTP host, port, username, password, encryption, and sender values in the app -component. +App Platform does not load `.env.docker`, so set `MAIL_MAILER=log` and +`LOG_CHANNEL=stderr` explicitly for the default safe, non-sending behavior; +messages then appear in the app's runtime logs. Password reset and renewal +messages will not reach recipients until SMTP is configured. To send mail, +configure `MAIL_MAILER=smtp` plus the SMTP host, port, username, password, +encryption, and sender values in the app component. App Platform runs the web container only, so schedule tasks separately or use the Compose setup on a Droplet, which starts both the app and database services together. @@ -305,9 +305,11 @@ them; **`down -v` deletes them**. Keep `.env.docker` in your secure backup as we Create a logical database backup without putting the password in shell history: ```sh +mkdir -p "$HOME/phpip-backups" +chmod 700 "$HOME/phpip-backups" docker compose --env-file .env.docker exec -T db sh -c \ 'MYSQL_PWD="$MYSQL_PASSWORD" exec mysqldump -u"$MYSQL_USER" --no-tablespaces --single-transaction --routines --triggers "$MYSQL_DATABASE"' \ - > phpip-backup.sql + > "$HOME/phpip-backups/phpip-backup-$(date -u +%Y%m%dT%H%M%SZ).sql" ``` Keep backups outside the repository/build context. To back up storage, stop the @@ -316,7 +318,8 @@ app and scheduler briefly so their files do not change while archiving: ```sh docker compose --env-file .env.docker stop app scheduler docker compose --env-file .env.docker run --rm --no-deps --entrypoint tar app \ - -czf - -C /var/www/html storage > phpip-storage.tar.gz + -czf - -C /var/www/html storage \ + > "$HOME/phpip-backups/phpip-storage-$(date -u +%Y%m%dT%H%M%SZ).tar.gz" docker compose --env-file .env.docker up -d app # Restart the scheduler too if it was enabled. ``` From 2d4ee1bb0ead10d41f4a981761f570ac490ee69c Mon Sep 17 00:00:00 2001 From: srdco Date: Tue, 3 Nov 2026 14:56:08 -0600 Subject: [PATCH 17/20] Complete App Platform deployment setup --- config/database.php | 29 ++++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/config/database.php b/config/database.php index 62ee235b..2dd0018b 100644 --- a/config/database.php +++ b/config/database.php @@ -2,6 +2,32 @@ use Illuminate\Support\Str; +$mysqlSslCa = env('MYSQL_ATTR_SSL_CA'); +$mysqlSslCaBase64 = env('MYSQL_ATTR_SSL_CA_BASE64'); +if ($mysqlSslCaBase64 !== null && $mysqlSslCaBase64 !== '') { + $mysqlSslCaContents = base64_decode($mysqlSslCaBase64, true); + if ($mysqlSslCaContents === false) { + throw new InvalidArgumentException('MYSQL_ATTR_SSL_CA_BASE64 must contain valid base64 data.'); + } + + $mysqlSslCa = sys_get_temp_dir().'/phpip-mysql-ca-'.hash('sha256', $mysqlSslCaContents).'.pem'; + if (! is_file($mysqlSslCa) || file_get_contents($mysqlSslCa) !== $mysqlSslCaContents) { + $temporaryCaFile = tempnam(sys_get_temp_dir(), 'phpip-mysql-ca-'); + $caFileCreated = $temporaryCaFile !== false + && file_put_contents($temporaryCaFile, $mysqlSslCaContents) !== false + && chmod($temporaryCaFile, 0600) + && rename($temporaryCaFile, $mysqlSslCa); + + if (! $caFileCreated) { + if (is_string($temporaryCaFile)) { + @unlink($temporaryCaFile); + } + + throw new RuntimeException('Unable to materialize the MySQL CA certificate.'); + } + } +} + return [ /* @@ -60,7 +86,8 @@ 'strict' => false, 'engine' => null, 'options' => extension_loaded('pdo_mysql') ? array_filter([ - (class_exists(\Pdo\Mysql::class) ? \Pdo\Mysql::ATTR_SSL_CA : \PDO::MYSQL_ATTR_SSL_CA) => env('MYSQL_ATTR_SSL_CA'), + (class_exists(\Pdo\Mysql::class) ? \Pdo\Mysql::ATTR_SSL_CA : \PDO::MYSQL_ATTR_SSL_CA) => $mysqlSslCa, + (class_exists(\Pdo\Mysql::class) ? \Pdo\Mysql::ATTR_SSL_VERIFY_SERVER_CERT : \PDO::MYSQL_ATTR_SSL_VERIFY_SERVER_CERT) => $mysqlSslCa ? true : null, ]) : [], ], From 1b976dc5927f86b5b9f5be39df7af960a34fc48c Mon Sep 17 00:00:00 2001 From: srdco Date: Tue, 3 Nov 2026 14:56:14 -0600 Subject: [PATCH 18/20] Complete App Platform deployment setup --- docs/DOCKER.md | 29 ++++++++++++++++++++++++----- 1 file changed, 24 insertions(+), 5 deletions(-) diff --git a/docs/DOCKER.md b/docs/DOCKER.md index 7c7168e7..e98fa6d4 100644 --- a/docs/DOCKER.md +++ b/docs/DOCKER.md @@ -18,8 +18,18 @@ the app component from that database's connection details. Use its private host when both components are in the same DigitalOcean network. Do not set `DB_HOST=127.0.0.1` unless MySQL actually runs inside the app container. +DigitalOcean managed MySQL requires TLS. Download the cluster CA certificate +from its database connection details, base64-encode it locally with +`base64 -w0 ca-certificate.crt` (on macOS, use +`base64 < ca-certificate.crt | tr -d '\n'`), and set the output as the +`MYSQL_ATTR_SSL_CA_BASE64` app environment variable. Laravel writes it to a +private temporary file and configures PDO to validate the database certificate. +Keep certificate verification enabled. + Also configure a persistent `APP_KEY`, `APP_URL` (your public `https://` URL), -and `APP_TIMEZONE` in the app component. Generate the key with +`APP_TIMEZONE`, and `SESSION_DRIVER=cookie` in the app component. The cookie +session driver works across App Platform instances without a shared filesystem +or an extra session table. Generate the key with `php artisan key:generate --show` in a local checkout and retain the same value across deployments. The container exits at startup if `APP_KEY` is missing. For App Platform's TLS-terminating ingress, set `TRUSTED_PROXIES=*` so Laravel @@ -40,7 +50,8 @@ On a new database, run both `php artisan migrate --force` and baseline seeder is required for production too: it loads countries, roles, event and matter types, templates, and the initial account, but does not load sample matters. Before seeding a production database, set `INITIAL_USER_PASSWORD` to a -unique secret in the app component; production seeding stops if it is missing. +unique secret (for example, `openssl rand -hex 24`) in the app component; +production seeding stops if it is missing. After the seed succeeds, remove that environment variable and sign in as `phpipuser` with the password you supplied, then change it in the app. Never seed an existing production database as an upgrade step. @@ -88,7 +99,8 @@ Set `APP_KEY` in `.env.docker` to `base64:` followed by the output of `openssl rand -base64 32`. Set distinct `DB_PASSWORD` and `MYSQL_ROOT_PASSWORD` values, for example with `openssl rand -hex 24` twice. Set your company name, locale, timezone, and `APP_URL` for your domain. Set `INITIAL_USER_PASSWORD` to -a unique value before running the seed command. Keep the key and database +a unique value (for example, `openssl rand -hex 24`) before running the seed +command. Keep the key and database passwords stable. Leave `APP_BIND_ADDRESS=127.0.0.1` when an HTTPS reverse proxy will forward requests to the Droplet's port 8000. Compose does not configure DNS or HTTPS. For TLS termination at that proxy, set `TRUSTED_PROXIES` to its @@ -269,8 +281,15 @@ The runtime image excludes development dependencies, including PHPUnit. The Docker GitHub Actions workflow builds a fresh stack, migrates and seeds it, checks HTTP login and compiled assets, and replaces the app container to check database and storage persistence. The same HTTP check can be run against a -disposable seeded stack with `python3 tests/docker-smoke.py`. This is a container -smoke check, not the full application test suite. +disposable seeded stack by exporting the password from `.env.docker`: + +```sh +export INITIAL_USER_PASSWORD="$(sed -n 's/^INITIAL_USER_PASSWORD=//p' .env.docker)" +python3 tests/docker-smoke.py +unset INITIAL_USER_PASSWORD +``` + +This is a container smoke check, not the full application test suite. The image creates active `config/renewal.php` and `config/renewr.php` from their tracked example files. This gives renewal pages usable defaults and lets the From 1df8df9cb2728eeb93c20cc23d1c659f191d6ec6 Mon Sep 17 00:00:00 2001 From: srdco Date: Tue, 3 Nov 2026 14:56:19 -0600 Subject: [PATCH 19/20] Complete App Platform deployment setup --- tests/docker-smoke.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/docker-smoke.py b/tests/docker-smoke.py index 959cecc1..f4ed1e32 100644 --- a/tests/docker-smoke.py +++ b/tests/docker-smoke.py @@ -57,7 +57,7 @@ def get(path): { "_token": page.token, "login": "phpipuser", - "password": os.environ.get("INITIAL_USER_PASSWORD", "changeme"), + "password": os.environ.get("INITIAL_USER_PASSWORD") or "changeme", } ).encode() with opener.open(base + "/login", data=payload, timeout=15) as response: From 174bc70af6b5ef527b782fe16329efc5aa45c639 Mon Sep 17 00:00:00 2001 From: srdco Date: Tue, 3 Nov 2026 16:21:31 -0600 Subject: [PATCH 20/20] Document secure single-instance App Platform setup --- docs/DOCKER.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/docs/DOCKER.md b/docs/DOCKER.md index e98fa6d4..9a8d06ac 100644 --- a/docs/DOCKER.md +++ b/docs/DOCKER.md @@ -27,9 +27,11 @@ private temporary file and configures PDO to validate the database certificate. Keep certificate verification enabled. Also configure a persistent `APP_KEY`, `APP_URL` (your public `https://` URL), -`APP_TIMEZONE`, and `SESSION_DRIVER=cookie` in the app component. The cookie -session driver works across App Platform instances without a shared filesystem -or an extra session table. Generate the key with +`APP_TIMEZONE`, `SESSION_DRIVER=cookie`, and `SESSION_SECURE_COOKIE=true` in the +app component. The cookie session driver avoids dependence on local filesystem +sessions and marks the authenticated cookie for HTTPS only. Keep the App +Platform service at one instance because login throttling uses the local +file-backed cache. Generate the key with `php artisan key:generate --show` in a local checkout and retain the same value across deployments. The container exits at startup if `APP_KEY` is missing. For App Platform's TLS-terminating ingress, set `TRUSTED_PROXIES=*` so Laravel