Pushword ships a Docker setup built on FrankenPHP: PHP 8.4 with every extension Pushword needs, Caddy in front, and the image tooling the media pipeline shells out to.
It is optional. A machine that already runs PHP 8.4 with the required extensions runs Pushword faster and more directly without it — bin/console, the profiler and Xdebug are all one command away. Docker earns its place when installing those extensions is the hard part.
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, .dockerignore and docker/, and never overwrites a file you have edited (--force if you want it to).
docker compose up --build
Then open http://localhost:8080 (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:list
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.
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.
Pushword keeps its state on disk, not in a database server. 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 |
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
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=…
Leave them unset and it falls back to the published admin@example.tld / p@ssword, and says so in the logs. Change them on first login.
It creates no content. Production content is yours, and it arrives the way you deploy it — a var/app.db you restore into the volume, or Markdown files pw:flat:sync reads. The demo pages composer create-project installs are development content and stay on your machine.
The account is created once: the entrypoint then drops a var/.pushword-seeded marker. A development project is marked by pw:docker:init itself, since it was already installed on the host.
A restored backup never gains an account, whatever address its own admin uses. The marker alone could not promise that — the backup above copies app.db on its own, so a restore into a fresh volume arrives without it — so before creating anything the entrypoint asks the database whether it holds any user at all, and says so in the logs when it does.
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.