Pushword ships a Docker setup built on FrankenPHP: PHP 8.5 with every extension Pushword needs, Caddy in front, and the image tooling the media pipeline shells out to.
It is optional: use it when installing the required extensions is the hard part. A native PHP 8.5 setup runs faster and keeps bin/console, the profiler and Xdebug one command away.
composer create-project asks, once, whether to use Docker — and recommends the answer that fits your machine, having checked which extensions your PHP actually has. Say no and nothing Docker-related is written.
To add them later, or to a project created before the question existed:
php bin/console pw:docker:init
It writes Dockerfile, compose.yaml, compose.prod.yaml, compose.postgresql.yaml, .dockerignore and docker/, and never overwrites an existing file unless you pass --force.
docker compose up --build
Then open HTTP_PORT changes the port).
The project is bind-mounted, so your editor stays the source of truth:
docker compose exec pushword php bin/console pw:page-scan --skip-external
Only var/cache is kept inside the container — Symfony bakes absolute paths into its compiled cache, so the host’s and the container’s cannot be the same directory. Everything else in var/, var/app.db above all, stays on the host where you can back it up.
The optional overlay replaces the Doctrine database with PostgreSQL while keeping the default stack unchanged:
POSTGRES_PASSWORD='choose-a-password' \
docker compose -f compose.yaml -f compose.postgresql.yaml up --build
The same overlay works in production:
POSTGRES_PASSWORD='choose-a-password' \
docker compose -f compose.prod.yaml -f compose.postgresql.yaml up -d --build
docker compose -f compose.prod.yaml up -d --build
Put your settings in a .env next to it:
CADDY_SERVER_NAME=example.com
HTTP_PORT=80
HTTPS_PORT=443
CADDY_SERVER_NAME is the one that matters: give it a domain rather than a port and Caddy obtains and renews the TLS certificate itself.
The default SQLite stack keeps its state on disk. The production stack puts each of those directories in a named volume:
| Volume | Path | Holds |
|---|---|---|
var | /app/var | the SQLite database, cache, logs |
media | /app/media | the originals you uploaded |
media_cache | /app/public/media | generated derivatives — regenerable |
static | /app/static | static-generator exports |
caddy_data | /data | TLS certificates |
postgres_data | /var/lib/postgresql/data | PostgreSQL data, when the overlay is used |
Delete var or media and you delete content. Back them up as you would any database:
docker compose -f compose.prod.yaml cp pushword:/app/var/app.db ./app.db.backup
With PostgreSQL, use its native backup tools instead:
docker compose -f compose.prod.yaml -f compose.postgresql.yaml \
exec -T postgres pg_dump -U pushword -d pushword > pushword.sql
Every boot runs doctrine:schema:update --force — Pushword has no migrations, the schema is derived from the entities — then assets:install and cache:clear.
On top of that, an empty var volume gets one super admin account, because an instance with no account cannot be logged into. Set the credentials before that first boot:
PUSHWORD_ADMIN_EMAIL=you@example.com
PUSHWORD_ADMIN_PASSWORD=…
The email defaults to admin@example.tld. Without a password, the entrypoint generates a temporary one, prints it once in the first-boot logs, and requires a change before administration can be accessed.
It creates no content: restore a database backup or let pw:flat:sync read your Markdown files. The demo pages composer create-project installs stay on your machine.
The account is created once: the entrypoint then drops a var/.pushword-seeded marker (pw:docker:init writes it for a project already installed on the host). A restored backup never gains an account either — the entrypoint first checks whether the database holds any user, since a restore into a fresh volume arrives without the marker.
The container runs classic per-request PHP. To switch it to a long-running kernel, install the runtime and set one variable:
composer require runtime/frankenphp-symfony
FRANKENPHP_WORKER_CONFIG=worker ./public/index.php 1
Pushword is safe to run this way; Performance explains why, and what the gain actually depends on.
There is only one, at the root of your project, and it serves both a native frankenphp run --config Caddyfile and the container. The container overrides its placeholders — CADDY_SERVER_NAME, CADDY_ADMIN, FRANKENPHP_WORKER_CONFIG — through the environment; unset, each falls back to the local default. Edit it once and both paths follow.
www-data in production. Fresh named volumes inherit their ownership from the image, which is why the four state directories are created there.yarn build before docker compose -f compose.prod.yaml build, or add a Node stage to the Dockerfile.svgo is not installed, so SVGs pass through unoptimized. Every other optimizer in the chain (cjpeg, pngquant, optipng, gifsicle, cwebp) is there.expose_php=Off, so PHP does not emit an X-Powered-By version header.