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..60f49289
--- /dev/null
+++ b/.env.docker.example
@@ -0,0 +1,34 @@
+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=
+
+# 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
+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..8076e2a3
--- /dev/null
+++ b/.github/workflows/docker.yml
@@ -0,0 +1,60 @@
+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, 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: |
+ 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..73b3e962
--- /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 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 \
+ && 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..ee61d581
--- /dev/null
+++ b/compose.yaml
@@ -0,0 +1,74 @@
+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: ""
+ # 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
+ 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:
+ 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
+ # 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/config/database.php b/config/database.php
index cec243b8..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 [
/*
@@ -56,10 +82,12 @@
'collation' => 'utf8mb4_0900_ai_ci',
'prefix' => '',
'prefix_indexes' => true,
+ 'timezone' => env('DB_TIMEZONE'),
'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,
]) : [],
],
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,
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)
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..5b5d4a9c
--- /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 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 gosu www-data "$@"
+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..9a8d06ac
--- /dev/null
+++ b/docs/DOCKER.md
@@ -0,0 +1,363 @@
+# 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.
+
+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),
+`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
+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.
+
+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`.
+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
+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 (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.
+
+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.
+
+## 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
+```
+
+After the Docker changes merge, clone the canonical upstream repository:
+
+```sh
+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,
+locale, timezone, and `APP_URL` for your domain. Set `INITIAL_USER_PASSWORD` to
+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
+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`. 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
+
+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. 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`.
+
+```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. 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.
+
+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 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
+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 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
+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
+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"' \
+ > "$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
+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 \
+ > "$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.
+```
+
+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..f4ed1e32
--- /dev/null
+++ b/tests/docker-smoke.py
@@ -0,0 +1,68 @@
+"""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 os
+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": os.environ.get("INITIAL_USER_PASSWORD") or "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.")