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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
┌─ Internet ──────────────────────────────────────────────┐
│ blog.chenyun.org → CNAME → Lightsail 52.220.247.252 │
└────────────────────────┬────────────────────────────────┘
│ :443 (HTTPS terminated at VPS)
┌─ Lightsail VPS (SG) ──┴─────────────────────────────────┐
│ Nginx SNI: blog.chenyun.org │
│ SSL: Let's Encrypt (acme.sh + Route53 DNS-01) │
│ proxy_pass → 127.0.0.1:7444 (FRP tunnel endpoint) │
└────────────────────────┬────────────────────────────────┘
│ :7444 (TCP tunnel, plain HTTP)
┌─ FRP Tunnel ───────────┴─────────────────────────────────┐
│ frps(Lightsail:7444) ←→ frpc(N100) │
│ TCP tunnel, tcp_mux enabled, no encryption (local net) │
└────────────────────────┬────────────────────────────────┘
│ :80
┌─ N100 Nginx ───────────┴─────────────────────────────────┐
│ server_name blog.chenyun.org; │
│ proxy_pass http://127.0.0.1:8081; │
│ X-Forwarded-Proto: https (from VPS) │
└────────────────────────┬────────────────────────────────┘
│ :8081
┌─ Docker serena-net ────┴─────────────────────────────────┐
│ │
│ ┌──────────────┐ ┌──────────────┐ │
│ │ serena-wp │ │ serena-db │ │
│ │ WordPress │─────▶│ MariaDB │ │
│ │ 6.7 PHP 8.3 │ │ 10.11 │ │
│ │ :8081→:80 │ │ :3306 │ │
│ └──────────────┘ └──────────────┘ │
│ │ │ │
│ /home/michael/ /home/michael/ │
│ www/wordpress data/mysql-data │
│ (bind mount) (bind mount) │
│ │
│ ┌──────────────┐ │
│ │ serena-adminer│ db.chenyun.org │
│ │ Adminer :8082 │ (database UI) │
│ └──────────────┘ │
└──────────────────────────────────────────────────────────┘

Traffic Flow

1
2
3
4
5
6
7
Visitor → blog.chenyun.org
→ DNS → Lightsail (52.220.247.252)
→ Nginx (SSL termination, SNI routing)
→ FRP tunnel (port 7444)
→ N100 nginx (Host-based routing)
→ Docker serena-wp:8081 (Apache PHP)
→ Docker serena-db:3306 (MariaDB)

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
# /home/michael/infra/docker/docker-compose.yml (excerpt)
services:
serena-wp:
image: wordpress:6.7-php8.3-apache
container_name: serena-wp
restart: unless-stopped
ports:
- "127.0.0.1:8081:80"
environment:
WORDPRESS_DB_HOST: serena-db
WORDPRESS_DB_NAME: wordpress
WORDPRESS_DB_USER: michael
WORDPRESS_DB_PASSWORD: ${WP_DB_PASSWORD}
volumes:
- /home/michael/www/wordpress:/var/www/html
depends_on:
serena-db:
condition: service_healthy

serena-db:
image: mariadb:10.11
container_name: wordpress
restart: unless-stopped
environment:
MARIADB_ROOT_PASSWORD: ${MARIADB_ROOT_PW}
MARIADB_DATABASE: wordpress
MARIADB_USER: michael
MARIADB_PASSWORD: ${WP_DB_PASSWORD}
volumes:
- /home/michael/data/mysql-data:/var/lib/mysql
healthcheck:
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
interval: 10s
retries: 5

serena-adminer:
image: adminer:latest
container_name: serena-adminer
restart: unless-stopped
ports:
- "127.0.0.1:8082:8080"

networks:
default:
name: serena-net

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
2
3
4
define('DB_NAME', 'wordpress');
define('DB_USER', 'michael');
define('DB_PASSWORD', 'Phoenix19820301@');
define('DB_HOST', 'serena-db');

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
2
3
4
if (isset($_SERVER['HTTP_X_FORWARDED_PROTO'])
&& strpos($_SERVER['HTTP_X_FORWARDED_PROTO'],'https') !== false) {
$_SERVER['HTTPS']='on';
}

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
2
3
4
5
6
7
8
9
10
11
12
13
# /etc/nginx/sites-available/chenyun-proxy
server {
listen 80;
server_name blog.chenyun.org;

location / {
proxy_pass http://127.0.0.1:8081;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
}
}

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
2
3
4
5
6
7
8
9
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
proxy_pass http://127.0.0.1:8081;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
expires 7d;
add_header Cache-Control "public, immutable";
}

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
2
3
4
5
6
7
8
9
10
11
12
13
# /etc/frp/frpc.ini (N100 side)
[common]
server_addr = 52.220.247.252
server_port = 7443
token = chenyun-frp-2026
tcp_mux = true
login_fail_exit = false

[nginx]
type = tcp
local_ip = 127.0.0.1
local_port = 80
remote_port = 7444

On the VPS side, Nginx receives HTTPS traffic on blog.chenyun.org, terminates SSL, and proxies to the FRP tunnel endpoint:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# Lightsail VPS Nginx (simplified)
server {
listen 443 ssl http2;
server_name blog.chenyun.org;

ssl_certificate /etc/letsencrypt/live/chenyun.org/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/chenyun.org/privkey.pem;

location / {
proxy_pass http://127.0.0.1:7444;
proxy_set_header Host blog.chenyun.org;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
}
}

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:

  1. Added proxy_set_header X-Forwarded-Proto https; to the N100 Nginx blog location block (hardcoded, since SSL terminates at VPS)
  2. Added the $_SERVER['HTTPS']='on' override in wp-config.php based on the forwarded header
  3. Set define('FORCE_SSL_LOGIN', true); in wp-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:

  1. Increased PHP timeout: Added define('WP_HTTP_BLOCK_EXTERNAL', false); and ensured the PHP max_execution_time is set to 300s in the container
  2. 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
2
3
4
# /etc/cron.d/wordpress-updates
0 3 * * 1 michael docker exec serena-wp wp plugin update --all --quiet
0 3 * * 1 michael docker exec serena-wp wp theme update --all --quiet
0 4 * * 1 michael docker exec serena-wp wp core update --minor --quiet

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
2
3
4
5
6
7
# /home/michael/data/mysql-data/my.cnf
[mysqld]
innodb_buffer_pool_size = 256M
innodb_log_file_size = 64M
max_connections = 50
query_cache_type = 0
performance_schema = OFF

Applied by bind-mounting into the container:

1
2
3
4
5
services:
serena-db:
volumes:
- /home/michael/data/mysql-data:/var/lib/mysql
- /home/michael/data/mysql-data/my.cnf:/etc/mysql/conf.d/my.cnf:ro

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.