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:
69
.env.example
Normal file
69
.env.example
Normal 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
23
.gitignore
vendored
Normal 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
99
CLAUDE.md
Normal 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
14
LICENSE
Normal 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
184
README.md
Normal 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
104
docker-compose.yml
Normal 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
63
docs/SECURITY.md
Normal 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
187
docs/TROUBLESHOOTING.md
Normal 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
59
docs/UPDATING.md
Normal 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
140
install.sh
Executable 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
20
nginx/Dockerfile
Normal 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
95
nginx/entrypoint.sh
Executable 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
40
nginx/nginx.conf
Normal 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
15
nginx/ssl-params.conf
Normal 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
107
nginx/wordpress-ssl.conf
Normal 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
23
nginx/wordpress.conf
Normal 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
69
scripts/backup.sh
Executable 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
52
scripts/healthcheck.sh
Executable 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
17
scripts/ssl-renew.sh
Executable 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
102
scripts/update.sh
Executable 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
274
setup.sh
Executable 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
770
websitebox-brief (2).md
Normal 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
400
websitebox-diagram (1).jsx
Normal 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://<INSTALL_URL_TBD>/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
41
wordpress/Dockerfile
Normal 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
181
wordpress/entrypoint.sh
Executable 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
5
wordpress/uploads.ini
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
file_uploads = On
|
||||||
|
upload_max_filesize = 64M
|
||||||
|
post_max_size = 64M
|
||||||
|
max_execution_time = 300
|
||||||
|
memory_limit = 256M
|
||||||
26
wordpress/wp-config-docker.php
Normal file
26
wordpress/wp-config-docker.php
Normal 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'));
|
||||||
|
}
|
||||||
90
wordpress/wp-content/mu-plugins/websitebox-setup.php
Normal file
90
wordpress/wp-content/mu-plugins/websitebox-setup.php
Normal 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">✓ Complete</span>';
|
||||||
|
} elseif ($partial) {
|
||||||
|
echo '<span style="color:orange">⚠ Partial — retry with: <code>docker compose restart wordpress</code></span>';
|
||||||
|
} else {
|
||||||
|
echo '<span style="color:blue">⌛ 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>';
|
||||||
|
}
|
||||||
37
wordpress/wp-content/themes/websitebox/functions.php
Normal file
37
wordpress/wp-content/themes/websitebox/functions.php
Normal 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);
|
||||||
|
});
|
||||||
13
wordpress/wp-content/themes/websitebox/index.php
Normal file
13
wordpress/wp-content/themes/websitebox/index.php
Normal 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();
|
||||||
12
wordpress/wp-content/themes/websitebox/style.css
Normal file
12
wordpress/wp-content/themes/websitebox/style.css
Normal 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
|
||||||
|
*/
|
||||||
66
wordpress/wp-content/themes/websitebox/theme.json
Normal file
66
wordpress/wp-content/themes/websitebox/theme.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user