Private
Public Access
1
0

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>
This commit is contained in:
constantprojects
2026-02-20 15:24:23 -07:00
commit a440026701
32 changed files with 3397 additions and 0 deletions

69
.env.example Normal file
View File

@@ -0,0 +1,69 @@
# WebsiteBox Configuration
# Copy this file to .env and fill in your values:
# cp .env.example .env
# Or run the setup wizard:
# ./setup.sh
# =============================================================================
# REQUIRED — Domain & Admin
# =============================================================================
# Your domain name (e.g., example.com)
DOMAIN=example.com
# WordPress site title
SITE_TITLE=My Portfolio
# WordPress admin credentials
ADMIN_USER=
ADMIN_EMAIL=
ADMIN_PASSWORD=
# =============================================================================
# DATABASE — Auto-generated by setup.sh (do not edit manually)
# =============================================================================
DB_NAME=websitebox
DB_USER=websitebox
DB_PASSWORD=
DB_ROOT_PASSWORD=
# =============================================================================
# AGE GATE
# =============================================================================
# Enable age verification gate (true/false)
AGE_GATE_ENABLED=true
# Minimum age (18-21)
AGE_GATE_MIN_AGE=18
# =============================================================================
# EMAIL (Optional) — SMTP relay for WordPress emails
# =============================================================================
SMTP_HOST=
SMTP_PORT=587
SMTP_USER=
SMTP_PASS=
SMTP_FROM=
# =============================================================================
# BACKUPS
# =============================================================================
# Days to keep local backups (1-365)
BACKUP_RETENTION_DAYS=30
# =============================================================================
# WORDPRESS SALTS — Auto-generated by setup.sh (do not edit manually)
# =============================================================================
AUTH_KEY=
SECURE_AUTH_KEY=
LOGGED_IN_KEY=
NONCE_KEY=
AUTH_SALT=
SECURE_AUTH_SALT=
LOGGED_IN_SALT=
NONCE_SALT=

23
.gitignore vendored Normal file
View File

@@ -0,0 +1,23 @@
# Environment and secrets
.env
.credentials
# Persistent data (created by setup.sh)
websitebox-data/
# Migration state
.websitebox-migrations
# OS files
.DS_Store
Thumbs.db
# Editor files
*.swp
*.swo
*~
.vscode/
.idea/
# Node (if any tooling added later)
node_modules/

99
CLAUDE.md Normal file
View File

@@ -0,0 +1,99 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Critical Rules
- **NEVER build, test, or run anything locally.** Always use Docker for all build/test/run operations.
- **`websitebox-brief (2).md` is the source of truth** for all project decisions and specifications. Always defer to it.
- **Do not load `websitebox-diagram (1).jsx`** unless the user explicitly asks for it — it is reference material only.
## Project Overview
WebsiteBox is a Docker-based, self-hosted WordPress deployment system. Users provision a VPS, run an install script, and get a working SSL-secured WordPress portfolio site with `docker compose up -d`. The project prioritizes zero-trust transparency, minimal CLI interaction, and VPS-agnostic deployment.
## Architecture
Four-container Docker Compose stack on a single Docker network (`websitebox_internal`):
- **nginx** (custom `nginx:alpine`) — Reverse proxy, SSL termination via Let's Encrypt. Only container exposing ports (80, 443). Entrypoint auto-acquires SSL certs on first boot.
- **wordpress** (custom `wordpress:php8.2-fpm-alpine`) — PHP-FPM with WP-CLI bundled. First-boot entrypoint installs WordPress core, GeneratePress theme, child theme, and plugins (Age Gate, Wordfence, UpdraftPlus) via WP-CLI.
- **db** (`mariadb:11`) — Internal only, never exposed to host.
- **certbot** (`certbot/certbot`) — Renewal loop every 12h, signals nginx via shared volume file trigger (no Docker socket mount).
All persistent data uses **bind mounts** under `./websitebox-data/` (not named Docker volumes):
- `wordpress/`, `database/`, `certs/`, `certbot-webroot/`, `certbot-signal/`, `backups/`
## Key Design Decisions
- **Restart policy**: `restart: unless-stopped` on all containers (not `always`).
- **SSL bootstrap**: Handled entirely in nginx entrypoint — no separate ssl-init.sh script. Nginx detects missing certs, serves HTTP for ACME challenge, acquires certs, reloads with SSL.
- **Certbot-to-nginx reload**: Certbot writes trigger file to shared `certbot-signal/` volume; nginx background loop detects and reloads. No Docker socket mounting.
- **WordPress setup**: All via WP-CLI in container entrypoint, not the mu-plugin. Uses marker files (`.websitebox-setup-complete` / `.websitebox-setup-partial`) for idempotency.
- **Backup path**: UpdraftPlus writes to `/var/backups/websitebox` (mapped to `./websitebox-data/backups/`), deliberately outside `/var/www/html` to avoid nested bind mount conflicts.
- **XML-RPC disable**: In mu-plugin via `add_filter('xmlrpc_enabled', '__return_false')`, NOT in wp-config.php (plugin API not loaded at wp-config time).
- **WP-CLI**: Bundled in WordPress Docker image. Run commands as `www-data`, never use `--allow-root`.
- **Table prefix**: `wbox_` (not default `wp_`).
- **wp-config**: Sets `DISALLOW_FILE_EDIT` (blocks theme/plugin editor) but NOT `DISALLOW_FILE_MODS` (would prevent plugin/theme updates via admin GUI).
## User Flow
1. `curl -fsSL <url>/install.sh | bash` — installs Docker, clones repo, runs setup wizard
2. `setup.sh` — interactive wizard, generates `.env`, creates `websitebox-data/` structure
3. User configures DNS A record
4. `docker compose up -d` — everything self-configures
## File Structure Conventions
- `nginx/entrypoint.sh` — SSL auto-bootstrap logic
- `wordpress/entrypoint.sh` — First-boot WP-CLI setup (theme/plugin install, site config)
- `wordpress/wp-content/mu-plugins/websitebox-setup.php` — Lightweight status checker + XML-RPC disable (not the installer)
- `wordpress/wp-content/themes/websitebox/` — Child theme (parent: GeneratePress)
- `scripts/``backup.sh`, `update.sh`, `ssl-renew.sh`, `healthcheck.sh`
- `.env` / `.env.example` — Configuration (gitignored / template)
## Commands
```bash
# Start the stack
docker compose up -d
# Rebuild after Dockerfile changes
docker compose up -d --build
# View logs
docker compose logs -f [service]
# Restart a specific service
docker compose restart wordpress
# Re-run first-boot setup (force clean)
docker compose exec wordpress rm /var/www/html/.websitebox-setup-complete /var/www/html/.websitebox-setup-partial
docker compose restart wordpress
# Update WebsiteBox
./scripts/update.sh
# Manual backup
./scripts/backup.sh
```
## Healthchecks
| Service | Check | Retries |
|---------|-------|---------|
| nginx | `curl -f http://localhost/nginx-health` | 3 |
| wordpress | `php-fpm-healthcheck` | 3 |
| db | `healthcheck --su-mysql --connect --innodb_initialized` | 5 |
WordPress depends on db with `condition: service_healthy`.
## Error Handling Patterns
- WordPress entrypoint: MariaDB retry loop (30 attempts, 2s apart). On partial failure, creates `.websitebox-setup-partial` marker and starts PHP-FPM anyway. All WP-CLI commands are idempotent — safe to re-run entire sequence.
- nginx entrypoint: On SSL failure, serves HTTP placeholder explaining DNS isn't ready. User retries with `docker compose restart nginx`.
- install.sh: Uses `sg docker` for immediate Docker group activation without logout/login.
## Non-Goals (v1)
Payment processing, multi-site WordPress, automatic VPS provisioning, built-in CDN, email server (SMTP relay config only).

14
LICENSE Normal file
View File

@@ -0,0 +1,14 @@
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies of this
license document, but changing it is not allowed.
The full text of the GNU General Public License v3 is available at:
https://www.gnu.org/licenses/gpl-3.0.txt
This project is licensed under the terms of the GNU General Public
License version 3 (GPLv3). WordPress is licensed under GPLv2 or later,
making GPLv3 a compatible choice for derivative works.

184
README.md Normal file
View File

@@ -0,0 +1,184 @@
# WebsiteBox
A Docker-based, self-hosted WordPress deployment system. Provision a VPS, run the installer, and get a working SSL-secured WordPress portfolio site with a single command. All code is auditable, no telemetry, no external dependencies beyond what you explicitly configure.
## Features
- **One-command deploy**: `docker compose up -d` starts everything
- **Automatic SSL**: Let's Encrypt certificates acquired and renewed automatically
- **Secure by default**: Hardened WordPress config, Wordfence firewall, XML-RPC disabled, rate-limited login
- **Age verification**: Pre-configured Age Gate plugin (optional)
- **Automated backups**: UpdraftPlus with configurable retention
- **VPS-agnostic**: Works on any Ubuntu/Debian VPS provider
- **Transparent**: All persistent data in one visible directory (`websitebox-data/`)
- **Auto-restart**: Survives VPS reboots without intervention
## Requirements
- A VPS with Ubuntu 20.04+ or Debian 11+ (1GB RAM minimum, 2GB recommended)
- A domain name with DNS access
- Basic command-line familiarity
### Recommended VPS 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 |
## Quick Start
1. **SSH into your VPS** and run the installer:
```bash
curl -fsSL https://raw.githubusercontent.com/websitebox/websitebox/main/install.sh | bash
```
2. **Answer the setup wizard** prompts (domain, admin credentials, etc.)
3. **Point your domain's A record** to your server's IP address
4. **Wait for DNS propagation**, then start:
```bash
cd ~/websitebox
docker compose up -d
```
5. **Visit your site** at `https://yourdomain.com` and log in at `https://yourdomain.com/wp-admin`
## Configuration Reference
All configuration is stored in `.env` (generated by `setup.sh`).
| Variable | Description | Default |
|----------|-------------|---------|
| `DOMAIN` | Your domain name | Required |
| `SITE_TITLE` | WordPress site title | My Portfolio |
| `ADMIN_USER` | WordPress admin username | Required |
| `ADMIN_EMAIL` | Admin email (for SSL & WordPress) | Required |
| `ADMIN_PASSWORD` | Admin password | Required |
| `AGE_GATE_ENABLED` | Enable age verification | true |
| `AGE_GATE_MIN_AGE` | Minimum age (18-21) | 18 |
| `SMTP_HOST` | SMTP server for email | (empty) |
| `SMTP_PORT` | SMTP port | 587 |
| `SMTP_USER` | SMTP username | (empty) |
| `SMTP_PASS` | SMTP password | (empty) |
| `BACKUP_RETENTION_DAYS` | Days to keep local backups | 30 |
Database passwords and WordPress salts are auto-generated by `setup.sh` — do not edit them manually.
## Customization Guide
### Theme
WebsiteBox uses a child theme built on [GeneratePress](https://generatepress.com/). Customize it in the WordPress admin under **Appearance > Customize**, or edit the child theme files directly:
```
websitebox-data/wordpress/wp-content/themes/websitebox/
├── style.css # Custom styles
├── functions.php # Theme functions
├── theme.json # Block theme settings (colors, typography)
├── templates/ # Block templates
└── parts/ # Template parts (header, footer)
```
### Plugins
Pre-installed plugins can be managed normally through the WordPress admin. Additional plugins can be installed via **Plugins > Add New**.
- **Age Gate**: Configure under Settings > Age Gate
- **Wordfence**: Configure under Wordfence > Dashboard
- **UpdraftPlus**: Configure under Settings > UpdraftPlus Backups
## Backup & Restore
### Automatic Backups
UpdraftPlus runs automatic backups stored in `websitebox-data/backups/`.
### Manual Backup
```bash
./scripts/backup.sh
```
This creates a database dump and compressed file backup in `websitebox-data/backups/`.
### Cleanup Old Backups
```bash
./scripts/backup.sh --prune-only
```
### Restore
1. Go to **Settings > UpdraftPlus Backups** in wp-admin
2. Select a backup to restore
3. Follow the UpdraftPlus restore wizard
### Remote Backups
For offsite backups, configure UpdraftPlus to send copies to Amazon S3, Backblaze B2, or other remote storage via the UpdraftPlus settings in wp-admin.
## Updating WebsiteBox
```bash
cd ~/websitebox
./scripts/update.sh
```
This pulls the latest changes, rebuilds containers, and runs any migrations. See [docs/UPDATING.md](docs/UPDATING.md) for details.
## Uninstalling
```bash
cd ~/websitebox
docker compose down
# Remove all data (IRREVERSIBLE):
# rm -rf websitebox-data/
```
## Security Practices
See [docs/SECURITY.md](docs/SECURITY.md) for a full overview. Key points:
- SSL via Let's Encrypt with auto-renewal
- WordPress file editor disabled (`DISALLOW_FILE_EDIT`)
- XML-RPC disabled
- Non-standard database table prefix
- Rate limiting on wp-login.php
- Wordfence firewall and brute-force protection
- MariaDB not exposed to host network
- Auto-generated cryptographic passwords and salts
## Troubleshooting
See [docs/TROUBLESHOOTING.md](docs/TROUBLESHOOTING.md) for common issues. Quick commands:
```bash
# Check container health
./scripts/healthcheck.sh
# View logs
docker compose logs -f nginx
docker compose logs -f wordpress
docker compose logs -f db
# Restart a service
docker compose restart nginx
# Re-run WordPress first-boot setup
docker compose exec wordpress rm /var/www/html/.websitebox-setup-complete /var/www/html/.websitebox-setup-partial
docker compose restart wordpress
```
## Contributing
1. Fork the repository
2. Create a feature branch
3. Make your changes
4. Submit a pull request with DCO sign-off (`git commit -s`)
## License
GPLv3 — see [LICENSE](LICENSE).

104
docker-compose.yml Normal file
View File

@@ -0,0 +1,104 @@
services:
nginx:
build:
context: ./nginx
container_name: websitebox-nginx
ports:
- "80:80"
- "443:443"
volumes:
- ./websitebox-data/certs:/etc/letsencrypt
- ./websitebox-data/certbot-webroot:/var/www/certbot
- ./websitebox-data/certbot-signal:/var/run/certbot-signal
- ./websitebox-data/wordpress:/var/www/html:ro
environment:
- DOMAIN=${DOMAIN}
- ADMIN_EMAIL=${ADMIN_EMAIL}
depends_on:
wordpress:
condition: service_started
restart: unless-stopped
networks:
- websitebox_internal
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost/nginx-health"]
interval: 30s
timeout: 10s
retries: 3
wordpress:
build:
context: ./wordpress
container_name: websitebox-wordpress
volumes:
- ./websitebox-data/wordpress:/var/www/html
- ./websitebox-data/backups:/var/backups/websitebox
environment:
- DOMAIN=${DOMAIN}
- SITE_TITLE=${SITE_TITLE}
- ADMIN_USER=${ADMIN_USER}
- ADMIN_EMAIL=${ADMIN_EMAIL}
- ADMIN_PASSWORD=${ADMIN_PASSWORD}
- WORDPRESS_DB_HOST=db
- WORDPRESS_DB_NAME=${DB_NAME}
- WORDPRESS_DB_USER=${DB_USER}
- WORDPRESS_DB_PASSWORD=${DB_PASSWORD}
- WORDPRESS_TABLE_PREFIX=wbox_
- WORDPRESS_CONFIG_EXTRA=
- AGE_GATE_ENABLED=${AGE_GATE_ENABLED}
- AGE_GATE_MIN_AGE=${AGE_GATE_MIN_AGE}
- BACKUP_RETENTION_DAYS=${BACKUP_RETENTION_DAYS}
- WORDPRESS_AUTH_KEY=${AUTH_KEY}
- WORDPRESS_SECURE_AUTH_KEY=${SECURE_AUTH_KEY}
- WORDPRESS_LOGGED_IN_KEY=${LOGGED_IN_KEY}
- WORDPRESS_NONCE_KEY=${NONCE_KEY}
- WORDPRESS_AUTH_SALT=${AUTH_SALT}
- WORDPRESS_SECURE_AUTH_SALT=${SECURE_AUTH_SALT}
- WORDPRESS_LOGGED_IN_SALT=${LOGGED_IN_SALT}
- WORDPRESS_NONCE_SALT=${NONCE_SALT}
depends_on:
db:
condition: service_healthy
restart: unless-stopped
networks:
- websitebox_internal
healthcheck:
test: ["CMD", "php-fpm-healthcheck"]
interval: 30s
timeout: 10s
retries: 3
db:
image: mariadb:11
container_name: websitebox-db
volumes:
- ./websitebox-data/database:/var/lib/mysql
environment:
- MARIADB_DATABASE=${DB_NAME}
- MARIADB_USER=${DB_USER}
- MARIADB_PASSWORD=${DB_PASSWORD}
- MARIADB_ROOT_PASSWORD=${DB_ROOT_PASSWORD}
restart: unless-stopped
networks:
- websitebox_internal
healthcheck:
test: ["CMD", "healthcheck", "--su-mysql", "--connect", "--innodb_initialized"]
interval: 30s
timeout: 10s
retries: 5
certbot:
image: certbot/certbot
container_name: websitebox-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
networks:
- websitebox_internal
networks:
websitebox_internal:
driver: bridge

63
docs/SECURITY.md Normal file
View File

@@ -0,0 +1,63 @@
# Security Practices
## Container Security
- All containers use minimal Alpine-based images where possible
- MariaDB is not exposed to the host network — it communicates only with WordPress on the internal Docker network
- Only ports 80 (HTTP) and 443 (HTTPS) are exposed to the host
- No containers run in privileged mode
- The Docker socket is never mounted into any container
- All containers use `restart: unless-stopped` (not `always`)
## SSL/TLS
- Let's Encrypt certificates are acquired automatically on first boot
- Certificates are renewed automatically every 12 hours (per Let's Encrypt recommendation)
- TLS 1.2+ only (TLS 1.0 and 1.1 are disabled)
- Strong cipher suites with forward secrecy
- HSTS header with a 2-year max-age
- OCSP stapling enabled
- SSL session tickets disabled for forward secrecy
## WordPress Hardening
- **`DISALLOW_FILE_EDIT`**: The theme and plugin file editor in wp-admin is disabled. This prevents an attacker with admin access from injecting code via the editor. Note: this does NOT block plugin/theme updates via the admin GUI — those still work normally.
- **XML-RPC disabled**: The XML-RPC endpoint is disabled both at the application level (via mu-plugin) and at the nginx level (returns 403). XML-RPC is a common attack vector for brute-force and DDoS amplification.
- **Non-standard table prefix**: Tables use `wbox_` instead of the default `wp_`, which mitigates automated SQL injection attacks targeting default table names.
- **Strong passwords**: Database passwords and WordPress salts are auto-generated using cryptographic randomness (`openssl rand`). Admin passwords must be at least 12 characters.
- **Post revisions limited**: WordPress stores a maximum of 10 revisions per post to limit database growth.
- **Minor auto-updates**: WordPress core security patches are applied automatically.
## Nginx Security
- **Rate limiting**: `wp-login.php` is rate-limited to 1 request/second with a burst of 3, mitigating brute-force login attempts.
- **Security headers**: HSTS, X-Content-Type-Options, X-Frame-Options (SAMEORIGIN), X-XSS-Protection, Referrer-Policy.
- **PHP upload restrictions**: Blocked in `uploads/` directory at nginx level.
- **Hidden files blocked**: Dotfiles (`.htaccess`, `.git`, etc.) return 403.
## Wordfence
Wordfence is pre-installed and provides:
- Application-level web application firewall (WAF)
- Brute-force login protection
- Malware scanning
- Real-time threat intelligence
Note: Wordfence's `.htaccess`-based WAF rules do not apply to nginx. The nginx rate limiting and Wordfence's application-level firewall provide equivalent protection.
## Secrets Management
- All secrets are stored in `.env` with permissions set to `600` (owner read/write only)
- The `.env` file is gitignored and never committed to the repository
- Auto-generated admin passwords are stored temporarily in `.credentials` — users are instructed to delete this file after recording the password
- WordPress salts are generated uniquely per installation
## Network Architecture
```
Internet → :80/:443 → nginx → wordpress (FastCGI :9000) → db (:3306 internal only)
certbot (ACME challenges)
```
Only nginx is reachable from the internet. All other services communicate on the internal Docker network (`websitebox_internal`).

187
docs/TROUBLESHOOTING.md Normal file
View File

@@ -0,0 +1,187 @@
# Troubleshooting
## Check Container Health
```bash
./scripts/healthcheck.sh
```
Or individually:
```bash
docker compose ps
docker compose logs -f nginx
docker compose logs -f wordpress
docker compose logs -f db
```
## SSL Certificate Issues
### "Setting up SSL..." page persists
Your DNS is not pointing to this server yet.
1. Verify DNS: `dig yourdomain.com`
2. The A record should show your server's IP
3. Once DNS is correct, restart nginx:
```bash
docker compose restart nginx
```
### Certificate renewal fails
Check certbot logs:
```bash
docker compose logs certbot
```
If you've hit Let's Encrypt rate limits, wait and retry. For testing, you can use the staging environment by editing `nginx/entrypoint.sh` and adding `--staging` to the certbot command.
### Force certificate re-acquisition
```bash
rm -rf websitebox-data/certs/live/yourdomain.com
docker compose restart nginx
```
## WordPress Issues
### WordPress first-boot setup failed partially
The admin dashboard will show a warning banner if setup was incomplete.
To retry:
```bash
docker compose restart wordpress
```
The setup will re-run idempotently — it's safe to run multiple times.
### Force a clean WordPress setup
To re-run the entire first-boot setup from scratch:
```bash
docker compose exec wordpress rm /var/www/html/.websitebox-setup-complete /var/www/html/.websitebox-setup-partial
docker compose restart wordpress
```
### WordPress shows "Error establishing a database connection"
1. Check that the database container is healthy:
```bash
docker compose ps db
docker compose logs db
```
2. Verify `.env` database credentials match what MariaDB was initialized with
3. If you changed database passwords after first run, you'll need to reset the database:
```bash
docker compose down
rm -rf websitebox-data/database
docker compose up -d
```
### Can't upload files / upload size limit
The default upload limit is 64MB. If you need larger uploads, edit `wordpress/uploads.ini`:
```ini
upload_max_filesize = 128M
post_max_size = 128M
```
Also update `nginx/nginx.conf`:
```nginx
client_max_body_size 128M;
```
Then rebuild:
```bash
docker compose up -d --build
```
## Database Issues
### MariaDB won't start
Check logs:
```bash
docker compose logs db
```
Common causes:
- Corrupted data: `rm -rf websitebox-data/database && docker compose up -d` (WARNING: destroys all data)
- Disk full: `df -h`
### Connect to database directly
```bash
docker compose exec db mariadb -u websitebox -p websitebox
```
## Docker Issues
### "permission denied" when running docker commands
```bash
sudo usermod -aG docker $USER
# Log out and back in, or run:
newgrp docker
```
### Containers won't start after VPS reboot
```bash
sudo systemctl start docker
docker compose up -d
```
### Port 80 or 443 already in use
Find what's using the port:
```bash
sudo ss -tlnp | grep ':80 '
sudo ss -tlnp | grep ':443 '
```
Common culprits: Apache (`sudo systemctl stop apache2`), another nginx instance, or Caddy.
## Backup Issues
### UpdraftPlus backup fails
Check that the backup directory is writable:
```bash
docker compose exec wordpress ls -la /var/backups/websitebox/
```
### Manual backup fails
```bash
./scripts/backup.sh
```
If this fails, check that the WordPress and database containers are running:
```bash
docker compose ps
```
## Resetting Everything
To completely reset WebsiteBox (WARNING: destroys all site data):
```bash
docker compose down
rm -rf websitebox-data/
rm .env .credentials
./setup.sh
docker compose up -d
```

59
docs/UPDATING.md Normal file
View File

@@ -0,0 +1,59 @@
# Updating WebsiteBox
## Quick Update
```bash
cd ~/websitebox
./scripts/update.sh
```
The update script will:
1. Check for local modifications to tracked files (warns and aborts if found)
2. Show you what's changing before applying
3. Ask for confirmation
4. Pull the latest code
5. Pull updated Docker base images
6. Rebuild containers with any changes
7. Clean up old Docker images
8. Run any required migrations
9. Verify all containers are healthy
## What Gets Updated
| Component | How it updates |
|-----------|---------------|
| WebsiteBox configs (nginx, docker-compose, entrypoints) | `update.sh` pulls from git |
| Base Docker images (nginx, MariaDB) | `docker compose pull` via update script |
| WordPress core (minor/security) | Automatic via WordPress auto-updates |
| WordPress core (major) | Manual via WordPress admin GUI |
| Plugins (Wordfence, UpdraftPlus, Age Gate) | Manual via WordPress admin GUI |
| GeneratePress parent theme | Manual via WordPress admin GUI |
| WebsiteBox child theme | `update.sh` pulls from git |
## Before Updating
- Your site data in `websitebox-data/` is never modified by updates
- If you've edited any tracked files (Dockerfiles, nginx configs, etc.), the update script will warn you
- Consider running a backup first: `./scripts/backup.sh`
## After Updating
Check that everything is working:
```bash
./scripts/healthcheck.sh
```
Visit your site and wp-admin to verify normal operation.
## Rollback
If an update causes issues:
```bash
cd ~/websitebox
git log --oneline -5 # Find the previous commit
git checkout <commit-hash> # Revert to that commit
docker compose up -d --build # Rebuild with previous code
```

140
install.sh Executable file
View File

@@ -0,0 +1,140 @@
#!/bin/bash
set -eo pipefail
# WebsiteBox Install Script
# Bootstrap script: installs Docker, clones repo, runs setup wizard
# Usage: curl -fsSL <url>/install.sh | bash
echo ""
echo "═══════════════════════════════════════════════════════════"
echo " WebsiteBox Installer"
echo "═══════════════════════════════════════════════════════════"
echo ""
# --- Check for root/sudo ---
if [ "$(id -u)" -eq 0 ]; then
SUDO=""
ACTUAL_USER="${SUDO_USER:-root}"
else
if ! command -v sudo &>/dev/null; then
echo "ERROR: This script requires root or sudo access."
exit 1
fi
SUDO="sudo"
ACTUAL_USER="$(whoami)"
fi
# --- Detect OS ---
if [ -f /etc/os-release ]; then
# shellcheck disable=SC1091
. /etc/os-release
OS_ID="$ID"
OS_VERSION="$VERSION_ID"
else
echo "ERROR: Cannot detect OS. /etc/os-release not found."
echo "WebsiteBox supports Ubuntu 20.04+ and Debian 11+."
exit 1
fi
case "$OS_ID" in
ubuntu)
if [ "${OS_VERSION%%.*}" -lt 20 ]; then
echo "ERROR: Ubuntu 20.04 or later is required (detected: ${OS_VERSION})."
exit 1
fi
;;
debian)
if [ "${OS_VERSION%%.*}" -lt 11 ]; then
echo "ERROR: Debian 11 or later is required (detected: ${OS_VERSION})."
exit 1
fi
;;
*)
echo "WARNING: Unsupported OS detected (${OS_ID} ${OS_VERSION})."
echo "WebsiteBox is tested on Ubuntu 20.04+ and Debian 11+."
read -rp "Continue anyway? (y/N) " cont
if [ "$cont" != "y" ] && [ "$cont" != "Y" ]; then
exit 1
fi
;;
esac
echo "Detected: ${OS_ID} ${OS_VERSION}"
# --- Install Docker if needed ---
DOCKER_JUST_INSTALLED=false
if command -v docker &>/dev/null; then
echo "Docker is already installed."
else
echo "Installing Docker..."
# Install prerequisites
$SUDO apt-get update -qq
$SUDO apt-get install -y -qq ca-certificates curl gnupg lsb-release
# Add Docker's GPG key
$SUDO install -m 0755 -d /etc/apt/keyrings
curl -fsSL "https://download.docker.com/linux/${OS_ID}/gpg" | $SUDO gpg --dearmor -o /etc/apt/keyrings/docker.gpg
$SUDO chmod a+r /etc/apt/keyrings/docker.gpg
# Add Docker repository
echo \
"deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/${OS_ID} \
$(. /etc/os-release && echo "$VERSION_CODENAME") stable" | \
$SUDO tee /etc/apt/sources.list.d/docker.list > /dev/null
# Install Docker
$SUDO apt-get update -qq
$SUDO apt-get install -y -qq docker-ce docker-ce-cli containerd.io docker-compose-plugin
# Add user to docker group
if [ "$ACTUAL_USER" != "root" ]; then
$SUDO usermod -aG docker "$ACTUAL_USER"
DOCKER_JUST_INSTALLED=true
echo "Docker installed. User '${ACTUAL_USER}' added to docker group."
fi
# Start and enable Docker
$SUDO systemctl start docker
$SUDO systemctl enable docker
echo "Docker installation complete."
fi
# --- Clone Repository ---
INSTALL_DIR="${HOME}/websitebox"
if [ -d "$INSTALL_DIR" ]; then
echo "WebsiteBox directory already exists at ${INSTALL_DIR}"
echo "Pulling latest changes..."
cd "$INSTALL_DIR"
git pull || true
else
echo "Cloning WebsiteBox..."
git clone https://github.com/websitebox/websitebox.git "$INSTALL_DIR"
cd "$INSTALL_DIR"
fi
# Make scripts executable
chmod +x setup.sh install.sh scripts/*.sh
# --- Run Setup Wizard ---
echo ""
if [ "$DOCKER_JUST_INSTALLED" = true ] && [ "$ACTUAL_USER" != "root" ]; then
# Activate docker group for this session without requiring logout/login
echo "Activating Docker permissions for current session..."
sg docker -c "./setup.sh"
else
./setup.sh
fi
echo ""
echo "If 'docker compose' commands fail later, log out and back in"
echo "to permanently activate Docker permissions, then try again."
echo ""

20
nginx/Dockerfile Normal file
View File

@@ -0,0 +1,20 @@
FROM nginx:alpine
RUN apk add --no-cache \
curl \
certbot \
openssl \
inotify-tools \
bash
COPY nginx.conf /etc/nginx/nginx.conf
COPY wordpress.conf /etc/nginx/conf.d/default.conf
COPY wordpress-ssl.conf /etc/nginx/conf.d/wordpress-ssl.conf.disabled
COPY ssl-params.conf /etc/nginx/snippets/ssl-params.conf
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh && \
mkdir -p /etc/nginx/snippets /var/www/certbot /var/run/certbot-signal
ENTRYPOINT ["/entrypoint.sh"]
CMD ["nginx", "-g", "daemon off;"]

95
nginx/entrypoint.sh Executable file
View File

@@ -0,0 +1,95 @@
#!/bin/bash
set -eo pipefail
DOMAIN="${DOMAIN:-localhost}"
ADMIN_EMAIL="${ADMIN_EMAIL:-admin@example.com}"
CERT_DIR="/etc/letsencrypt/live/${DOMAIN}"
SIGNAL_DIR="/var/run/certbot-signal"
# Create setup-pending placeholder page
cat > /usr/share/nginx/html/setup-pending.html <<'HTMLEOF'
<!DOCTYPE html>
<html>
<head><title>WebsiteBox - Setting Up</title>
<style>body{font-family:sans-serif;display:flex;justify-content:center;align-items:center;min-height:100vh;margin:0;background:#f5f5f5}
.box{text-align:center;padding:2em;background:white;border-radius:8px;box-shadow:0 2px 10px rgba(0,0,0,0.1);max-width:500px}
h1{color:#333}p{color:#666;line-height:1.6}</style></head>
<body><div class="box">
<h1>Setting up SSL...</h1>
<p>Your WebsiteBox site is starting up and acquiring an SSL certificate.</p>
<p>If this page persists, your DNS may not be pointing to this server yet.
Once DNS is configured, restart nginx:</p>
<pre>docker compose restart nginx</pre>
</div></body></html>
HTMLEOF
echo "WebsiteBox nginx: checking SSL certificates for ${DOMAIN}..."
if [ -f "${CERT_DIR}/fullchain.pem" ] && [ -f "${CERT_DIR}/privkey.pem" ]; then
echo "SSL certificates found. Starting with SSL."
# Activate SSL config
sed "s/DOMAIN_PLACEHOLDER/${DOMAIN}/g" /etc/nginx/conf.d/wordpress-ssl.conf.disabled > /etc/nginx/conf.d/wordpress-ssl.conf
# Replace HTTP-only default with redirect
rm -f /etc/nginx/conf.d/default.conf
else
echo "No SSL certificates found. Starting HTTP-only for ACME challenge..."
# Generate temporary self-signed cert so nginx can start if needed
mkdir -p "${CERT_DIR}"
openssl req -x509 -nodes -days 1 -newkey rsa:2048 \
-keyout "${CERT_DIR}/privkey.pem" \
-out "${CERT_DIR}/fullchain.pem" \
-subj "/CN=${DOMAIN}" 2>/dev/null
# Start nginx in background with HTTP-only config
nginx -g "daemon on;"
echo "Requesting Let's Encrypt certificate for ${DOMAIN}..."
# Attempt certificate acquisition
if certbot certonly --webroot \
-w /var/www/certbot \
-d "${DOMAIN}" \
--agree-tos \
--email "${ADMIN_EMAIL}" \
--non-interactive \
--no-eff-email; then
echo "SSL certificate acquired successfully!"
# Activate SSL config
sed "s/DOMAIN_PLACEHOLDER/${DOMAIN}/g" /etc/nginx/conf.d/wordpress-ssl.conf.disabled > /etc/nginx/conf.d/wordpress-ssl.conf
rm -f /etc/nginx/conf.d/default.conf
# Stop background nginx — will be restarted by CMD
nginx -s stop
sleep 1
else
echo "WARNING: SSL certificate acquisition failed."
echo "This usually means DNS is not pointing to this server yet."
echo "Once DNS is configured, run: docker compose restart nginx"
# Stop background nginx — will be restarted by CMD with HTTP-only
nginx -s stop
sleep 1
# Remove the self-signed certs so we retry on next start
rm -rf "${CERT_DIR}"
fi
fi
# Start background loop to watch for certbot renewal signal
(
while true; do
if [ -f "${SIGNAL_DIR}/reload" ]; then
echo "Certbot renewal detected. Reloading nginx..."
rm -f "${SIGNAL_DIR}/reload"
nginx -s reload 2>/dev/null || true
fi
sleep 60
done
) &
# Execute the CMD (nginx -g daemon off)
exec "$@"

40
nginx/nginx.conf Normal file
View File

@@ -0,0 +1,40 @@
worker_processes auto;
error_log /var/log/nginx/error.log warn;
pid /var/run/nginx.pid;
events {
worker_connections 1024;
multi_accept on;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
access_log /var/log/nginx/access.log main;
sendfile on;
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout 65;
types_hash_max_size 2048;
# Gzip compression
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript image/svg+xml;
# Client body size (match PHP upload limits)
client_max_body_size 64M;
# Rate limiting zone for wp-login.php
limit_req_zone $binary_remote_addr zone=wplogin:10m rate=1r/s;
include /etc/nginx/conf.d/*.conf;
}

15
nginx/ssl-params.conf Normal file
View File

@@ -0,0 +1,15 @@
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;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;

107
nginx/wordpress-ssl.conf Normal file
View File

@@ -0,0 +1,107 @@
# HTTPS server block — activated after SSL certificates are acquired
server {
listen 443 ssl;
http2 on;
server_name DOMAIN_PLACEHOLDER;
ssl_certificate /etc/letsencrypt/live/DOMAIN_PLACEHOLDER/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/DOMAIN_PLACEHOLDER/privkey.pem;
include /etc/nginx/snippets/ssl-params.conf;
root /var/www/html;
index index.php index.html;
# Nginx healthcheck endpoint
location /nginx-health {
access_log off;
return 200 "healthy\n";
add_header Content-Type text/plain;
}
# ACME challenge (for renewals)
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
# Rate limit wp-login.php
location = /wp-login.php {
limit_req zone=wplogin burst=3 nodelay;
fastcgi_pass wordpress:9000;
fastcgi_index index.php;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
include fastcgi_params;
fastcgi_param HTTPS on;
}
# Block xmlrpc.php at nginx level
location = /xmlrpc.php {
deny all;
return 403;
}
# Block access to sensitive files
location ~ /\. {
deny all;
}
location ~* /(?:uploads|files)/.*\.php$ {
deny all;
}
# WordPress permalinks
location / {
try_files $uri $uri/ /index.php?$args;
}
# PHP handling via FastCGI to WordPress container
location ~ \.php$ {
try_files $uri =404;
fastcgi_split_path_info ^(.+\.php)(/.+)$;
fastcgi_pass wordpress:9000;
fastcgi_index index.php;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
include fastcgi_params;
fastcgi_param HTTPS on;
fastcgi_buffers 16 16k;
fastcgi_buffer_size 32k;
fastcgi_read_timeout 300;
}
# Static file caching
# NOTE: add_header in a location block overrides ALL parent add_header directives,
# so security headers must be repeated here.
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|webp|woff|woff2|ttf|eot)$ {
expires 30d;
add_header Cache-Control "public, immutable";
add_header Strict-Transport-Security "max-age=63072000" always;
add_header X-Content-Type-Options nosniff always;
add_header X-Frame-Options SAMEORIGIN always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
access_log off;
}
}
# HTTP to HTTPS redirect (replaces the HTTP-only block)
server {
listen 80;
server_name DOMAIN_PLACEHOLDER;
# ACME challenge must remain accessible over HTTP
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
# Nginx healthcheck endpoint
location /nginx-health {
access_log off;
return 200 "healthy\n";
add_header Content-Type text/plain;
}
location / {
return 301 https://$host$request_uri;
}
}

23
nginx/wordpress.conf Normal file
View File

@@ -0,0 +1,23 @@
# HTTP server block — used during SSL bootstrap and for ACME challenges
server {
listen 80;
server_name _;
# ACME challenge for Let's Encrypt
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
# Nginx healthcheck endpoint
location /nginx-health {
access_log off;
return 200 "healthy\n";
add_header Content-Type text/plain;
}
# Before SSL is acquired, serve a placeholder
location / {
root /usr/share/nginx/html;
try_files /setup-pending.html =404;
}
}

69
scripts/backup.sh Executable file
View File

@@ -0,0 +1,69 @@
#!/bin/bash
set -eo pipefail
# WebsiteBox Manual Backup Script
# Usage: ./scripts/backup.sh [--prune-only]
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
BACKUP_DIR="${PROJECT_DIR}/websitebox-data/backups"
# Load .env
if [ -f "${PROJECT_DIR}/.env" ]; then
# shellcheck disable=SC1091
source "${PROJECT_DIR}/.env"
fi
RETENTION_DAYS="${BACKUP_RETENTION_DAYS:-30}"
prune_old_backups() {
echo "Pruning backups older than ${RETENTION_DAYS} days..."
if [ -d "${BACKUP_DIR}" ]; then
find "${BACKUP_DIR}" -type f -mtime "+${RETENTION_DAYS}" -delete 2>/dev/null || true
echo "Pruning complete."
else
echo "Backup directory does not exist: ${BACKUP_DIR}"
fi
}
if [ "$1" = "--prune-only" ]; then
prune_old_backups
exit 0
fi
echo "═══════════════════════════════════════════════════════════"
echo " WebsiteBox Manual Backup"
echo "═══════════════════════════════════════════════════════════"
# Check that containers are running
if ! docker compose -f "${PROJECT_DIR}/docker-compose.yml" ps --status running | grep -q websitebox-wordpress; then
echo "ERROR: WordPress container is not running."
echo "Start it with: docker compose up -d"
exit 1
fi
# Create database dump
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
echo "Creating database backup..."
docker compose -f "${PROJECT_DIR}/docker-compose.yml" exec -T db \
mariadb-dump -u"${DB_USER:-websitebox}" -p"${DB_PASSWORD}" "${DB_NAME:-websitebox}" \
> "${BACKUP_DIR}/db_${TIMESTAMP}.sql"
echo "Database backup saved: websitebox-data/backups/db_${TIMESTAMP}.sql"
# Create files backup
echo "Creating files backup..."
tar czf "${BACKUP_DIR}/files_${TIMESTAMP}.tar.gz" \
-C "${PROJECT_DIR}/websitebox-data" \
--exclude='backups' \
--exclude='database' \
wordpress/ certs/
echo "Files backup saved: websitebox-data/backups/files_${TIMESTAMP}.tar.gz"
# Prune old backups
prune_old_backups
echo ""
echo "Backup complete!"
echo "Files stored in: websitebox-data/backups/"

52
scripts/healthcheck.sh Executable file
View File

@@ -0,0 +1,52 @@
#!/bin/bash
set -eo pipefail
# WebsiteBox Health Check
# Usage: ./scripts/healthcheck.sh
echo "═══════════════════════════════════════════════════════════"
echo " WebsiteBox Health Check"
echo "═══════════════════════════════════════════════════════════"
ALL_HEALTHY=true
for service in nginx wordpress db; do
container="websitebox-${service}"
status=$(docker inspect --format='{{.State.Health.Status}}' "$container" 2>/dev/null || echo "not found")
case "$status" in
healthy)
echo " [OK] ${service}: healthy"
;;
unhealthy)
echo " [FAIL] ${service}: unhealthy"
ALL_HEALTHY=false
;;
starting)
echo " [WAIT] ${service}: starting..."
;;
*)
echo " [????] ${service}: ${status}"
ALL_HEALTHY=false
;;
esac
done
# Certbot doesn't have a healthcheck — just check if running
certbot_state=$(docker inspect --format='{{.State.Status}}' websitebox-certbot 2>/dev/null || echo "not found")
if [ "$certbot_state" = "running" ]; then
echo " [OK] certbot: running"
else
echo " [WARN] certbot: ${certbot_state}"
fi
echo ""
if [ "$ALL_HEALTHY" = true ]; then
echo "All services are healthy."
exit 0
else
echo "Some services are not healthy. Check logs with:"
echo " docker compose logs -f [service]"
exit 1
fi

17
scripts/ssl-renew.sh Executable file
View File

@@ -0,0 +1,17 @@
#!/bin/bash
set -eo pipefail
# WebsiteBox SSL Renewal Helper
# Manually triggers a certificate renewal attempt via the certbot container.
# Automatic renewals are handled by the certbot container's built-in loop.
# Use this script only if you need to force an immediate renewal.
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
echo "Requesting certificate renewal..."
docker compose -f "${PROJECT_DIR}/docker-compose.yml" exec certbot \
certbot renew --deploy-hook "touch /var/run/certbot-signal/reload"
echo "Renewal attempt complete. Check logs for details:"
echo " docker compose logs certbot"

102
scripts/update.sh Executable file
View File

@@ -0,0 +1,102 @@
#!/bin/bash
set -eo pipefail
# WebsiteBox Update Script
# Usage: ./scripts/update.sh
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
cd "${PROJECT_DIR}"
echo "═══════════════════════════════════════════════════════════"
echo " WebsiteBox Update"
echo "═══════════════════════════════════════════════════════════"
# Check for uncommitted changes to tracked files
if ! git diff --quiet HEAD 2>/dev/null; then
echo "WARNING: You have uncommitted changes to tracked files."
echo "These files have been modified:"
git diff --name-only HEAD
echo ""
echo "Please commit or stash your changes before updating."
exit 1
fi
# Fetch latest changes
echo "Checking for updates..."
git fetch origin main 2>/dev/null || git fetch origin master 2>/dev/null || {
echo "ERROR: Could not fetch from remote repository."
exit 1
}
# Determine branch name
BRANCH="main"
if ! git rev-parse --verify origin/main >/dev/null 2>&1; then
BRANCH="master"
fi
# Check if already up to date
LOCAL=$(git rev-parse HEAD)
REMOTE=$(git rev-parse "origin/${BRANCH}")
if [ "${LOCAL}" = "${REMOTE}" ]; then
echo "Already up to date!"
exit 0
fi
# Show what's changing
echo ""
echo "Changes to be applied:"
git log --oneline "HEAD..origin/${BRANCH}"
echo ""
# Prompt for confirmation
read -rp "Apply these updates? (y/N) " confirm
if [ "${confirm}" != "y" ] && [ "${confirm}" != "Y" ]; then
echo "Update cancelled."
exit 0
fi
# Pull changes
echo "Pulling updates..."
git pull origin "${BRANCH}"
# Pull latest base images
echo "Pulling latest Docker images..."
docker compose pull
# Rebuild containers
echo "Rebuilding containers..."
docker compose up -d --build
# Clean up old images
echo "Cleaning up old images..."
docker image prune -f
# Run migrations if any exist
MIGRATIONS_DIR="${PROJECT_DIR}/scripts/migrations"
MIGRATIONS_STATE="${PROJECT_DIR}/.websitebox-migrations"
if [ -d "${MIGRATIONS_DIR}" ]; then
touch "${MIGRATIONS_STATE}"
for migration in "${MIGRATIONS_DIR}"/*.sh; do
[ -f "$migration" ] || continue
migration_name=$(basename "$migration")
if ! grep -qF "$migration_name" "${MIGRATIONS_STATE}" 2>/dev/null; then
echo "Running migration: ${migration_name}..."
bash "$migration"
echo "$migration_name" >> "${MIGRATIONS_STATE}"
fi
done
fi
# Health check
echo "Checking container health..."
sleep 10
"${SCRIPT_DIR}/healthcheck.sh"
echo ""
echo "═══════════════════════════════════════════════════════════"
echo " Update complete!"
echo "═══════════════════════════════════════════════════════════"

274
setup.sh Executable file
View File

@@ -0,0 +1,274 @@
#!/bin/bash
set -eo pipefail
# WebsiteBox Setup Wizard
# Generates .env file and creates data directories
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
cd "$SCRIPT_DIR"
echo ""
echo "═══════════════════════════════════════════════════════════"
echo " WebsiteBox Setup Wizard"
echo "═══════════════════════════════════════════════════════════"
echo ""
# --- Prerequisite Checks ---
check_command() {
if ! command -v "$1" &>/dev/null; then
echo "ERROR: $1 is not installed."
echo "$2"
exit 1
fi
}
check_command docker "Install Docker: https://docs.docker.com/engine/install/"
if ! docker compose version &>/dev/null; then
echo "ERROR: Docker Compose (v2) is not available."
echo "Install it: https://docs.docker.com/compose/install/"
exit 1
fi
# Check if Docker is accessible
if ! docker info &>/dev/null; then
echo "ERROR: Cannot connect to Docker daemon."
echo "Make sure Docker is running and your user has permission."
echo "You may need to: sudo usermod -aG docker \$USER && newgrp docker"
exit 1
fi
# Check ports
for port in 80 443; do
if ss -tlnp 2>/dev/null | grep -q ":${port} " || netstat -tlnp 2>/dev/null | grep -q ":${port} "; then
echo "WARNING: Port ${port} is already in use."
echo "Another service may need to be stopped first."
read -rp "Continue anyway? (y/N) " cont
if [ "$cont" != "y" ] && [ "$cont" != "Y" ]; then
exit 1
fi
fi
done
# --- Generate Random Strings ---
generate_password() {
local length="${1:-32}"
openssl rand -base64 48 | tr -dc 'a-zA-Z0-9' | head -c "$length"
}
generate_salt() {
openssl rand -base64 48 | head -c 64
}
# --- Collect Configuration ---
echo "Please provide the following configuration:"
echo ""
# Domain
while true; do
read -rp "Domain name (e.g., example.com): " DOMAIN
if [[ "$DOMAIN" =~ ^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?(\.[a-zA-Z]{2,})+$ ]]; then
break
fi
echo "Invalid domain format. Please enter a valid domain (e.g., example.com)"
done
# Site title
read -rp "Site title [My Portfolio]: " SITE_TITLE
SITE_TITLE="${SITE_TITLE:-My Portfolio}"
# Admin username
while true; do
read -rp "WordPress admin username: " ADMIN_USER
if [ -z "$ADMIN_USER" ]; then
echo "Username cannot be empty."
continue
fi
if [ "$ADMIN_USER" = "admin" ]; then
echo "For security, please choose a username other than 'admin'."
continue
fi
break
done
# Admin email
while true; do
read -rp "Admin email (used for SSL & WordPress): " ADMIN_EMAIL
if [[ "$ADMIN_EMAIL" =~ ^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$ ]]; then
break
fi
echo "Please enter a valid email address."
done
# Admin password
echo ""
echo "Set your WordPress admin password."
echo "Press Enter to auto-generate a secure 20-character password."
while true; do
read -rsp "Admin password: " ADMIN_PASSWORD
echo ""
if [ -z "$ADMIN_PASSWORD" ]; then
ADMIN_PASSWORD=$(generate_password 20)
PASSWORD_AUTO_GENERATED=true
echo "Password auto-generated."
break
fi
if [ ${#ADMIN_PASSWORD} -lt 12 ]; then
echo "Password must be at least 12 characters."
continue
fi
read -rsp "Confirm password: " ADMIN_PASSWORD_CONFIRM
echo ""
if [ "$ADMIN_PASSWORD" != "$ADMIN_PASSWORD_CONFIRM" ]; then
echo "Passwords do not match. Try again."
continue
fi
PASSWORD_AUTO_GENERATED=false
break
done
# Age Gate
echo ""
read -rp "Enable age verification gate? (Y/n): " AGE_GATE_INPUT
if [ "$AGE_GATE_INPUT" = "n" ] || [ "$AGE_GATE_INPUT" = "N" ]; then
AGE_GATE_ENABLED=false
AGE_GATE_MIN_AGE=18
else
AGE_GATE_ENABLED=true
read -rp "Minimum age (18-21) [18]: " AGE_GATE_MIN_AGE
AGE_GATE_MIN_AGE="${AGE_GATE_MIN_AGE:-18}"
if [ "$AGE_GATE_MIN_AGE" -lt 18 ] 2>/dev/null || [ "$AGE_GATE_MIN_AGE" -gt 21 ] 2>/dev/null; then
echo "Invalid age. Using default: 18"
AGE_GATE_MIN_AGE=18
fi
fi
# SMTP (optional)
echo ""
read -rp "SMTP server (optional, press Enter to skip): " SMTP_HOST
SMTP_PORT=587
SMTP_USER=""
SMTP_PASS=""
SMTP_FROM=""
if [ -n "$SMTP_HOST" ]; then
read -rp "SMTP port [587]: " SMTP_PORT_INPUT
SMTP_PORT="${SMTP_PORT_INPUT:-587}"
read -rp "SMTP username: " SMTP_USER
read -rsp "SMTP password: " SMTP_PASS
echo ""
read -rp "SMTP from address [${ADMIN_EMAIL}]: " SMTP_FROM_INPUT
SMTP_FROM="${SMTP_FROM_INPUT:-$ADMIN_EMAIL}"
fi
# Backup retention
read -rp "Days to keep local backups (1-365) [30]: " BACKUP_RETENTION_DAYS
BACKUP_RETENTION_DAYS="${BACKUP_RETENTION_DAYS:-30}"
# --- Auto-generated Values ---
DB_NAME="websitebox"
DB_USER="websitebox"
DB_PASSWORD=$(generate_password 32)
DB_ROOT_PASSWORD=$(generate_password 32)
AUTH_KEY=$(generate_salt)
SECURE_AUTH_KEY=$(generate_salt)
LOGGED_IN_KEY=$(generate_salt)
NONCE_KEY=$(generate_salt)
AUTH_SALT=$(generate_salt)
SECURE_AUTH_SALT=$(generate_salt)
LOGGED_IN_SALT=$(generate_salt)
NONCE_SALT=$(generate_salt)
# --- Write .env File ---
cat > .env <<ENVEOF
# WebsiteBox Configuration — Generated by setup.sh
# $(date)
DOMAIN='${DOMAIN}'
SITE_TITLE='${SITE_TITLE//\'/\'\\\'\'}'
ADMIN_USER='${ADMIN_USER}'
ADMIN_EMAIL='${ADMIN_EMAIL}'
ADMIN_PASSWORD='${ADMIN_PASSWORD//\'/\'\\\'\'}'
DB_NAME='${DB_NAME}'
DB_USER='${DB_USER}'
DB_PASSWORD='${DB_PASSWORD}'
DB_ROOT_PASSWORD='${DB_ROOT_PASSWORD}'
AGE_GATE_ENABLED=${AGE_GATE_ENABLED}
AGE_GATE_MIN_AGE=${AGE_GATE_MIN_AGE}
SMTP_HOST='${SMTP_HOST}'
SMTP_PORT=${SMTP_PORT}
SMTP_USER='${SMTP_USER}'
SMTP_PASS='${SMTP_PASS//\'/\'\\\'\'}'
SMTP_FROM='${SMTP_FROM}'
BACKUP_RETENTION_DAYS=${BACKUP_RETENTION_DAYS}
AUTH_KEY='${AUTH_KEY}'
SECURE_AUTH_KEY='${SECURE_AUTH_KEY}'
LOGGED_IN_KEY='${LOGGED_IN_KEY}'
NONCE_KEY='${NONCE_KEY}'
AUTH_SALT='${AUTH_SALT}'
SECURE_AUTH_SALT='${SECURE_AUTH_SALT}'
LOGGED_IN_SALT='${LOGGED_IN_SALT}'
NONCE_SALT='${NONCE_SALT}'
ENVEOF
chmod 600 .env
# --- Create Data Directories ---
mkdir -p websitebox-data/{wordpress,database,certs,certbot-webroot,certbot-signal,backups}
# --- Save Auto-generated Password ---
if [ "$PASSWORD_AUTO_GENERATED" = true ]; then
cat > .credentials <<CREDEOF
# WebsiteBox Admin Credentials
# DELETE THIS FILE after recording your password somewhere secure.
#
# Username: ${ADMIN_USER}
# Password: ${ADMIN_PASSWORD}
CREDEOF
chmod 600 .credentials
fi
# --- Get Server IP ---
SERVER_IP=$(curl -s -4 ifconfig.me 2>/dev/null || curl -s -4 icanhazip.com 2>/dev/null || echo "YOUR_SERVER_IP")
# --- Print Summary ---
echo ""
echo "═══════════════════════════════════════════════════════════"
echo " WebsiteBox Setup Complete!"
echo "═══════════════════════════════════════════════════════════"
echo ""
echo " Configuration saved to .env"
echo ""
echo " Next steps:"
echo " 1. Point your domain's A record to this server's IP: ${SERVER_IP}"
echo " 2. Wait for DNS propagation (check: dig ${DOMAIN})"
echo " 3. Run: docker compose up -d"
echo " 4. Access your site at: https://${DOMAIN}"
echo " 5. Log in at: https://${DOMAIN}/wp-admin"
echo " Username: ${ADMIN_USER}"
if [ "$PASSWORD_AUTO_GENERATED" = true ]; then
echo " Password: ${ADMIN_PASSWORD}"
echo ""
echo " WARNING: Your auto-generated password has also been saved to: .credentials"
echo " Delete this file after recording your password somewhere secure."
else
echo " Password: ******* (the password you set during setup)"
fi
echo ""
echo "═══════════════════════════════════════════════════════════"

770
websitebox-brief (2).md Normal file
View File

@@ -0,0 +1,770 @@
# 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)

400
websitebox-diagram (1).jsx Normal file
View File

@@ -0,0 +1,400 @@
import React, { useState } from 'react';
const WebsiteBoxDiagram = () => {
const [activeView, setActiveView] = useState('flow');
const UserStep = ({ number, title, description }) => (
<div className="flex items-start gap-3 p-4 bg-blue-50 border-2 border-blue-300 rounded-lg">
<div className="flex-shrink-0 w-8 h-8 bg-blue-500 text-white rounded-full flex items-center justify-center font-bold text-sm">
{number}
</div>
<div className="flex-1">
<div className="font-semibold text-blue-900">{title}</div>
<div className="text-sm text-blue-700 mt-1">{description}</div>
</div>
</div>
);
const AutomatedStep = ({ title, items }) => (
<div className="p-4 bg-green-50 border-2 border-green-300 rounded-lg">
<div className="font-semibold text-green-900 flex items-center gap-2">
<span className="text-green-500"></span> {title}
</div>
<ul className="mt-2 space-y-1">
{items.map((item, i) => (
<li key={i} className="text-sm text-green-700 flex items-start gap-2">
<span className="text-green-400 mt-0.5"></span>
<span>{item}</span>
</li>
))}
</ul>
</div>
);
const Arrow = ({ label }) => (
<div className="flex flex-col items-center py-2">
{label && <span className="text-xs text-gray-500 mb-1">{label}</span>}
<div className="text-gray-400 text-xl"></div>
</div>
);
const FlowView = () => (
<div className="space-y-2">
{/* Phase 1: Setup */}
<div className="bg-gray-100 rounded-xl p-4">
<h3 className="text-sm font-bold text-gray-600 uppercase tracking-wide mb-4">Phase 1: Initial Setup</h3>
<UserStep
number="1"
title="Provision VPS"
description="Sign up for BuyVM/Vultr/etc, create Ubuntu VPS, get IP address and SSH access"
/>
<Arrow label="SSH into server" />
<UserStep
number="2"
title="Run Install Command"
description={<span><span className="font-mono text-xs bg-blue-100 px-1 py-0.5 rounded">curl -fsSL https://&lt;INSTALL_URL_TBD&gt;/install.sh | bash</span></span>}
/>
<Arrow />
<AutomatedStep
title="install.sh runs automatically"
items={[
"Detects OS (Ubuntu/Debian)",
"Installs Docker & Docker Compose if missing",
"Adds user to docker group (activates via sg docker — no logout needed)",
"Clones WebsiteBox repository",
"Launches setup wizard"
]}
/>
<Arrow />
<UserStep
number="3"
title="Answer Setup Wizard"
description="Enter domain, site title, admin username, email, set your password, configure age gate"
/>
<Arrow />
<AutomatedStep
title="setup.sh generates configuration"
items={[
"Generates secure database passwords",
"Creates .env file with all settings",
"Generates WordPress security salts",
"Creates websitebox-data/ directory structure with correct permissions",
"Saves credentials to .credentials (only if password was auto-generated)"
]}
/>
</div>
{/* Phase 2: DNS */}
<div className="bg-gray-100 rounded-xl p-4">
<h3 className="text-sm font-bold text-gray-600 uppercase tracking-wide mb-4">Phase 2: Domain Configuration</h3>
<UserStep
number="4"
title="Configure DNS"
description="Point domain's A record to VPS IP address at your domain registrar"
/>
<div className="flex items-center justify-center py-3">
<div className="bg-yellow-100 border border-yellow-300 rounded-lg px-4 py-2 text-sm text-yellow-800">
Wait for DNS propagation (usually 5-30 min, can take up to 48h)
</div>
</div>
</div>
{/* Phase 3: Launch */}
<div className="bg-gray-100 rounded-xl p-4">
<h3 className="text-sm font-bold text-gray-600 uppercase tracking-wide mb-4">Phase 3: Launch</h3>
<UserStep
number="5"
title="Start WebsiteBox"
description={<span><span className="font-mono text-xs bg-blue-100 px-1 py-0.5 rounded">docker compose up -d</span></span>}
/>
<Arrow />
<AutomatedStep
title="Containers start and self-configure"
items={[
"Pulls/builds container images",
"MariaDB initializes and passes healthcheck",
"WordPress entrypoint: waits for DB → installs core, themes, plugins via WP-CLI",
"nginx entrypoint: detects no SSL cert → serves HTTP → runs certbot → reloads with HTTPS",
"Age Gate configured, default content removed, permalinks set"
]}
/>
<div className="flex items-center justify-center py-3">
<div className="bg-amber-50 border border-amber-300 rounded-lg px-4 py-2 text-sm text-amber-800">
If SSL fails (DNS not ready yet), nginx serves an HTTP page explaining the issue.
<div className="text-xs mt-1">Fix: wait for DNS to propagate, then <span className="font-mono bg-amber-100 px-1 rounded">docker compose restart nginx</span></div>
</div>
</div>
<Arrow />
<UserStep
number="6"
title="Visit Your Site"
description="https://yourdomain.com shows your site with age gate. Log in at /wp-admin to start customizing."
/>
</div>
{/* Phase 4: Customize */}
<div className="bg-gray-100 rounded-xl p-4">
<h3 className="text-sm font-bold text-gray-600 uppercase tracking-wide mb-4">Phase 4: Customize & Use</h3>
<UserStep
number="7"
title="Customize Site"
description="Add content, upload images, customize theme — all via WordPress admin GUI"
/>
<div className="mt-4 p-3 bg-white rounded-lg border border-gray-200">
<div className="text-sm font-medium text-gray-700 mb-2">Ongoing automated maintenance:</div>
<div className="grid grid-cols-2 gap-2 text-xs">
<div className="bg-green-50 p-2 rounded text-green-700">🔄 SSL auto-renewal (12h check)</div>
<div className="bg-green-50 p-2 rounded text-green-700">💾 Daily DB + weekly full backups</div>
<div className="bg-green-50 p-2 rounded text-green-700">🔒 Wordfence monitoring</div>
<div className="bg-green-50 p-2 rounded text-green-700">🚀 Container auto-restart on reboot</div>
<div className="bg-green-50 p-2 rounded text-green-700">🔧 WordPress minor auto-updates</div>
<div className="bg-green-50 p-2 rounded text-green-700">🗑 Backup retention cleanup</div>
</div>
</div>
</div>
</div>
);
const ArchitectureView = () => (
<div className="space-y-6">
{/* Server diagram */}
<div className="bg-gray-800 rounded-xl p-6 text-white">
<h3 className="text-sm font-bold text-gray-400 uppercase tracking-wide mb-4">VPS Server Architecture</h3>
<div className="flex items-center justify-center mb-4">
<div className="text-center">
<div className="text-3xl mb-1">🌐</div>
<div className="text-sm text-gray-400">Internet</div>
</div>
</div>
<div className="flex justify-center mb-2">
<div className="w-0.5 h-6 bg-gray-600"></div>
</div>
<div className="text-center text-xs text-gray-500 mb-2">Ports 80, 443 only</div>
<div className="flex justify-center mb-2">
<div className="w-0.5 h-6 bg-gray-600"></div>
</div>
{/* Docker container stack */}
<div className="border-2 border-dashed border-gray-600 rounded-xl p-4">
<div className="text-xs text-gray-500 mb-1 text-center">Docker Compose Stack</div>
<div className="text-xs text-gray-600 mb-3 text-center italic">restart: unless-stopped on all containers</div>
{/* Nginx */}
<div className="bg-green-700 rounded-lg p-3 mb-3">
<div className="font-semibold flex items-center gap-2">
<span>📦</span> nginx (custom image w/ entrypoint)
</div>
<div className="text-xs text-green-200 mt-1">SSL termination Auto-acquires certs on first boot Security headers Static files</div>
<div className="text-xs text-green-300 mt-1 opacity-75">Healthcheck: curl localhost/nginx-health</div>
</div>
<div className="flex justify-center mb-2">
<div className="text-gray-500 text-sm"> FastCGI</div>
</div>
{/* WordPress */}
<div className="bg-blue-700 rounded-lg p-3 mb-3">
<div className="font-semibold flex items-center gap-2">
<span>📦</span> wordpress (custom image w/ WP-CLI)
</div>
<div className="text-xs text-blue-200 mt-1">PHP-FPM First-boot entrypoint installs themes, plugins, configures site via WP-CLI</div>
<div className="text-xs text-blue-300 mt-1 opacity-75">Healthcheck: php-fpm-healthcheck depends_on: db (healthy)</div>
</div>
<div className="flex justify-center mb-2">
<div className="text-gray-500 text-sm"> MySQL protocol</div>
</div>
{/* MariaDB */}
<div className="bg-orange-700 rounded-lg p-3 mb-3">
<div className="font-semibold flex items-center gap-2">
<span>📦</span> mariadb (database)
</div>
<div className="text-xs text-orange-200 mt-1">Internal only Not exposed to host</div>
<div className="text-xs text-orange-300 mt-1 opacity-75">Healthcheck: healthcheck --connect --innodb_initialized</div>
</div>
{/* Certbot */}
<div className="bg-purple-700 rounded-lg p-3">
<div className="font-semibold flex items-center gap-2">
<span>📦</span> certbot (SSL renewal)
</div>
<div className="text-xs text-purple-200 mt-1">Renewal loop every 12h Shares certbot-webroot volume with nginx</div>
</div>
</div>
{/* Volumes */}
<div className="mt-4">
<div className="text-center text-xs text-gray-400 mb-2">./websitebox-data/ single bind mount directory, all persistent data</div>
<div className="grid grid-cols-5 gap-2">
{['wordpress/', 'database/', 'certs/', 'certbot-webroot/', 'backups/'].map(name => (
<div key={name} className="bg-gray-700 rounded p-2 text-center text-xs">
<div className="text-yellow-400 mb-1">💾</div>
{name}
</div>
))}
</div>
</div>
</div>
{/* What's automated vs manual */}
<div className="grid grid-cols-2 gap-4">
<div className="bg-green-50 border-2 border-green-200 rounded-xl p-4">
<h4 className="font-bold text-green-800 mb-3 flex items-center gap-2">
<span></span> WebsiteBox Handles
</h4>
<ul className="space-y-2 text-sm text-green-700">
<li className="flex items-start gap-2"><span></span> Docker installation</li>
<li className="flex items-start gap-2"><span></span> Server configuration</li>
<li className="flex items-start gap-2"><span></span> SSL certificates (acquire + renew)</li>
<li className="flex items-start gap-2"><span></span> WordPress + theme + plugin install</li>
<li className="flex items-start gap-2"><span></span> Security hardening</li>
<li className="flex items-start gap-2"><span></span> Age gate setup</li>
<li className="flex items-start gap-2"><span></span> Automatic backups + retention</li>
<li className="flex items-start gap-2"><span></span> WordPress minor auto-updates</li>
<li className="flex items-start gap-2"><span></span> Container auto-restart on reboot</li>
</ul>
</div>
<div className="bg-blue-50 border-2 border-blue-200 rounded-xl p-4">
<h4 className="font-bold text-blue-800 mb-3 flex items-center gap-2">
<span>👤</span> User Does Manually
</h4>
<ul className="space-y-2 text-sm text-blue-700">
<li className="flex items-start gap-2"><span></span> Provision VPS ($3-6/mo)</li>
<li className="flex items-start gap-2"><span></span> Register domain ($10-15/yr)</li>
<li className="flex items-start gap-2"><span></span> Configure DNS A record</li>
<li className="flex items-start gap-2"><span></span> Run install command</li>
<li className="flex items-start gap-2"><span></span> Answer setup wizard</li>
<li className="flex items-start gap-2"><span></span> Create site content</li>
<li className="flex items-start gap-2"><span></span> Update plugins (via WP admin GUI)</li>
<li className="flex items-start gap-2"><span></span> Run update.sh (occasionally)</li>
<li className="flex items-start gap-2 text-blue-400"><span></span> Configure remote backups (optional)</li>
<li className="flex items-start gap-2 text-blue-400"><span></span> Set up SMTP (optional)</li>
</ul>
</div>
</div>
</div>
);
const FilesView = () => (
<div className="bg-gray-900 rounded-xl p-4 font-mono text-sm">
<div className="text-gray-400 mb-4"># Repository structure</div>
<div className="space-y-1 text-gray-300">
<div className="text-yellow-400">websitebox/</div>
<div className="pl-4"> <span className="text-green-400">docker-compose.yml</span></div>
<div className="pl-4"> <span className="text-green-400">.env.example</span></div>
<div className="pl-4"> <span className="text-cyan-400">install.sh</span> <span className="text-gray-500"> curl this to start</span></div>
<div className="pl-4"> <span className="text-cyan-400">setup.sh</span> <span className="text-gray-500"> interactive wizard</span></div>
<div className="pl-4"> README.md</div>
<div className="pl-4"></div>
<div className="pl-4"> <span className="text-gray-600">websitebox-data/</span> <span className="text-gray-500"> all persistent data (gitignored)</span></div>
<div className="pl-8 text-gray-500"> wordpress/ database/ certs/ certbot-webroot/ backups/</div>
<div className="pl-4"></div>
<div className="pl-4"> <span className="text-yellow-400">nginx/</span></div>
<div className="pl-8"> Dockerfile</div>
<div className="pl-8"> <span className="text-cyan-400">entrypoint.sh</span> <span className="text-gray-500"> SSL auto-bootstrap</span></div>
<div className="pl-8"> nginx.conf</div>
<div className="pl-8"> wordpress.conf</div>
<div className="pl-8"> wordpress-ssl.conf</div>
<div className="pl-8"> ssl-params.conf</div>
<div className="pl-4"></div>
<div className="pl-4"> <span className="text-yellow-400">wordpress/</span></div>
<div className="pl-8"> Dockerfile <span className="text-gray-500"> includes WP-CLI</span></div>
<div className="pl-8"> <span className="text-cyan-400">entrypoint.sh</span> <span className="text-gray-500"> first-boot WP-CLI setup</span></div>
<div className="pl-8"> wp-config-docker.php</div>
<div className="pl-8"> uploads.ini</div>
<div className="pl-8"> <span className="text-yellow-400">wp-content/</span></div>
<div className="pl-12"> <span className="text-yellow-400">themes/websitebox/</span> <span className="text-gray-500"> child theme</span></div>
<div className="pl-12"> <span className="text-yellow-400">mu-plugins/</span></div>
<div className="pl-16"> websitebox-setup.php <span className="text-gray-500"> status checker + XML-RPC disable</span></div>
<div className="pl-4"></div>
<div className="pl-4"> <span className="text-yellow-400">scripts/</span></div>
<div className="pl-8"> <span className="text-cyan-400">ssl-renew.sh</span></div>
<div className="pl-8"> <span className="text-cyan-400">backup.sh</span> <span className="text-gray-500"> manual backup + --prune-only</span></div>
<div className="pl-8"> <span className="text-cyan-400">update.sh</span> <span className="text-gray-500"> update WebsiteBox</span></div>
<div className="pl-8"> <span className="text-cyan-400">healthcheck.sh</span></div>
<div className="pl-4"></div>
<div className="pl-4"> <span className="text-yellow-400">docs/</span></div>
<div className="pl-8"> SECURITY.md</div>
<div className="pl-8"> TROUBLESHOOTING.md</div>
<div className="pl-8"> UPDATING.md</div>
</div>
</div>
);
return (
<div className="min-h-screen bg-gray-50 p-6">
<div className="max-w-3xl mx-auto">
{/* Header */}
<div className="text-center mb-6">
<h1 className="text-3xl font-bold text-gray-900 mb-2">📦 WebsiteBox</h1>
<p className="text-gray-600">Self-hosted WordPress portfolio in minutes</p>
</div>
{/* View tabs */}
<div className="flex gap-2 mb-6 bg-white rounded-lg p-1 shadow-sm">
{[
{ id: 'flow', label: 'User Flow' },
{ id: 'architecture', label: 'Architecture' },
{ id: 'files', label: 'File Structure' }
].map(tab => (
<button
key={tab.id}
onClick={() => setActiveView(tab.id)}
className={`flex-1 py-2 px-4 rounded-md text-sm font-medium transition-colors ${
activeView === tab.id
? 'bg-blue-500 text-white'
: 'text-gray-600 hover:bg-gray-100'
}`}
>
{tab.label}
</button>
))}
</div>
{/* Content */}
{activeView === 'flow' && <FlowView />}
{activeView === 'architecture' && <ArchitectureView />}
{activeView === 'files' && <FilesView />}
{/* Legend */}
<div className="mt-6 flex justify-center gap-6 text-sm">
<div className="flex items-center gap-2">
<div className="w-4 h-4 rounded bg-blue-300 border-2 border-blue-400"></div>
<span className="text-gray-600">User action required</span>
</div>
<div className="flex items-center gap-2">
<div className="w-4 h-4 rounded bg-green-300 border-2 border-green-400"></div>
<span className="text-gray-600">Automated by WebsiteBox</span>
</div>
</div>
</div>
</div>
);
};
export default WebsiteBoxDiagram;

41
wordpress/Dockerfile Normal file
View File

@@ -0,0 +1,41 @@
FROM wordpress:php8.2-fpm-alpine
# Install dependencies
RUN apk add --no-cache \
bash \
less \
mysql-client \
fcgi
# Install WP-CLI
RUN curl -o /usr/local/bin/wp https://raw.githubusercontent.com/wp-cli/builds/gh-pages/phar/wp-cli.phar && \
chmod +x /usr/local/bin/wp
# Install php-fpm-healthcheck
RUN curl -o /usr/local/bin/php-fpm-healthcheck \
https://raw.githubusercontent.com/renatomefi/php-fpm-healthcheck/master/php-fpm-healthcheck && \
chmod +x /usr/local/bin/php-fpm-healthcheck
# Enable php-fpm status page for healthcheck
RUN echo "pm.status_path = /status" >> /usr/local/etc/php-fpm.d/zz-docker.conf
# Copy PHP upload config
COPY uploads.ini /usr/local/etc/php/conf.d/uploads.ini
# Copy wp-config template
COPY wp-config-docker.php /usr/src/wordpress/wp-config-docker.php
# Copy child theme and mu-plugins
COPY wp-content/themes/websitebox/ /usr/src/websitebox-theme/
COPY wp-content/mu-plugins/ /usr/src/websitebox-mu-plugins/
# Copy custom entrypoint
COPY entrypoint.sh /usr/local/bin/websitebox-entrypoint.sh
RUN chmod +x /usr/local/bin/websitebox-entrypoint.sh
# Create Wordfence-compatible .user.ini
RUN echo "; Wordfence PHP settings" > /usr/src/wordpress/.user.ini && \
echo "auto_prepend_file = /var/www/html/wordfence-waf.php" >> /usr/src/wordpress/.user.ini
ENTRYPOINT ["websitebox-entrypoint.sh"]
CMD ["php-fpm"]

181
wordpress/entrypoint.sh Executable file
View File

@@ -0,0 +1,181 @@
#!/bin/bash
set -eo pipefail
MARKER_COMPLETE="/var/www/html/.websitebox-setup-complete"
MARKER_PARTIAL="/var/www/html/.websitebox-setup-partial"
# Run the default WordPress docker-entrypoint.sh first
# This sets up wp-config.php, copies WordPress files, then execs php-fpm
docker-entrypoint.sh php-fpm &
WP_PID=$!
# Forward signals to PHP-FPM for graceful shutdown
trap 'kill -TERM $WP_PID; wait $WP_PID; exit $?' TERM INT
# Wait for WordPress files AND wp-config.php to be available
echo "WebsiteBox: Waiting for WordPress files..."
for i in $(seq 1 60); do
if [ -f /var/www/html/wp-includes/version.php ] && [ -f /var/www/html/wp-config.php ]; then
echo "WebsiteBox: WordPress files ready."
break
fi
if [ "$i" -eq 60 ]; then
echo "ERROR: WordPress files did not appear after 120 seconds."
exit 1
fi
sleep 2
done
# Copy mu-plugins into place
mkdir -p /var/www/html/wp-content/mu-plugins
cp -f /usr/src/websitebox-mu-plugins/*.php /var/www/html/wp-content/mu-plugins/ 2>/dev/null || true
chown -R www-data:www-data /var/www/html/wp-content/mu-plugins/
# Check if setup is already complete
if [ -f "${MARKER_COMPLETE}" ]; then
echo "WebsiteBox: Setup already complete. Starting normally."
wait $WP_PID
exit $?
fi
echo "WebsiteBox: Running first-boot setup..."
# If partial marker exists, we're retrying
if [ -f "${MARKER_PARTIAL}" ]; then
echo "WebsiteBox: Previous setup was partial. Re-running setup idempotently..."
fi
# Wait for MariaDB to be ready
echo "WebsiteBox: Waiting for database..."
DB_READY=false
for i in $(seq 1 30); do
if su -s /bin/sh -c 'wp db check --path=/var/www/html 2>/dev/null' www-data; then
DB_READY=true
echo "WebsiteBox: Database is ready."
break
fi
echo "WebsiteBox: Database not ready, attempt ${i}/30..."
sleep 2
done
if [ "${DB_READY}" != "true" ]; then
echo "ERROR: WebsiteBox could not connect to database after 30 attempts."
echo "Check that the db container is running: docker compose ps db"
exit 1
fi
# Track if any step fails
SETUP_FAILED=false
# Install WordPress core if not already installed
# Using env vars directly (inherited by su without -l) avoids shell quoting issues
echo "WebsiteBox: Installing WordPress core..."
if ! su -s /bin/sh -c 'wp core is-installed --path=/var/www/html 2>/dev/null' www-data; then
# shellcheck disable=SC2016
# Single quotes intentional: inner shell (via su) expands env vars
if ! su -s /bin/sh www-data -c 'wp core install \
--path=/var/www/html \
--url="https://${DOMAIN}" \
--title="${SITE_TITLE:-My Portfolio}" \
--admin_user="${ADMIN_USER}" \
--admin_password="${ADMIN_PASSWORD}" \
--admin_email="${ADMIN_EMAIL}" \
--skip-email'; then
echo "ERROR: WordPress core install failed."
SETUP_FAILED=true
fi
else
echo "WebsiteBox: WordPress core already installed."
fi
# Install and activate GeneratePress theme
if [ "${SETUP_FAILED}" != "true" ]; then
echo "WebsiteBox: Installing GeneratePress theme..."
su -s /bin/sh -c 'wp theme install generatepress --path=/var/www/html 2>/dev/null' www-data || true
fi
# Copy and activate child theme
echo "WebsiteBox: Installing WebsiteBox child theme..."
cp -r /usr/src/websitebox-theme/ /var/www/html/wp-content/themes/websitebox/
chown -R www-data:www-data /var/www/html/wp-content/themes/websitebox/
if [ "${SETUP_FAILED}" != "true" ]; then
su -s /bin/sh -c 'wp theme activate websitebox --path=/var/www/html' www-data || {
echo "WARNING: Could not activate child theme. Activating GeneratePress instead."
su -s /bin/sh -c 'wp theme activate generatepress --path=/var/www/html' www-data || true
}
fi
# Install and activate plugins
if [ "${SETUP_FAILED}" != "true" ]; then
echo "WebsiteBox: Installing plugins..."
for plugin in age-gate wordfence updraftplus; do
echo "WebsiteBox: Installing ${plugin}..."
if ! su -s /bin/sh -c "wp plugin install ${plugin} --activate --path=/var/www/html 2>/dev/null" www-data; then
echo "WARNING: Failed to install ${plugin}. Will continue with remaining setup."
SETUP_FAILED=true
fi
done
fi
# Configure WordPress settings
if [ "${SETUP_FAILED}" != "true" ]; then
echo "WebsiteBox: Configuring WordPress settings..."
# Set permalink structure
su -s /bin/sh -c "wp rewrite structure '/%postname%/' --path=/var/www/html" www-data || true
# Delete default content
su -s /bin/sh -c 'wp post delete 1 --force --path=/var/www/html 2>/dev/null' www-data || true
su -s /bin/sh -c 'wp post delete 2 --force --path=/var/www/html 2>/dev/null' www-data || true
# Set timezone
su -s /bin/sh -c "wp option update timezone_string 'UTC' --path=/var/www/html" www-data || true
# Discourage search engines until user is ready
su -s /bin/sh -c "wp option update blog_public 0 --path=/var/www/html" www-data || true
fi
# Configure Age Gate
if [ "${SETUP_FAILED}" != "true" ] && [ "${AGE_GATE_ENABLED}" = "true" ]; then
echo "WebsiteBox: Configuring Age Gate..."
# shellcheck disable=SC2016
su -s /bin/sh -c 'wp option update age_gate_min_age "${AGE_GATE_MIN_AGE:-18}" --path=/var/www/html' www-data || true
su -s /bin/sh -c 'wp option update age_gate_restrict_all "1" --path=/var/www/html' www-data || true
fi
# Disable Age Gate if not enabled
if [ "${AGE_GATE_ENABLED}" != "true" ]; then
echo "WebsiteBox: Age Gate disabled. Deactivating plugin..."
su -s /bin/sh -c 'wp plugin deactivate age-gate --path=/var/www/html 2>/dev/null' www-data || true
fi
# Configure UpdraftPlus backup path
echo "WebsiteBox: Configuring UpdraftPlus backup path..."
su -s /bin/sh -c "wp option update updraft_dir '/var/backups/websitebox' --path=/var/www/html" www-data || true
# Set backup retention
RETENTION="${BACKUP_RETENTION_DAYS:-30}"
DB_RETAIN=$((RETENTION > 30 ? 30 : RETENTION))
FILE_RETAIN=$((RETENTION / 7))
[ "${FILE_RETAIN}" -lt 1 ] && FILE_RETAIN=1
su -s /bin/sh -c "wp option update updraft_retain '${FILE_RETAIN}' --path=/var/www/html" www-data || true
su -s /bin/sh -c "wp option update updraft_retain_db '${DB_RETAIN}' --path=/var/www/html" www-data || true
# Create marker file
if [ "${SETUP_FAILED}" = "true" ]; then
touch "${MARKER_PARTIAL}"
chown www-data:www-data "${MARKER_PARTIAL}"
echo "WARNING: WebsiteBox setup completed with errors."
echo "Some plugins/themes may not have installed correctly."
echo "To retry: docker compose restart wordpress"
else
rm -f "${MARKER_PARTIAL}"
touch "${MARKER_COMPLETE}"
chown www-data:www-data "${MARKER_COMPLETE}"
echo "WebsiteBox: First-run setup complete!"
fi
# Wait for the background PHP-FPM process
wait $WP_PID

5
wordpress/uploads.ini Normal file
View File

@@ -0,0 +1,5 @@
file_uploads = On
upload_max_filesize = 64M
post_max_size = 64M
max_execution_time = 300
memory_limit = 256M

View File

@@ -0,0 +1,26 @@
<?php
/**
* WebsiteBox WordPress Configuration (Extra Settings)
*
* This file is included by the auto-generated wp-config.php from the official
* WordPress Docker image. It must NOT redefine DB constants, $table_prefix,
* auth keys/salts, or call wp-settings.php — those are all handled by the
* parent wp-config.php.
*/
// Security hardening
define('DISALLOW_FILE_EDIT', true);
define('FORCE_SSL_ADMIN', true);
define('WP_POST_REVISIONS', 10);
define('WP_AUTO_UPDATE_CORE', 'minor');
// Debug settings (disabled in production)
define('WP_DEBUG', false);
define('WP_DEBUG_LOG', false);
define('WP_DEBUG_DISPLAY', false);
// WordPress URLs
if (getenv('DOMAIN')) {
define('WP_HOME', 'https://' . getenv('DOMAIN'));
define('WP_SITEURL', 'https://' . getenv('DOMAIN'));
}

View File

@@ -0,0 +1,90 @@
<?php
/**
* Plugin Name: WebsiteBox Setup Status
* Description: Displays setup status notices and dashboard widget. Disables XML-RPC.
* Version: 1.0.0
* Author: WebsiteBox
*/
if (!defined('ABSPATH')) {
exit;
}
// Disable XML-RPC
add_filter('xmlrpc_enabled', '__return_false');
// Admin notices based on setup status
add_action('admin_notices', function () {
$complete = ABSPATH . '.websitebox-setup-complete';
$partial = ABSPATH . '.websitebox-setup-partial';
if (file_exists($complete)) {
return; // Normal operation
}
if (file_exists($partial)) {
echo '<div class="notice notice-warning"><p>';
echo '<strong>WebsiteBox:</strong> Initial setup completed with errors. ';
echo 'Some plugins or themes may not have installed correctly. ';
echo 'To retry, run: <code>docker compose restart wordpress</code>';
echo '</p></div>';
return;
}
echo '<div class="notice notice-info"><p>';
echo '<strong>WebsiteBox:</strong> Initial setup is still in progress. ';
echo 'The container may still be installing themes and plugins. Please wait a moment and refresh this page.';
echo '</p></div>';
});
// Dashboard widget
add_action('wp_dashboard_setup', function () {
wp_add_dashboard_widget(
'websitebox_status',
'WebsiteBox Status',
'websitebox_dashboard_widget'
);
});
function websitebox_dashboard_widget() {
$complete = file_exists(ABSPATH . '.websitebox-setup-complete');
$partial = file_exists(ABSPATH . '.websitebox-setup-partial');
echo '<table class="widefat" style="border:0">';
// Setup status
echo '<tr><td><strong>Setup Status</strong></td><td>';
if ($complete) {
echo '<span style="color:green">&#10003; Complete</span>';
} elseif ($partial) {
echo '<span style="color:orange">&#9888; Partial — retry with: <code>docker compose restart wordpress</code></span>';
} else {
echo '<span style="color:blue">&#8987; In progress...</span>';
}
echo '</td></tr>';
// Age Gate status
if (is_plugin_active('age-gate/age-gate.php')) {
$min_age = get_option('age_gate_min_age', '18');
$restrict_all = get_option('age_gate_restrict_all', '0');
echo '<tr><td><strong>Age Gate</strong></td><td>';
echo $restrict_all ? "Enabled (minimum age: {$min_age})" : 'Installed but not restricting all content';
echo '</td></tr>';
} else {
echo '<tr><td><strong>Age Gate</strong></td><td>Not active</td></tr>';
}
// Backup status
if (is_plugin_active('updraftplus/updraftplus.php')) {
$last_backup = get_option('updraft_last_backup', []);
echo '<tr><td><strong>Backups</strong></td><td>';
if (!empty($last_backup['backup_time'])) {
echo 'Last backup: ' . date('Y-m-d H:i', $last_backup['backup_time']);
} else {
echo 'No backups yet';
}
echo '</td></tr>';
}
echo '</table>';
}

View File

@@ -0,0 +1,37 @@
<?php
/**
* WebsiteBox Child Theme Functions
*/
if (!defined('ABSPATH')) {
exit;
}
// Enqueue parent theme styles
add_action('wp_enqueue_scripts', function () {
wp_enqueue_style(
'generatepress-parent',
get_template_directory_uri() . '/style.css',
[],
wp_get_theme('generatepress')->get('Version')
);
wp_enqueue_style(
'websitebox-child',
get_stylesheet_uri(),
['generatepress-parent'],
wp_get_theme()->get('Version')
);
});
// Remove GeneratePress footer branding
add_filter('generate_copyright', function () {
return '';
});
// Set portfolio-friendly defaults
add_action('after_setup_theme', function () {
add_theme_support('post-thumbnails');
set_post_thumbnail_size(1200, 800, true);
add_image_size('portfolio-thumb', 600, 400, true);
add_image_size('portfolio-full', 1920, 1080, false);
});

View File

@@ -0,0 +1,13 @@
<?php
// Silence is golden. GeneratePress handles all template rendering.
// This file exists only because WordPress requires index.php in every theme.
get_header();
if (have_posts()) :
while (have_posts()) : the_post();
get_template_part('content', get_post_type());
endwhile;
the_posts_navigation();
else :
get_template_part('content', 'none');
endif;
get_footer();

View File

@@ -0,0 +1,12 @@
/*
Theme Name: WebsiteBox
Theme URI: https://github.com/websitebox/websitebox
Description: A clean portfolio child theme for WebsiteBox, built on GeneratePress.
Author: WebsiteBox
Author URI: https://github.com/websitebox/websitebox
Template: generatepress
Version: 1.0.0
License: GNU General Public License v3 or later
License URI: https://www.gnu.org/licenses/gpl-3.0.html
Text Domain: websitebox
*/

View File

@@ -0,0 +1,66 @@
{
"$schema": "https://schemas.wp.org/trunk/theme.json",
"version": 3,
"settings": {
"color": {
"palette": [
{
"slug": "primary",
"color": "#1a1a2e",
"name": "Primary"
},
{
"slug": "secondary",
"color": "#16213e",
"name": "Secondary"
},
{
"slug": "accent",
"color": "#e94560",
"name": "Accent"
},
{
"slug": "light",
"color": "#f8f9fa",
"name": "Light"
},
{
"slug": "dark",
"color": "#0f0f1a",
"name": "Dark"
}
]
},
"typography": {
"fontSizes": [
{
"slug": "small",
"size": "0.875rem",
"name": "Small"
},
{
"slug": "medium",
"size": "1rem",
"name": "Medium"
},
{
"slug": "large",
"size": "1.5rem",
"name": "Large"
},
{
"slug": "x-large",
"size": "2.25rem",
"name": "Extra Large"
}
]
},
"spacing": {
"units": ["px", "em", "rem", "%", "vh", "vw"]
},
"layout": {
"contentSize": "1200px",
"wideSize": "1400px"
}
}
}