# Pubwhale demo bundle Two self-contained versions of the Pubwhale site, packaged as pre-built Docker images. No source tree, no build step and no internet access required on the machine that runs it. Download: | Version | Compose file | Default URL | Source commit | | --- | --- | --- | --- | | Headless WordPress (bilingual) | `docker-compose.yml` | http://localhost:3026 | `de62711` | | CMS admin for the above | `docker-compose.yml` | http://localhost:8080/wp-admin | — | | Original, WordPress-less | `docker-compose.orig.yml` | http://localhost:3027 | `813e951` | Requirements: Docker Engine 24+ (or Docker Desktop) with the Compose plugin, about 3 GB of free disk, and ports 3026 / 8080 / 3027 free. --- ## 1. Download and verify ```sh curl -LO https://pubwhale-dl.demo.hkdeveloper.io/pubwhale-demo-bundle.tar.gz curl -LO https://pubwhale-dl.demo.hkdeveloper.io/SHA256SUMS sha256sum -c SHA256SUMS # macOS: shasum -a 256 -c SHA256SUMS ``` ## 2. Run ```sh tar -xzf pubwhale-demo-bundle.tar.gz cd pubwhale-demo-bundle ./load.sh # imports the images, writes .env, prepares media dir docker compose up -d # WordPress version ``` Then open: - **Site** — http://localhost:3026 - **CMS** — http://localhost:8080/wp-admin Log in with **`pubwhale`** / **`pubwhale-b61e8864cd2c`**. > This credential only ever unlocks *your own local copy*. It is deliberately > different from the password on the hosted demo, so publishing it here grants > access to nothing but the container you just started. The bundled database already contains the seeded bilingual content, so the site is populated the moment it comes up. First start takes ~30 s while MariaDB imports the dump. For the original, WordPress-less version: ```sh docker compose -f docker-compose.orig.yml up -d # http://localhost:3027 ``` The two stacks are independent — run either, or both at the same time. ### Changing ports Edit `.env` (`WEB_PORT`, `WORDPRESS_PORT`, `ORIG_WEB_PORT`) and `docker compose up -d` again. If you change `WORDPRESS_PORT`, update `WORDPRESS_PUBLIC_URL` to match or media URLs will point at the old port. ### Stopping ```sh docker compose down # stops containers, KEEPS all content docker compose down -v # also DELETES the database — see below ``` --- ## 3. Storage and media — how state persists The part worth understanding before putting real content in. | What | Where it lives | Survives `down` | Survives `down -v` | | --- | --- | --- | --- | | **Posts, pages, settings, users** | `db-data` Docker volume | yes | **no — destroyed** | | WordPress core + installed plugins | `wp-data` Docker volume | yes | no — destroyed | | **Uploaded media** | `./content/uploads` (plain host directory) | yes | **yes** | | Custom post types (News, Governance, IR, People) | `./wordpress/mu-plugins`, mounted read-only | yes | yes | | Both frontends | nothing — completely stateless | n/a | n/a | Three consequences: 1. **Your content is database rows, not files.** Every article, person and document created in wp-admin lives in MariaDB inside the `db-data` volume. Backing up the `uploads` folder alone backs up *none* of your editorial content. 2. **`down -v` is the destructive one.** Plain `down` keeps everything. Adding `-v` deletes `db-data` and `wp-data`, so all CMS content is gone. Media in `./content/uploads` would survive but be orphaned — the rows describing those files would not. 3. **The frontends hold no state** and can be destroyed and recreated freely. The WordPress version caches CMS responses for `WORDPRESS_REVALIDATE_SECONDS` (default 300), but WordPress pings `/api/revalidate` on every edit, so changes normally appear within a second or two. ### Why media is a folder, not a volume On the hosted demo, uploads sit inside the `wp-data` volume mixed in with WordPress core files — durable, but awkward to find and back up. This bundle bind-mounts `./content/uploads` instead, so media lands in a visible directory next to the compose file that you can zip, rsync or commit. WordPress runs as uid 33 (`www-data`), so that directory must be writable by uid 33 or uploads fail with a permissions error. `load.sh` handles this on Linux; on Docker Desktop the mount is already writable. If you see the warning, run: ```sh sudo chown -R 33:33 content/uploads ``` `content/uploads` ships **empty** — the seeded demo content is all text and references no images. Your first upload appears as `content/uploads///`. ### How the seeded content gets in `content/db.sql.gz` is mounted into `/docker-entrypoint-initdb.d/`. MariaDB runs that directory **only on the very first start with an empty `db-data` volume**, then ignores it forever. Your edits are never overwritten by a restart. To return to the pristine seeded state: ```sh docker compose down -v docker compose up -d ``` ### Backup and restore Capture the database **and** the media together: ```sh # Backup source .env docker compose exec -T db \ mariadb-dump -u root -p"$WORDPRESS_DB_ROOT_PASSWORD" \ --single-transaction pubwhale | gzip > backup-db.sql.gz tar -czf backup-uploads.tar.gz -C content uploads # Restore into a running stack gunzip -c backup-db.sql.gz | docker compose exec -T db \ mariadb -u root -p"$WORDPRESS_DB_ROOT_PASSWORD" pubwhale tar -xzf backup-uploads.tar.gz -C content ``` --- ## 4. Pointing it at a real domain One sharp edge. Next.js compiles `NEXT_PUBLIC_*` values into the **client** bundle at build time, so they cannot be overridden by environment variables at runtime. These images were built with localhost defaults: - `NEXT_PUBLIC_SITE_URL=http://localhost:3026` - `NEXT_PUBLIC_WORDPRESS_BASE_URL=http://localhost:8080` Editing `.env` updates server-rendered metadata, but two things stay pinned to the build-time values: `next/image` remote patterns and the `Content-Security-Policy` `connect-src`. Serve media from a different origin and images will be blocked by the browser. To target a real domain, rebuild from the repository: ```sh docker build --target runner \ --build-arg NEXT_PUBLIC_SITE_URL=https://www.example.com \ --build-arg NEXT_PUBLIC_WORDPRESS_BASE_URL=https://cms.example.com \ -t pubwhale-web:prod . ``` Then set `image: pubwhale-web:prod` in `docker-compose.yml`. If you put the CMS behind a TLS-terminating proxy, keep the `X-Forwarded-Proto` header — without it `wp-admin` redirect-loops, because `WP_SITEURL` is `https://` while the proxy forwards plain HTTP. The original WordPress-less image reads no `NEXT_PUBLIC_*` at build time and is domain-neutral. --- ## 5. What's in the archive ``` docker-compose.yml WordPress version docker-compose.orig.yml original version .env.example template; load.sh turns it into .env load.sh image import + first-run setup README.md this document images/*.tar.gz all five images (fully offline) content/db.sql.gz seeded database content/uploads/ media (bind-mounted, starts empty) wordpress/mu-plugins/ custom post types — required at runtime wordpress/seed/ source content for re-seeding wordpress/scripts/ init script for the `init` profile ``` `images/` contains `pubwhale-web`, `pubwhale-orig-web`, `wordpress:6-php8.3-apache`, `wordpress:cli-php8.3` and `mariadb:11`. If the target machine can pull from Docker Hub, deleting the last three shrinks the archive by roughly two thirds. ## Troubleshooting **Port already in use** — change `WEB_PORT` / `WORDPRESS_PORT` / `ORIG_WEB_PORT` in `.env`. **Site loads but has no articles** — the database import only runs on a first start with an empty volume. Run `docker compose down -v && docker compose up -d`. **Media upload fails with a permissions error** — `sudo chown -R 33:33 content/uploads`. **News/Governance/IR/People menus missing from wp-admin** — the `wordpress/mu-plugins` directory must sit next to the compose file; it is mounted read-only into the container and defines those post types. **`docker compose up` tries to pull from the internet** — you skipped `./load.sh`, which imports the bundled images.