Private
Public Access
1
0
Files
websitebox/websitebox-brief (2).md
constantprojects a440026701 Initial commit: complete WebsiteBox project
Docker-based self-hosted WordPress deployment system with:
- Four-container stack (nginx, wordpress/php-fpm, mariadb, certbot)
- Automatic SSL via Let's Encrypt with self-signed fallback
- First-boot WordPress setup via WP-CLI (GeneratePress + child theme, plugins)
- Interactive setup wizard and one-line install script
- Backup, update, healthcheck, and SSL renewal scripts

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 15:24:23 -07:00

771 lines
38 KiB
Markdown

# WebsiteBox: Project Briefing Document
## Project Overview
**WebsiteBox** is an open-source, Docker-based deployment system for self-hosted WordPress portfolio sites. The primary use case is creators who need hosting independence from platforms with content policies (e.g., adult content portfolios), though the system is content-agnostic.
### Core Goals
1. **Zero-trust transparency**: All code is auditable, no telemetry, no external service dependencies beyond what the user explicitly configures
2. **Minimal CLI interaction**: Users should be able to deploy with `docker compose up -d` after running a one-time setup wizard
3. **VPS-agnostic**: Works identically on any VPS provider (BuyVM, Vultr, DigitalOcean, Hetzner, bare metal, etc.)
4. **Secure by default**: SSL via Let's Encrypt, hardened WordPress configuration, security plugins pre-installed
5. **Non-technical friendly**: A motivated non-developer should be able to follow the README and have a working site
### Explicit Non-Goals (v1)
- Payment processing of any kind
- Multi-site WordPress
- Automatic VPS provisioning (users provision their own VPS)
- Built-in CDN (users can put Cloudflare/BunnyCDN in front if desired)
- Email server (SMTP relay configuration only)
---
## Architecture
### Container Stack (Docker Compose)
```
┌─────────────────────────────────────────────────────────┐
│ Host VPS │
│ ┌───────────────────────────────────────────────────┐ │
│ │ Docker Compose Stack │ │
│ │ │ │
│ │ ┌─────────┐ ┌─────────┐ ┌─────────────────┐ │ │
│ │ │ nginx │──│ WordPress│──│ MariaDB │ │ │
│ │ │ :443 │ │ (PHP) │ │ (internal) │ │ │
│ │ │ :80 │ │ │ │ │ │ │
│ │ └────┬────┘ └─────────┘ └─────────────────┘ │ │
│ │ │ │ │
│ │ ┌────┴────┐ │ │
│ │ │ Certbot │ (Let's Encrypt SSL) │ │
│ │ └─────────┘ │ │
│ │ │ │
│ │ All persistent data in: ./websitebox-data/ │ │
│ │ ├── wordpress/ (WordPress files) │ │
│ │ ├── database/ (MariaDB data) │ │
│ │ ├── certs/ (SSL certificates) │ │
│ │ ├── certbot-webroot/ (ACME challenge files) │ │
│ │ ├── certbot-signal/ (certbot→nginx reload) │ │
│ │ └── backups/ (UpdraftPlus local backups) │ │
│ └───────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────┘
```
### Volume Strategy
All persistent data uses **bind mounts** under a single directory: `./websitebox-data/`. This is a deliberate choice over named Docker volumes:
- **User-friendly**: One visible folder contains everything. Users can browse, backup, or migrate by copying a single directory.
- **Transparent**: No hidden Docker volume state. `ls websitebox-data/` shows exactly what's persisted.
- **Portable**: `tar czf backup.tar.gz websitebox-data/` captures the entire site.
```
websitebox-data/
├── wordpress/ # Mounted to /var/www/html in wordpress container
├── database/ # Mounted to /var/lib/mysql in mariadb container
├── certs/ # Mounted to /etc/letsencrypt in nginx + certbot containers
├── certbot-webroot/ # Mounted to /var/www/certbot in nginx + certbot containers
├── certbot-signal/ # Shared signal directory for certbot→nginx reload trigger
└── backups/ # Mounted to /var/backups/websitebox in wordpress container (NOT inside /var/www/html — avoids nested mount conflict)
```
The `setup.sh` wizard creates this directory structure with appropriate permissions before first `docker compose up`. The `websitebox-data/` directory is gitignored.
**Compose file volume mappings** (all services use bind mounts, no named volumes):
```yaml
volumes:
# nginx
- ./websitebox-data/certs:/etc/letsencrypt
- ./websitebox-data/certbot-webroot:/var/www/certbot
# wordpress
- ./websitebox-data/wordpress:/var/www/html
- ./websitebox-data/backups:/var/backups/websitebox
# mariadb
- ./websitebox-data/database:/var/lib/mysql
```
> **Why `/var/backups/websitebox` instead of `/var/www/html/wp-content/backups`?** Mounting `backups/` inside the already-mounted `wordpress/` path creates a nested bind mount. This works in Docker but causes confusion: the `websitebox-data/wordpress/wp-content/backups/` directory on the host appears empty (shadowed by the overlay), and any writes UpdraftPlus makes to slightly different subpaths land in the wrong mount. Using a separate, non-overlapping container path avoids this entirely. UpdraftPlus is configured to write to `/var/backups/websitebox` via the first-boot entrypoint.
### Services
| Service | Image Base | Purpose |
|---------|------------|---------|
| `nginx` | `nginx:alpine` | Reverse proxy, SSL termination, static file serving |
| `wordpress` | `wordpress:php8.2-fpm-alpine` (custom Dockerfile) | WordPress with PHP-FPM + WP-CLI |
| `db` | `mariadb:11` | Database (internal network only, not exposed) |
| `certbot` | `certbot/certbot` | SSL certificate acquisition and renewal |
### Container Restart Policy
**All containers** must specify `restart: unless-stopped` in docker-compose.yml. This ensures the site survives VPS reboots without user intervention. Do not use `restart: always` (it restarts manually-stopped containers, which is confusing for debugging).
### Healthcheck Definitions
Each service defines a Docker `HEALTHCHECK` in docker-compose.yml:
| Service | Healthcheck command | Interval | Timeout | Retries |
|---------|-------------------|----------|---------|---------|
| `nginx` | `curl -f http://localhost/nginx-health || exit 1` (serve a static 200 at `/nginx-health` internally) | 30s | 10s | 3 |
| `wordpress` | `php-fpm-healthcheck || exit 1` (use [php-fpm-healthcheck](https://github.com/renatomefi/php-fpm-healthcheck), installed in Dockerfile) | 30s | 10s | 3 |
| `db` | `healthcheck --su-mysql --connect --innodb_initialized` (MariaDB's built-in healthcheck) | 30s | 10s | 5 |
| `certbot` | None (runs as a periodic task, not a long-lived service) | — | — | — |
The WordPress container should have `depends_on: db: condition: service_healthy` so it waits for MariaDB to be ready before starting.
### Network Architecture
- Single Docker network (`websitebox_internal`)
- Only nginx exposes ports to host (80, 443)
- MariaDB is internal-only (no port exposure)
- WordPress communicates with nginx via FastCGI
---
## File Structure
```
websitebox/
├── docker-compose.yml # Main compose file
├── .env.example # Template environment file
├── .env # User's actual config (gitignored)
├── websitebox-data/ # All persistent data (gitignored, created by setup.sh)
│ ├── wordpress/
│ ├── database/
│ ├── certs/
│ ├── certbot-webroot/
│ ├── certbot-signal/
│ └── backups/
├── setup.sh # Interactive CLI wizard
├── install.sh # Bootstrap script (curl target)
├── README.md # User-facing documentation
├── LICENSE # GPLv3
├── nginx/
│ ├── Dockerfile # Custom nginx image with entrypoint
│ ├── entrypoint.sh # SSL bootstrap logic (auto-acquires certs on first run)
│ ├── nginx.conf # Main nginx configuration
│ ├── wordpress.conf # WordPress-specific server block
│ ├── wordpress-ssl.conf # SSL-enabled server block (used after certs acquired)
│ └── ssl-params.conf # SSL hardening parameters
├── wordpress/
│ ├── Dockerfile # Custom WordPress image (includes WP-CLI)
│ ├── entrypoint.sh # First-boot setup logic (installs plugins/themes via WP-CLI)
│ ├── wp-config-docker.php # WordPress configuration template
│ ├── uploads.ini # PHP upload limits
│ └── wp-content/
│ ├── themes/
│ │ └── websitebox/ # Child theme
│ │ ├── style.css
│ │ ├── functions.php
│ │ ├── templates/ # Block theme templates
│ │ └── parts/
│ └── mu-plugins/
│ └── websitebox-setup.php # Status checker + XML-RPC disable (lightweight)
├── scripts/
│ ├── ssl-renew.sh # Certificate renewal (called by certbot container)
│ ├── backup.sh # Manual backup trigger
│ ├── update.sh # Update WebsiteBox to latest version
│ └── healthcheck.sh # Container health checks
└── docs/
├── SECURITY.md # Security practices documentation
├── TROUBLESHOOTING.md # Common issues and solutions
└── UPDATING.md # How to update WebsiteBox
```
---
## Setup Wizard Specification
### `setup.sh` Behavior
The setup wizard is a bash script that:
1. Checks prerequisites (Docker, Docker Compose, ports 80/443 available)
2. Collects configuration via interactive prompts
3. Generates `.env` file
4. Optionally runs initial SSL acquisition
5. Provides next-steps instructions
### Configuration Variables
| Variable | Prompt | Validation | Default |
|----------|--------|------------|---------|
| `DOMAIN` | "Enter your domain (e.g., example.com)" | Valid domain format | Required |
| `SITE_TITLE` | "Site title" | Non-empty | "My Portfolio" |
| `ADMIN_USER` | "WordPress admin username" | Non-empty, not "admin" | Required |
| `ADMIN_EMAIL` | "Admin email (for SSL & WP)" | Valid email | Required |
| `ADMIN_PASSWORD` | "Set your admin password (or press enter to auto-generate)" | Min 12 chars; if empty, auto-generate 20-char random | User-set preferred |
| `DB_PASSWORD` | N/A (auto-generated) | 32 char random | Auto |
| `DB_ROOT_PASSWORD` | N/A (auto-generated) | 32 char random | Auto |
| `AGE_GATE_ENABLED` | "Enable age verification gate? (y/n)" | y/n | y |
| `AGE_GATE_MIN_AGE` | "Minimum age" | 18-21 | 18 |
| `SMTP_HOST` | "SMTP server (optional, press enter to skip)" | Valid host or empty | Empty |
| `SMTP_USER` | If SMTP_HOST set | - | - |
| `SMTP_PASS` | If SMTP_HOST set | - | - |
| `BACKUP_RETENTION_DAYS` | "Days to keep local backups" | 1-365 | 30 |
### Wizard Output
```
═══════════════════════════════════════════════════════════
WebsiteBox Setup Complete!
═══════════════════════════════════════════════════════════
Configuration saved to .env
Next steps:
1. Point your domain's A record to this server's IP: XXX.XXX.XXX.XXX
2. Wait for DNS propagation (check: dig example.com)
3. Run: docker compose up -d
4. Access your site at: https://example.com
5. Log in at: https://example.com/wp-admin
Username: your_username
Password: ******* (the password you set during setup)
═══════════════════════════════════════════════════════════
```
If the user auto-generated a password (pressed enter at the password prompt), the output changes:
```
Password: [auto-generated, shown once here]
⚠ Your auto-generated password has also been saved to: .credentials
Delete this file after recording your password somewhere secure.
```
The `.credentials` file is only created when the password is auto-generated. If the user set their own password, no `.credentials` file is created (they already know their password).
---
## WordPress Configuration
### Pre-installed Plugins (via WP-CLI in container entrypoint)
Plugins are installed via WP-CLI during the WordPress container's **first boot**, before the user ever accesses wp-admin. This is handled by a custom entrypoint script, not by the mu-plugin.
**WP-CLI is bundled in the custom WordPress Docker image** (installed in the Dockerfile). This is a hard requirement — do not rely on the WordPress plugin API or PHP `exec()` for initial setup.
#### First-boot sequence (wordpress entrypoint.sh):
```bash
# 1. Run the default WordPress docker-entrypoint.sh (sets up wp-config, etc.)
# 2. Check for marker file: /var/www/html/.websitebox-setup-complete
# 3. If marker exists: skip setup, start PHP-FPM normally
# 4. If marker does NOT exist (first boot):
# a. Wait for MariaDB to accept connections (loop with wp db check, max 30 retries, 2s apart)
# b. Run WordPress core install if not already installed:
# wp core install --url=$DOMAIN --title="$SITE_TITLE" --admin_user=$ADMIN_USER
# --admin_password=$ADMIN_PASSWORD --admin_email=$ADMIN_EMAIL --skip-email
# c. Install and activate parent theme:
# wp theme install flavor --activate [or whatever the parent theme is]
# d. Activate child theme:
# wp theme activate websitebox
# e. Install and activate plugins:
# wp plugin install age-gate wordfence updraftplus --activate
# f. Configure WordPress settings:
# wp rewrite structure '/%postname%/'
# wp post delete 1 --force (Hello World)
# wp post delete 2 --force (Sample Page)
# g. Configure Age Gate settings based on env vars:
# wp option update age_gate_min_age $AGE_GATE_MIN_AGE
# [additional Age Gate options as needed]
# h. Create marker file: touch /var/www/html/.websitebox-setup-complete
# i. Log: "WebsiteBox first-run setup complete"
# 5. Start PHP-FPM
```
#### Error handling during first-boot:
- If MariaDB is unreachable after 30 retries (60s): exit with clear error message. Docker's restart policy will retry.
- If any `wp` command fails (e.g., wordpress.org unreachable for plugin downloads): log the specific failure, create a partial marker file (`.websitebox-setup-partial`) instead of `.websitebox-setup-complete`, and continue to start PHP-FPM so the user can at least access wp-admin. The mu-plugin detects the partial state and shows an admin notice with retry instructions.
- If `.websitebox-setup-complete` exists, the entire setup is skipped — this makes the entrypoint idempotent and safe across container restarts/rebuilds.
- If `.websitebox-setup-partial` exists (previous run failed partway), the entrypoint **re-runs the entire first-boot sequence from the top**. This is safe because every step is idempotent: `wp core install` no-ops if already installed, `wp theme install` no-ops if the theme exists, `wp plugin install` no-ops if the plugin exists, `wp option update` overwrites to the same value, and `wp post delete` no-ops if the post is already gone. No per-step state tracking is needed.
- On successful completion of all steps, the entrypoint replaces `.websitebox-setup-partial` (if present) with `.websitebox-setup-complete`.
#### Retry mechanism:
If first-boot setup partially fails, the user can trigger a retry by simply restarting the container:
```bash
docker compose restart wordpress
```
The entrypoint detects `.websitebox-setup-partial` and re-runs all setup steps idempotently. If the user wants to force a completely clean re-run (e.g., to change initial settings), they can remove the marker files first:
```bash
docker compose exec wordpress rm /var/www/html/.websitebox-setup-complete /var/www/html/.websitebox-setup-partial
docker compose restart wordpress
```
Both paths are documented in TROUBLESHOOTING.md.
| Plugin | Purpose | Configuration |
|--------|---------|---------------|
| **Age Gate** | Age verification overlay | Auto-configured via WP-CLI based on .env values |
| **Wordfence** | Security, firewall, malware scan | Defaults applied; disable external API calls where possible |
| **UpdraftPlus** | Backups | Local filesystem configured to `./websitebox-data/backups/`, retention set from .env |
### Theme
Use **GeneratePress** as the parent theme with a child theme called `websitebox`:
- Most lightweight popular theme (~10KB), excellent performance
- Mature codebase with extensive documentation and community support
- Works well with both classic editor and block editor
- No upsell nags in the free version
- Child theme provides:
- Age gate styling integration
- Pre-built portfolio page template
- Sensible typography and spacing defaults
- Custom color scheme appropriate for portfolio sites
- Removes GeneratePress branding from footer
GeneratePress is installed via WP-CLI in the WordPress container's first-boot entrypoint (not baked into the Docker image) so it receives normal WordPress theme updates.
### wp-config.php Hardening
```php
// Disable file editing in admin (theme/plugin editor only — does NOT block plugin installs/updates)
define('DISALLOW_FILE_EDIT', true);
// IMPORTANT: Do NOT set DISALLOW_FILE_MODS — it would prevent plugin/theme updates via the admin GUI.
// Force SSL admin
define('FORCE_SSL_ADMIN', true);
// Limit post revisions
define('WP_POST_REVISIONS', 10);
// Auto-update core for security releases
define('WP_AUTO_UPDATE_CORE', 'minor');
// Security salts (generated per-install by setup.sh)
// ... AUTH_KEY, SECURE_AUTH_KEY, etc.
// NOTE: XML-RPC is disabled via the mu-plugin (websitebox-setup.php), not here.
// add_filter() calls do not work in wp-config.php because the plugin API is not yet loaded.
// Custom table prefix (not wp_)
$table_prefix = 'wbox_';
```
### Must-Use Plugin: `websitebox-setup.php`
This mu-plugin is a **lightweight status checker**, not the installer. All heavy setup is done by the WordPress container entrypoint (see "Pre-installed Plugins" above).
The mu-plugin does the following:
1. On every admin page load, checks for marker files:
- If `.websitebox-setup-complete` exists: no action (normal operation)
- If `.websitebox-setup-partial` exists: shows an admin warning banner explaining which parts of setup failed and how to retry (`docker compose restart wordpress` after fixing the issue)
- If neither exists: shows an admin notice that setup is still in progress (container may still be running first-boot)
2. Provides a "WebsiteBox Status" widget on the admin dashboard showing:
- Setup status (complete / partial / in progress)
- Age gate status (enabled/disabled, minimum age)
- Backup status (last backup time if UpdraftPlus is active)
3. Disables XML-RPC (this was previously incorrectly placed in wp-config.php):
```php
add_filter('xmlrpc_enabled', '__return_false');
```
---
## Nginx Configuration
### Key Requirements
- HTTP → HTTPS redirect
- SSL with Let's Encrypt certificates
- Security headers (HSTS, X-Content-Type-Options, X-Frame-Options, CSP)
- FastCGI pass to WordPress container
- Static file caching headers
- Client upload size matching PHP limits
- Rate limiting on wp-login.php
### SSL Parameters (`ssl-params.conf`)
```nginx
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers off;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384;
ssl_session_timeout 1d;
ssl_session_cache shared:SSL:50m;
ssl_session_tickets off;
ssl_stapling on;
ssl_stapling_verify on;
resolver 1.1.1.1 8.8.8.8 valid=300s;
resolver_timeout 5s;
add_header Strict-Transport-Security "max-age=63072000" always;
add_header X-Content-Type-Options nosniff always;
add_header X-Frame-Options SAMEORIGIN always; # SAMEORIGIN, not DENY — WordPress admin uses iframes for media uploader, customizer, etc.
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
```
---
## SSL Certificate Management
### Initial Acquisition (Automatic via nginx entrypoint)
SSL certificate acquisition happens automatically on first `docker compose up` with no separate script required. The nginx container entrypoint handles the bootstrap sequence:
```bash
# nginx entrypoint logic (entrypoint.sh):
# 1. Check if SSL certificates exist at /etc/letsencrypt/live/$DOMAIN/
# 2. If certs DO exist:
# - Start nginx normally with SSL configuration
# 3. If certs DO NOT exist (first run):
# a. Generate a temporary self-signed certificate
# b. Start nginx serving HTTP only (port 80) with:
# - ACME challenge location (/.well-known/acme-challenge/) proxied to certbot-webroot volume
# - All other requests return a "Setting up SSL..." placeholder page
# c. Run certbot in webroot mode: certbot certonly --webroot -w /var/www/certbot -d $DOMAIN --agree-tos --email $ADMIN_EMAIL --non-interactive
# d. If certbot succeeds: reload nginx with full SSL config
# e. If certbot fails: log error clearly, keep serving HTTP with a message explaining DNS may not be ready yet,
# and provide instructions to retry: "docker compose restart nginx"
```
**Key constraint**: DNS must already be pointing to the VPS IP before `docker compose up -d` for automatic SSL to succeed. The setup wizard prints this requirement prominently. If the user starts containers before DNS propagates, the nginx placeholder page tells them what happened and how to fix it (just restart nginx after DNS resolves).
The separate `ssl-init.sh` script is **removed** — all SSL bootstrapping lives in the nginx entrypoint. This ensures `docker compose up -d` is truly one command to a working site.
### Renewal Strategy
- Certbot container runs a renewal check loop (twice daily, per Let's Encrypt recommendation)
- Uses `--webroot` mode with the shared `certbot-webroot` volume
- **Nginx reload after renewal**: The certbot container cannot send signals to sibling containers directly. Instead, certbot writes a timestamp file to a shared volume on successful renewal, and nginx runs a background inotifywait (or polling loop) that detects the change and reloads. The simpler alternative (used here): **mount the Docker socket into the certbot container** is avoided for security reasons. Instead, the certbot entrypoint loop runs `certbot renew` and on success writes a trigger file. The nginx container runs a sidecar loop that watches for this trigger and executes `nginx -s reload`.
**Implementation**: Both containers share a small signal volume:
```yaml
# In docker-compose.yml, add a shared signal directory:
certbot:
image: certbot/certbot
volumes:
- ./websitebox-data/certs:/etc/letsencrypt
- ./websitebox-data/certbot-webroot:/var/www/certbot
- ./websitebox-data/certbot-signal:/var/run/certbot-signal
entrypoint: "/bin/sh -c 'trap exit TERM; while :; do certbot renew --deploy-hook \"touch /var/run/certbot-signal/reload\"; sleep 12h & wait $${!}; done;'"
restart: unless-stopped
nginx:
# ... existing config ...
volumes:
- ./websitebox-data/certs:/etc/letsencrypt
- ./websitebox-data/certbot-webroot:/var/www/certbot
- ./websitebox-data/certbot-signal:/var/run/certbot-signal:ro
# nginx entrypoint must include a background watcher:
# while :; do if [ -f /var/run/certbot-signal/reload ]; then rm /var/run/certbot-signal/reload; nginx -s reload; fi; sleep 60; done &
```
The `certbot-signal/` directory is added to `websitebox-data/` and created by `setup.sh`. The `--deploy-hook` flag ensures the trigger file is only written when a certificate is actually renewed (not on every check). The nginx background loop checks once per minute and reloads only when the trigger file is present.
---
## Security Considerations
### Container Security
- All containers run as non-root where possible
- Read-only root filesystem for nginx container
- No privileged mode
- Minimal base images (Alpine where possible)
- No unnecessary packages
### Network Security
- MariaDB not exposed to host
- WordPress communicates internally only
- Only ports 80/443 exposed
### WordPress Security
- `DISALLOW_FILE_EDIT` prevents admin code editing
- Wordfence provides firewall and brute-force protection
- XML-RPC disabled
- Non-standard table prefix
- Strong auto-generated passwords
- Login rate limiting at nginx level
### Secrets Management
- All passwords auto-generated with cryptographically secure randomness
- Stored in `.env` file with restrictive permissions (chmod 600)
- `.credentials` file for initial admin password (user instructed to delete)
- WordPress salts generated uniquely per installation
---
## Backup Strategy
### Default Configuration
- UpdraftPlus configured for local filesystem backups
- Daily database backup
- Weekly full backup (files + database)
- Backups stored in `./websitebox-data/backups/` (bind mounted into the WordPress container)
- Configurable retention (default 30 days)
### Backup Retention Mechanism
The `BACKUP_RETENTION_DAYS` value from `.env` is applied in **two** places:
1. **UpdraftPlus settings**: The first-boot entrypoint configures UpdraftPlus to retain the matching number of scheduled backups (e.g., 30 days = 4 weekly full backups + 30 daily DB backups).
2. **Host-level cleanup cron**: The `scripts/backup.sh` script, when run manually, also prunes files in `./websitebox-data/backups/` older than `BACKUP_RETENTION_DAYS`. This catches any orphaned files UpdraftPlus doesn't clean up.
For automated host-level cleanup, the setup wizard optionally adds a weekly cron job:
```bash
0 3 * * 0 cd ~/websitebox && ./scripts/backup.sh --prune-only
```
### User Guidance
Documentation should explain:
- How to configure UpdraftPlus for remote storage (S3, Backblaze B2)
- Manual backup trigger via `./scripts/backup.sh`
- Restore procedures
---
## User Experience Flow
### Happy Path
1. User provisions Ubuntu VPS (any provider)
2. User SSHes into VPS
3. User runs: `curl -fsSL https://<INSTALL_URL_TBD>/install.sh | bash`
- This script:
- Detects OS (Ubuntu/Debian)
- Installs Docker and Docker Compose if not present
- Adds current user to docker group (activates via `sg docker` for current session)
- Clones the WebsiteBox repository
- Launches `setup.sh`
4. User answers wizard prompts
5. User points domain A record to VPS IP
6. User runs: `docker compose up -d`
- Containers start; nginx entrypoint auto-acquires SSL certificate
- WordPress entrypoint installs themes and plugins via WP-CLI
7. User visits `https://domain.com` — site is live with age gate
8. User visits `https://domain.com/wp-admin` — logs in and customizes
### Install Script Details (`install.sh`)
```bash
#!/bin/bash
# install.sh - Bootstrap script for WebsiteBox
# 1. Check if running as root or with sudo available
# 2. Detect OS (Ubuntu 20.04+, 22.04, 24.04, Debian 11, 12)
# 3. If Docker not installed:
# - Add Docker's official GPG key and repository
# - apt-get install docker-ce docker-ce-cli containerd.io docker-compose-plugin
# - Add user to docker group
# 4. Clone repo to ~/websitebox
# 5. Launch setup.sh with Docker group active:
# - If user is already in docker group (existing install): run ./setup.sh directly
# - If user was just added to docker group (fresh install): run via `sg docker -c "./setup.sh"`
# (sg activates the new group in a subshell without requiring logout/login)
# 6. After setup.sh completes, print next-steps instructions
# - Include note: "If 'docker compose' commands fail later, log out and back in
# to permanently activate Docker permissions, then try again."
```
The script should be idempotent — safe to run multiple times without breaking anything.
**Docker group strategy**: The `sg docker` command activates the docker group for the current shell session without requiring logout/login. This is the *only* mechanism used to bridge the gap — `setup.sh` and any `docker compose` commands within the install flow must run under `sg docker` if the group was just added. The "log out and back in" note is provided as guidance for *future* terminal sessions, not as a blocking step.
### Error Handling
The setup wizard should check for and clearly report:
- Docker not installed → provide installation command
- Docker Compose not installed → provide installation command
- Port 80 in use → identify process, suggest resolution
- Port 443 in use → identify process, suggest resolution
- Domain doesn't resolve to this IP → warning (not blocking, but clearly state that SSL will fail until DNS propagates)
- Insufficient disk space → warning
The nginx entrypoint should handle:
- SSL acquisition failure (DNS not ready) → serve HTTP placeholder page explaining the issue, log instructions to retry via `docker compose restart nginx`
- Certbot rate limiting → log error with link to Let's Encrypt rate limit docs, suggest using staging flag
The WordPress entrypoint should handle:
- MariaDB not ready → retry loop (30 attempts, 2s apart) before failing
- wordpress.org unreachable (plugin/theme download failure) → create partial marker, log which installs failed, continue to start PHP-FPM so the user can at least access wp-admin
- Partial previous setup → detect `.websitebox-setup-partial` marker, re-run entire first-boot sequence idempotently (all WP-CLI commands are safe to repeat)
---
## Update Mechanism
### Update Script (`scripts/update.sh`)
A simple bash script that handles WebsiteBox updates with minimal dependencies (bash, git, docker):
```bash
#!/bin/bash
# update.sh - Update WebsiteBox to latest version
# 1. Check for uncommitted local changes to tracked files
# - Warn user and abort if changes detected (they may have customized configs)
# 2. git fetch origin main
# 3. Show changelog/diff summary of what's changing
# 4. Prompt for confirmation
# 5. git pull origin main
# 6. docker compose pull (get latest base images)
# 7. docker compose up -d --build (rebuild with any Dockerfile changes)
# 8. docker image prune -f (clean up old images)
# 9. Run any migration scripts if present (check for scripts/migrations/*.sh)
# 10. Health check - verify containers are running
# 11. Print success message with any post-update notes
```
### Migration Scripts
For breaking changes, include versioned migration scripts:
- `scripts/migrations/001-example-migration.sh`
- Each script records completion in `.websitebox-migrations` file
- Update script runs any unapplied migrations in order
### What Gets Updated
| Component | Update method |
|-----------|---------------|
| WebsiteBox configs (nginx, docker-compose, entrypoints) | `update.sh` pulls from git |
| Base Docker images (nginx, MariaDB) | `docker compose pull` |
| WordPress core | Standard WP auto-updates (minor) or admin GUI (major) |
| Plugins (Wordfence, UpdraftPlus, Age Gate) | WordPress admin GUI |
| Themes (GeneratePress parent) | WordPress admin GUI |
| Themes (websitebox child) | `update.sh` pulls from git, copies into `websitebox-data/wordpress/wp-content/themes/` |
### User Instructions
Users run updates with:
```bash
cd ~/websitebox
./scripts/update.sh
```
The script should be safe to run anytime — if already up to date, it exits cleanly.
---
## Testing Requirements
### Automated Tests
- Dockerfile builds successfully
- Containers start and pass health checks
- nginx serves HTTP and redirects to HTTPS
- WordPress responds on HTTPS
- Age gate appears when enabled
- wp-admin is accessible
- Certbot can acquire staging certificate
### Manual Test Checklist
- [ ] Fresh Ubuntu 22.04 VPS install works
- [ ] Fresh Ubuntu 24.04 VPS install works
- [ ] Fresh Debian 12 VPS install works
- [ ] SSL certificate acquisition succeeds
- [ ] SSL renewal succeeds
- [ ] Age gate displays and respects cookie
- [ ] Wordfence activates without errors
- [ ] UpdraftPlus backup completes
- [ ] Restore from backup works
- [ ] Theme displays correctly
- [ ] Image uploads work (test large file)
---
## Documentation Requirements
### README.md Structure
1. One-paragraph description
2. Features list
3. Requirements (VPS specs, Docker, domain)
4. Quick Start (5 numbered steps)
5. Configuration Reference (all .env variables)
6. Customization Guide (theme, plugins)
7. Backup & Restore
8. Updating WebsiteBox
9. Uninstalling
10. Security Practices
11. Troubleshooting
12. Contributing
13. License
### Tone
- Direct and practical
- Assumes basic command-line familiarity but not expertise
- No jargon without explanation
- Examples for every configuration option
---
## Resolved Design Decisions
| Question | Decision |
|----------|----------|
| **Theme choice** | GeneratePress (parent) + websitebox (child theme). Mature, lightweight, well-documented. |
| **Install script scope** | Full bootstrap including Docker installation if missing. Supports Ubuntu 20.04+, Debian 11+. Uses `sg docker` for group activation without logout. |
| **Plugin installation method** | WP-CLI bundled in WordPress Docker image. First-boot entrypoint installs plugins/themes before user accesses wp-admin. Plugins live in `websitebox-data/wordpress/` bind mount and can be updated via WordPress admin GUI normally. |
| **Update mechanism** | Dedicated `update.sh` script. Pulls git changes, rebuilds containers, runs migrations. Minimal dependencies (bash, git, docker). |
| **Staging SSL** | Use production Let's Encrypt by default. Document staging flag for testing if users hit rate limits. |
| **SSL bootstrapping** | Automatic via nginx container entrypoint. No separate ssl-init.sh script. Nginx detects missing certs, serves HTTP for ACME challenge, acquires certs, reloads with SSL. |
| **Volume strategy** | Single `./websitebox-data/` bind mount directory with subfolders. No named Docker volumes. |
| **Docker group activation** | `sg docker` in install.sh for immediate activation. No logout/login required during install flow. |
| **Admin password** | User sets their own password during setup wizard. Auto-generate is fallback (press enter to skip). `.credentials` file only created for auto-generated passwords. |
| **XML-RPC disable** | In mu-plugin (`add_filter`), NOT in wp-config.php (plugin API not available at wp-config load time). |
| **Restart policy** | `restart: unless-stopped` on all containers. |
---
## Remaining Implementation Notes
- **Docker group caveat**: Resolved — `install.sh` uses `sg docker` to activate group membership without requiring logout. See Install Script Details above.
- **First-run plugin install**: Resolved — WP-CLI is bundled in the WordPress Docker image. All setup runs in the container entrypoint before the user accesses wp-admin. See "Pre-installed Plugins" section above.
- **Age Gate configuration**: The WordPress entrypoint sets Age Gate options via WP-CLI (`wp option update`) based on .env values (age, styling, cookie duration).
- **Wordfence firewall**: Wordfence's "extended protection" requires `.user.ini` modifications. Since we're using nginx (no `.htaccess`), the WordPress Dockerfile should create a compatible `.user.ini` in the webroot with Wordfence's recommended PHP settings. Document in SECURITY.md that `.htaccess`-based WAF rules don't apply to nginx and that the nginx rate limiting + Wordfence's application-level firewall provide equivalent protection.
- **Volume permissions**: The WordPress Dockerfile must `chown -R www-data:www-data /var/www/html` and ensure the entrypoint runs WP-CLI commands as `www-data` (use `--allow-root` is NOT acceptable; run as the correct user via `su -s /bin/sh -c "wp ..." www-data` or set `USER` in Dockerfile appropriately).
- **Backup path**: UpdraftPlus is configured in the WordPress entrypoint to write to `/var/backups/websitebox/` which maps to the host's `./websitebox-data/backups/` bind mount. This path is deliberately outside `/var/www/html` to avoid a nested bind mount conflict with the `wordpress/` volume.
- **XML-RPC**: Disabled in the mu-plugin via `add_filter('xmlrpc_enabled', '__return_false')`. NOT in wp-config.php (the plugin API isn't loaded at wp-config time).
---
## Success Criteria
A successful v1 meets these criteria:
1. A user with a fresh Ubuntu VPS, a domain, and basic terminal familiarity can deploy a working, SSL-secured WordPress portfolio site by following the README
2. The site has age verification enabled by default
3. All code is auditable in the repository
4. No data leaves the VPS except what the user explicitly configures (email, remote backups)
5. Documentation is sufficient that common issues are self-serviceable
6. The system survives VPS reboots (containers auto-restart)
7. SSL certificates auto-renew without intervention
---
## Repository & Licensing
- **Repository**: GitHub at `[org]/websitebox`
- **License**: GPLv3 (WordPress derivative requirement applies to the project as a whole)
- **Contributions**: Standard PR workflow, DCO sign-off
---
## Appendix: Recommended VPS Providers
For documentation, recommend these content-permissive providers:
| Provider | Min Cost | Notes |
|----------|----------|-------|
| BuyVM | $3.50/mo | Explicit "legal content" policy, free DDoS protection |
| Vultr | $6/mo | No explicit prohibition, widely used |
| OVHcloud | $5.50/mo | European, no explicit prohibition |
**Avoid**: Hetzner (explicit ban), DigitalOcean (vague TOS, enforcement risk), AWS/GCP/Azure (enterprise TOS complexity)