From Zero to AI-Powered Blog: Docker, WP-CLI, and Natural Language Publishing with OpenClaw and Hermes

Most WordPress tutorials start with a one-click install on shared hosting. This one starts with SSH, a blank server, and the assumption that you would rather own your infrastructure than rent convenience.


Why This Stack

There is a failure mode that every self-hoster eventually hits: a perfectly good blog disappears because a billing cycle gets missed, a platform sunsets, or a “simple” upgrade breaks the database. I lost ten years of writing that way. Once.

So when I rebuilt, I made a deliberate set of choices:

  • Docker — the entire WordPress and database stack lives in two containers. Move it to any server in minutes.
  • Nginx on the host — handles TLS termination, serves existing sites, and proxies WordPress cleanly. No port conflicts.
  • Let’s Encrypt — free, auto-renewing SSL. No annual cert anxiety.
  • Cloudflare — CDN, DDoS protection, and orange-cloud proxying with zero additional infrastructure.
  • WP-CLI — command-line management for everything. No clicking through the admin UI to do bulk operations.
  • OpenClaw and Hermes — AI agents that can draft and publish posts through natural language conversation.

The result is a blog I can fully control from an SSH terminal, or from a Telegram message.


The Architecture

Internet
  |
  v
Cloudflare (CDN + DDoS + Orange Cloud)
  |  HTTPS
  v
Nginx on host (port 443, TLS termination)
  |  HTTP proxy
  v
wp_app container (127.0.0.1:8082 -> container:80)
  |  internal Docker network
  v
wp_db container (MariaDB, port 3306, internal only)

The key insight: WordPress is never exposed to the internet directly. Nginx is the only public-facing process. WordPress listens on 127.0.0.1:8082 — unreachable from outside the server regardless of firewall rules.


Part 1 — Deploying WordPress in Docker

The docker-compose.yml

mkdir -p /opt/wordpress
services:
  db:
    image: mariadb:11
    container_name: wp_db
    restart: unless-stopped
    environment:
      MYSQL_ROOT_PASSWORD: your-root-password
      MYSQL_DATABASE: wordpress
      MYSQL_USER: wpuser
      MYSQL_PASSWORD: your-db-password
    volumes:
      - wp_db_data:/var/lib/mysql
    networks:
      - wordpress_net

  wordpress:
    image: wordpress:latest
    container_name: wp_app
    restart: unless-stopped
    ports:
      - "127.0.0.1:8082:80"
    environment:
      WORDPRESS_DB_HOST: db:3306
      WORDPRESS_DB_NAME: wordpress
      WORDPRESS_DB_USER: wpuser
      WORDPRESS_DB_PASSWORD: your-db-password
    volumes:
      - wp_content:/var/www/html/wp-content
      - /opt/wordpress/uploads.ini:/usr/local/etc/php/conf.d/uploads.ini:ro
    depends_on:
      - db
    networks:
      - wordpress_net

volumes:
  wp_db_data:
  wp_content:

networks:
  wordpress_net:
    driver: bridge

Why MariaDB 11 instead of MySQL 8? About 30% lighter on RAM and fully wire-compatible with WordPress.

Why bind WordPress to 127.0.0.1:8082? If you bind to 0.0.0.0:8082, WordPress is directly reachable on port 8082, bypassing nginx and Cloudflare entirely. Binding to localhost means the only path in is through nginx.

Custom PHP Upload Limit

The default WordPress upload limit is 2MB. Override it with a mounted config file:

cat > /opt/wordpress/uploads.ini << EOF
upload_max_filesize = 20M
post_max_size = 20M
EOF

This file is bind-mounted into the container at /usr/local/etc/php/conf.d/uploads.ini. To change the limit later, edit the file and restart wp_app — no rebuild needed.

Start the Stack

cd /opt/wordpress
docker compose up -d

# Verify
docker ps | grep wp_
curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:8082/
# 302 = redirect to install page, correct

Part 2 — Nginx Reverse Proxy and SSL

The Port Conflict Situation

Port 80 and 443 are already occupied by an existing Nginx serving other sites. The solution: add a new server block in a new config file under /etc/nginx/conf.d/. Never touch existing config files.

Create the vHost

mkdir -p /var/www/certbot

cat > /etc/nginx/conf.d/yourdomain.conf << 'EOF'
server {
    listen 80;
    listen [::]:80;
    server_name yourdomain.com www.yourdomain.com;

    location /.well-known/acme-challenge/ {
        root /var/www/certbot;
        try_files $uri =404;
    }

    location / {
        proxy_pass         http://127.0.0.1:8082;
        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 $scheme;
        proxy_http_version 1.1;
        client_max_body_size 64M;
    }
}
EOF

nginx -t && nginx -s reload

DNS Setup in Cloudflare

Before issuing an SSL certificate, DNS must resolve to your server:

  1. Add A record pointing to your server IP — grey cloud (DNS only)
  2. Add CNAME www pointing to apex domain — grey cloud (DNS only)
  3. SSL/TLS mode: Full

Grey cloud is required during cert issuance. Certbot’s HTTP-01 challenge must reach your server directly — Cloudflare’s orange cloud will intercept it and cause verification to fail.

Issue the Certificate

dnf install -y certbot python3-certbot-nginx

certbot --nginx \
  -d yourdomain.com \
  -d www.yourdomain.com \
  --non-interactive \
  --agree-tos \
  -m [email protected] \
  --redirect

Certbot automatically modifies the nginx vHost to add HTTPS, configures the redirect from HTTP, and sets up a systemd timer for auto-renewal.

After the cert is issued: switch Cloudflare to orange cloud, change SSL/TLS mode to Full (strict).


Part 3 — Managing WordPress with WP-CLI

WP-CLI is the command-line interface for WordPress. It gives you complete control — posts, pages, users, plugins, themes, options, database — without opening a browser.

Install WP-CLI in the Container

The official WordPress Docker image does not include WP-CLI. One-time install:

docker exec wp_app bash -c \
  'curl -sO https://raw.githubusercontent.com/wp-cli/builds/gh-pages/phar/wp-cli.phar \
   && chmod +x wp-cli.phar \
   && mv wp-cli.phar /usr/local/bin/wp'

Every WP-CLI command from now on follows this pattern:

docker exec wp_app wp --allow-root --path=/var/www/html <command>

Publishing a Post — The Direct Three-Step Flow

The fastest way to publish from a Markdown file:

# Step 1: Convert Markdown to HTML
python3 -c "
import markdown, sys
with open('post.md', encoding='utf-8') as f:
    body = f.read()
print(markdown.markdown(body, extensions=['tables','fenced_code','nl2br']))
" > /tmp/post.html

# Step 2: Copy the HTML file into the container
docker cp /tmp/post.html wp_app:/tmp/post.html

# Step 3: Publish with WP-CLI
docker exec wp_app wp post create /tmp/post.html \
  --post_title="Your Post Title" \
  --post_status=publish \
  --post_category=3 \
  --allow-root --path=/var/www/html \
  --porcelain
# Returns the new post ID

That is the entire pipeline. No admin UI, no copy-pasting, no formatting by hand.

To publish with a specific historical date — useful when migrating old content and you want to preserve the original timeline:

docker exec wp_app wp post create /tmp/post.html \
  --post_title="Old Post Title" \
  --post_status=publish \
  --post_date="2024-09-30 08:00:00" \
  --post_date_gmt="2024-09-30 08:00:00" \
  --post_category=3 \
  --allow-root --path=/var/www/html \
  --porcelain

WP-CLI Commands for Blog Management

Posts — reading and listing:

# List all published posts with ID, title, and date
docker exec wp_app wp post list \
  --post_status=publish \
  --fields=ID,post_title,post_date \
  --format=table \
  --allow-root --path=/var/www/html

# Get a specific post's full content
docker exec wp_app wp post get 42 \
  --fields=ID,post_title,post_status,post_date,post_content \
  --allow-root --path=/var/www/html

# Count posts by status
docker exec wp_app wp post list --post_status=publish --format=count --allow-root --path=/var/www/html
docker exec wp_app wp post list --post_status=draft --format=count --allow-root --path=/var/www/html

Posts — editing:

# Update a post's title
docker exec wp_app wp post update 42 \
  --post_title="New Title" \
  --allow-root --path=/var/www/html

# Update post content from a file
docker cp /tmp/updated.html wp_app:/tmp/updated.html
docker exec wp_app wp post update 42 \
  --post_content="$(docker exec wp_app cat /tmp/updated.html)" \
  --allow-root --path=/var/www/html

# Change a draft to published
docker exec wp_app wp post update 42 \
  --post_status=publish \
  --allow-root --path=/var/www/html

# Move a post back to draft
docker exec wp_app wp post update 42 \
  --post_status=draft \
  --allow-root --path=/var/www/html

Posts — deleting:

# Move to trash
docker exec wp_app wp post delete 42 \
  --allow-root --path=/var/www/html

# Delete permanently (skip trash)
docker exec wp_app wp post delete 42 \
  --force \
  --allow-root --path=/var/www/html

# Delete all posts in a category (get IDs first, then delete)
docker exec wp_app wp post list \
  --post_status=publish \
  --category=5 \
  --format=ids \
  --allow-root --path=/var/www/html \
  | xargs -I{} docker exec wp_app wp post delete {} --force --allow-root --path=/var/www/html

Categories and tags:

# List all categories with post counts
docker exec wp_app wp term list category \
  --fields=term_id,name,slug,count \
  --format=table \
  --allow-root --path=/var/www/html

# Create a new category
docker exec wp_app wp term create category "Travel" \
  --allow-root --path=/var/www/html

# Rename an existing category
docker exec wp_app wp term update category 8 \
  --name="My Thoughts" \
  --slug="my-thoughts" \
  --allow-root --path=/var/www/html

# Assign a category to a post
docker exec wp_app wp post term set 42 category "My Works" \
  --allow-root --path=/var/www/html

# List all tags
docker exec wp_app wp term list post_tag \
  --fields=term_id,name,count \
  --format=table \
  --allow-root --path=/var/www/html

Users:

# List all users
docker exec wp_app wp user list \
  --fields=ID,user_login,display_name,roles \
  --format=table \
  --allow-root --path=/var/www/html

# Change a user's password
docker exec wp_app wp user update 1 \
  --user_pass="new-secure-password" \
  --allow-root --path=/var/www/html

# Create a new author account
docker exec wp_app wp user create author [email protected] \
  --role=author \
  --allow-root --path=/var/www/html

Plugins and themes:

# List installed plugins and their status
docker exec wp_app wp plugin list --format=table --allow-root --path=/var/www/html

# Install and activate a plugin
docker exec wp_app wp plugin install wp-super-cache --activate \
  --allow-root --path=/var/www/html

# Deactivate a plugin
docker exec wp_app wp plugin deactivate wp-super-cache \
  --allow-root --path=/var/www/html

# Update all plugins at once
docker exec wp_app wp plugin update --all \
  --allow-root --path=/var/www/html

# List available themes
docker exec wp_app wp theme list --format=table --allow-root --path=/var/www/html

Site settings:

# Check the site URL and home URL (useful after domain changes)
docker exec wp_app wp option get siteurl --allow-root --path=/var/www/html
docker exec wp_app wp option get home --allow-root --path=/var/www/html

# Update site title and tagline
docker exec wp_app wp option update blogname "My Blog" \
  --allow-root --path=/var/www/html
docker exec wp_app wp option update blogdescription "Thoughts on technology and life" \
  --allow-root --path=/var/www/html

# Set reading settings — show latest posts or a static page
docker exec wp_app wp option update show_on_front "posts" \
  --allow-root --path=/var/www/html

Database and maintenance:

# Export the database (run on the host server)
docker exec wp_db mysqldump -u wpuser -pyour-db-password wordpress > /opt/wordpress/backup.sql

# Search and replace URLs in the database (useful after domain migration)
docker exec wp_app wp search-replace \
  'http://old-domain.com' 'https://new-domain.com' \
  --allow-root --path=/var/www/html

# Run database optimization
docker exec wp_app wp db optimize --allow-root --path=/var/www/html

# Check WordPress installation for errors
docker exec wp_app wp doctor check --all --allow-root --path=/var/www/html

Cache:

# Flush the object cache (Memcached, Redis, etc.)
docker exec wp_app wp cache flush --allow-root --path=/var/www/html

# If using WP Super Cache
docker exec wp_app wp super-cache flush --allow-root --path=/var/www/html

Part 4 — AI Agents: Natural Language Publishing with OpenClaw and Hermes

This is where the stack stops being infrastructure and starts being a workflow.

Both OpenClaw and Hermes Agent run on the same server as WordPress. They have shell access. Anything you can do with WP-CLI, they can do on your behalf — triggered by a chat message.

OpenClaw: Conversational Publishing

OpenClaw is a self-hosted AI gateway that connects to messaging channels (Telegram, WeChat, Feishu) and extends its capabilities through Skills — Markdown files that define what the agent can do and how.

A WordPress publishing skill looks like this:

---
name: wordpress
description: Create, update, list, and delete WordPress posts. Turn ideas into published posts.
---

# WordPress Publishing Skill

## Publish a new post

1. Write content to /tmp/wp_draft.html
2. Copy into container: docker cp /tmp/wp_draft.html wp_app:/tmp/wp_draft.html
3. Publish:
   docker exec wp_app wp post create /tmp/wp_draft.html \
     --post_title="<title>" \
     --post_status=publish \
     --post_category=<cat_id> \
     --allow-root --path=/var/www/html --porcelain

## List recent posts
docker exec wp_app wp post list \
  --post_status=publish \
  --fields=ID,post_title,post_date \
  --format=table \
  --allow-root --path=/var/www/html

## Update a post
docker exec wp_app wp post update <ID> \
  --post_title="<new title>" \
  --allow-root --path=/var/www/html

With this skill active, a Telegram conversation becomes a publishing interface:

You: Write a 500-word post about why self-hosted infrastructure beats SaaS for long-term reliability. Publish it under “My Works”.

OpenClaw: Drafting now…
(30 seconds later)
Published. Post ID 97. Live at https://yourdomain.com/?p=97

You: List my last 5 posts.
OpenClaw: Here are your most recent posts:
ID 97 | Why Self-Hosted Beats SaaS | 2026-04-26
ID 96 | From Zero to AI-Powered Blog | 2026-04-26
…

You: Move post 95 back to draft.
OpenClaw: Done. Post 95 is now a draft.

You never open a browser.

Hermes Agent: Research-to-Publication Pipeline

Hermes is a persistent agent framework for longer, multi-step workflows. Where OpenClaw handles quick conversational requests, Hermes handles research-intensive tasks that take 5–15 minutes.

A typical Hermes-powered workflow:

Input:
"Research LFP vs NMC battery performance in high-temperature climates.
 Write a 1200-word post comparing real-world data. Audience: EV buyers.
 Publish to WordPress when done."

Hermes pipeline:
  Step 1: Web search — battery chemistry, thermal performance data
  Step 2: Synthesize into outline
  Step 3: Write full draft in Markdown
  Step 4: Convert to HTML
  Step 5: docker cp + wp post create
  Step 6: Return post URL

Because Hermes runs as a persistent server-side process, it handles multi-step research without timeout concerns and can chain actions across tools.

What Changes When AI Agents Enter the Loop

Stage Manual With AI Agent
Idea capture Open laptop, navigate to wp-admin Send a Telegram message
Content creation Write in editor Agent generates
HTML formatting Manual Auto-converted
Metadata Click through UI Agent infers from content
Publish Click button Automatic
Total time 20–45 min 2–5 min

The time savings matter less than the friction reduction. At 2am, you are not opening a laptop and logging into WordPress. You are sending a message. That message either becomes a post, or the idea disappears. The agent is the difference.


Things I Would Do Differently

Set up WP-CLI from day one. It should be part of the initial deployment script. Installing it retroactively means the container diverges from its base image, which complicates future upgrades.

Automate database backups immediately. restart: unless-stopped is uptime management, not disaster recovery. A nightly mysqldump to remote storage should be in place before publishing anything worth keeping:

# Add to crontab on the host server
0 3 * * * docker exec wp_db mysqldump -u wpuser -pyour-db-password wordpress | gzip > /opt/wordpress/backups/$(date +\%Y\%m\%d).sql.gz

Monitor disk usage. Docker volumes grow silently. Set a weekly check:

# Add to crontab
0 9 * * 1 df -h / | awk 'NR==2 {print $5, $4}' | mail -s "Server disk usage" [email protected]

Add a WordPress Application Password. WP-CLI via docker exec works when the agent runs on the same server. Application Passwords (Settings → Users → Application Passwords) allow agents on external machines to publish via the REST API without shell access — more flexible for distributed setups.


Closing

The stack described here is not remarkable in any individual part. Docker, nginx, Let’s Encrypt, WordPress, WP-CLI — all standard tools with years of documentation. What is less common is wiring them together with AI agents that speak human language.

The infrastructure runs unattended. The agents handle the operational layer. The only thing left requiring deliberate human input is deciding what to say.

That is the point.


Built on a self-hosted VPS. Docker 28.4.0, MariaDB 11, WordPress latest, WP-CLI 2.8.x. SSL by Let’s Encrypt. CDN and DDoS protection by Cloudflare. AI publishing via OpenClaw and Hermes Agent.


了解 ~/jintaoblog/朝夕见闻志⚡️ 的更多信息

订阅后即可通过电子邮件收到最新文章。