HanyanOS 部署手记
1. Introduction
A self-hosted blog is the public face of any infrastructure project. For HanyanOS, the blog at blog.chenyun.org serves as both the project’s documentation portal and a real-world proving ground for Dockerized PHP applications behind an FRP tunnel. Deploying WordPress on a NAT’d N100 presents unique challenges: the database must be container-local, the PHP stack must handle FRP-induced latency gracefully, and the entire pipeline from VPS edge to Docker container must be transparent to visitors.
This post documents the complete WordPress + MariaDB deployment on the N100, covering Docker Compose orchestration, Nginx reverse proxy configuration, FRP tunnel integration, static asset optimization, and operational lessons from running a PHP application through a three-hop network path.
2. Architecture Overview
1 | ┌─ Internet ──────────────────────────────────────────────┐ |
Traffic Flow
1 | Visitor → blog.chenyun.org |
The round-trip latency from Singapore to Brisbane (~90ms) is absorbed by WordPress’s page cache and Nginx’s micro-caching for static assets.
3. Docker Compose Deployment
The blog stack lives in a shared Compose file alongside other HanyanOS services, all on the serena-net bridge network:
1 | # /home/michael/infra/docker/docker-compose.yml (excerpt) |
Key Design Decisions
| Decision | Rationale |
|---|---|
| MariaDB over MySQL | Lighter memory footprint on N100 (80MB vs 180MB idle); drop-in replacement; same query interface |
| Bind mount for wp-content | Direct file access for theme/plugin development, backup simplicity, and git integration |
| Health check dependency | Prevents WordPress starting before MariaDB is ready to accept connections |
| 127.0.0.1 port binding | WordPress is never directly exposed — all traffic goes through Nginx for unified logging and security headers |
| Environment variables | Docker secrets/prevent credential drift; .env file managed by HanyanOS vault |
Database Connection
WordPress connects to MariaDB via Docker’s embedded DNS. The service name serena-db resolves to the container’s IP on serena-net. The wp-config.php auto-generated by the WordPress entrypoint:
1 | define('DB_NAME', 'wordpress'); |
The FORCE_SSL_LOGIN constant is set to true to ensure all admin authentication happens over HTTPS, even though SSL termination occurs at the VPS:
1 | define('FORCE_SSL_LOGIN', true); |
The HTTPS detection is handled by the X-Forwarded-Proto header injected by the N100 Nginx:
1 | if (isset($_SERVER['HTTP_X_FORWARDED_PROTO']) |
4. Nginx Reverse Proxy Configuration
The N100 Nginx acts as the traffic dispatcher, routing by Host header to the appropriate backend. The blog-specific block:
1 | # /etc/nginx/sites-available/chenyun-proxy |
Note that X-Forwarded-Proto is hardcoded to https — this is correct because HTTPS termination happens on the Lightsail VPS before traffic enters the FRP tunnel. Without this, WordPress would generate http:// URLs for all assets and redirect loops would occur on login.
Static Asset Acceleration
A separate location block caches immutable assets for 7 days, reducing FRP tunnel bandwidth usage:
1 | location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { |
5. FRP Tunnel Integration
The blog is accessed through a TCP tunnel that forwards all HTTP traffic from the Lightsail VPS to the N100:
1 | # /etc/frp/frpc.ini (N100 side) |
On the VPS side, Nginx receives HTTPS traffic on blog.chenyun.org, terminates SSL, and proxies to the FRP tunnel endpoint:
1 | # Lightsail VPS Nginx (simplified) |
The complete path: Internet → VPS:443 (SSL) → VPS:7444 (FRP) → N100:80 (Nginx) → N100:8081 (Docker WordPress).
6. Problems Encountered and Resolutions
6.1 X-Forwarded-Proto Redirect Loop
Symptom: After initial deployment, accessing https://blog.chenyun.org/wp-admin/ caused an infinite redirect loop (https→http→https→http).
Root Cause: WordPress’s FORCE_SSL_LOGIN and FORCE_SSL_ADMIN redirect to https://, but without a correct X-Forwarded-Proto header, WordPress thought the request was plain HTTP and kept redirecting.
Resolution: Three fixes applied in combination:
- Added
proxy_set_header X-Forwarded-Proto https;to the N100 Nginx blog location block (hardcoded, since SSL terminates at VPS) - Added the
$_SERVER['HTTPS']='on'override inwp-config.phpbased on the forwarded header - Set
define('FORCE_SSL_LOGIN', true);inwp-config.php
6.2 MariaDB Container Name Collision
Symptom: After a system reboot, the MariaDB container failed to start because a stale MySQL 8.0 container (hanyan-db) had already bound port 3306 on the Docker network.
Root Cause: The HanyanOS agent database (hanyan-db, MySQL 8.0) and the blog database (serena-db, MariaDB 10.11) both expose port 3306 internally. Docker allows multiple containers on the same bridge network as long as port 3306 isn’t published to the host — but on container restart, the health check for serena-db timed out because it couldn’t initialize InnoDB.
Resolution: Ensured both containers are on the same Docker network (serena-net) and rely on Docker’s internal DNS (service name resolution) rather than port conflicts. The hanyan-db exposes 3306 only on host port 3307, leaving no port conflict.
6.3 Plugin Auto-Update Failures Behind FRP
Symptom: WordPress plugin and theme auto-updates consistently failed with cURL error 28: Connection timed out or cURL error 7: Failed to connect.
Root Cause: WordPress’s update mechanism makes outbound HTTP connections to api.wordpress.org, downloads.wordpress.org, and ps.w.org. From the N100 behind NAT, these connections exit through the home ISP and are subject to inconsistent routing and DNS resolution.
Resolution: Two mitigations:
- Increased PHP timeout: Added
define('WP_HTTP_BLOCK_EXTERNAL', false);and ensured the PHPmax_execution_timeis set to 300s in the container - Manual update workflow: All major updates are performed via
docker exec serena-wp wp-cli ...using WP-CLI, which has better error handling. For automated minor updates, added a cron entry:
1 | # /etc/cron.d/wordpress-updates |
6.4 MySQL Memory Pressure on N100
Symptom: After running for 3-4 days, the N100’s 8GB RAM was nearly exhausted, with MariaDB consuming 1.2GB.
Root Cause: MariaDB’s default innodb_buffer_pool_size is set to 80% of available memory on Docker (per the official image’s auto-configuration script). On an 8GB system running multiple containers (Stalwart, Qdrant, MySQL 8.0), this caused OOM kills.
Resolution: Capped InnoDB buffer pool to 256MB via a custom MariaDB configuration mounted into the container:
1 | # /home/michael/data/mysql-data/my.cnf |
Applied by bind-mounting into the container:
1 | services: |
This reduced MariaDB’s idle memory from 1.2GB to ~180MB.
6.5 Health Check Race Condition
Symptom: On docker compose up, WordPress occasionally started before MariaDB’s health check passed, resulting in Error establishing a database connection.
Root Cause: Docker Compose’s depends_on with condition: service_healthy is evaluated once at startup. If MariaDB’s health check takes longer than expected (cold start with InnoDB recovery), Compose proceeds before the DB is truly ready.
Resolution: Added a restart: unless-stopped policy and set MariaDB’s health check start_period to 30 seconds to account for cold-start InnoDB initialization. WordPress will crash-loop until MariaDB passes health — typically 2-3 restarts (about 15 seconds).
7. Security Posture
| Layer | Measure |
|---|---|
| Transport | TLS 1.3 terminated at VPS; FRP tunnel carries plain HTTP over VPC |
| Database isolation | MariaDB not exposed on any host port; only accessible via Docker network DNS |
| Admin access | wp-admin forced HTTPS via FORCE_SSL_LOGIN; Adminer at db.chenyun.org requires HTTP Basic Auth in Nginx |
| File permissions | WordPress files in /home/michael/www/wordpress owned by www-data:www-data inside container; host user michael has group access |
| Backups | Daily wp db export via cron; MySQL data directory included in HanyanOS nightly rsync |
| Rate limiting | Fail2ban watches WordPress login attempts on N100 (/var/log/nginx/blog-access.log) |
| Plugin hygiene | Only 5 plugins installed (Akismet, Jetpack, WP Super Cache, Yoast SEO, UpdraftPlus) — minimized attack surface |
8. Performance Observations
| Metric | Value |
|---|---|
| Page load time (cached, Australia) | ~1.2s (includes 90ms VPS→N100 tunnel latency) |
| Page load time (cached, US East) | ~2.4s (additional trans-Pacific latency) |
| MariaDB idle memory | 180 MB |
| WordPress PHP-FPM idle memory | 85 MB |
| FRP tunnel throughput | ~45 Mbps (N100 gigabit Ethernet) |
| Concurrent connections | 10-15 (typical for personal blog with bot traffic) |
The WP Super Cache plugin generates static HTML files that bypass PHP entirely for unauthenticated visitors, reducing the average request to a single Nginx try_files lookup.
9. Conclusion
Deploying WordPress behind an FRP tunnel on NAT’d hardware is not the simplest path — but it is a resilient one. The three-hop architecture (VPS → FRP → N100 → Docker) adds complexity but provides valuable isolation: WordPress never sees the public internet directly, the database is not exposed, and the entire stack can be torn down and rebuilt from Compose without touching external configuration. For a personal blog serving a few thousand monthly visitors, the performance penalty is negligible, and the operational flexibility is considerable.
Key takeaway: The hardest part of a tunneled WordPress deployment is not the infrastructure — it’s the HTTPS detection and header forwarding. Get X-Forwarded-Proto right, and everything else follows.
Next in series: OpenClaw Multi-Agent Scheduling — Orchestrating 7 Agents on N100.