# 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.