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

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

38 KiB

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):

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
wordpress `php-fpm-healthcheck exit 1` (use php-fpm-healthcheck, installed in Dockerfile) 30s
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):

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

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:

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

// 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):
    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)

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:

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

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

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)

#!/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):

#!/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:

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

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)