WUXIA for WordPress

Deployment guide

Deploy Wuxia so every episode plays.

Follow the guide from top to bottom. Copy each command as it is; change only the values in ALL_CAPS, such as YOUR_DOMAIN. Or let an AI assistant walk you through it.

  • About 30 minutes
  • Ubuntu 24.04
  • nginx · PHP 8.3 · MariaDB
  • Cloudflare

How video reaches a viewer

  1. Viewerpresses play
  2. Cloudflarecaches segments
  3. nginxthe two media rules
  4. WordPresschecks the signed link, names the file
  5. nginxfetches from your storage and streams it

WordPress never streams video itself, and viewers never see your storage address. That takes two nginx rules, in nginx-wuxia-media.conf.

Without the rules

The site works, but every episode answers 501 Media is served through nginx.

1Pick your hosting

Your hostingPagesVideoFollow
A VPS or dedicated server you can ssh into (Ubuntu 24.04)YesYesPath A
A panel that runs nginx and accepts custom nginx config (CloudPanel, RunCloud, SpinupWP, GridPane, Plesk with nginx)YesYesPath B
A server where you already run WordPress on nginxYesYesPath C
A server with Docker, where you want Nginx Proxy Manager to handle domains and HTTPS certificatesYesYesPath D
Shared hosting, cPanel, Apache or LiteSpeedYesNoMove to one of the rows above

Not sure what an existing site runs? Behind Cloudflare every answer says server: cloudflare, so ask your host, or look in its control panel for “nginx” or “Apache/LiteSpeed”.

A server with 2 vCPU, 4 GB of RAM and 40 GB of disk is plenty to start; the disk also holds the video cache (20 GB by default).

2Path A · a new Ubuntu server Recommended

Log in as a user with sudo on a fresh Ubuntu 24.04.

A1Firewall and packages

Terminal
sudo apt update && sudo apt -y upgrade
sudo ufw allow OpenSSH
sudo ufw allow 80,443/tcp
sudo ufw --force enable

sudo apt -y install nginx mariadb-server \
  php8.3-fpm php8.3-mysql php8.3-curl php8.3-xml php8.3-mbstring \
  php8.3-zip php8.3-gd php8.3-intl unzip curl

A2Database

Replace A_STRONG_DB_PASSWORD; you need it again in the WordPress installer.

Terminal
sudo mysql_secure_installation   # answer Y to every question
sudo mysql -e "CREATE DATABASE wuxia CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER 'wuxia'@'localhost' IDENTIFIED BY 'A_STRONG_DB_PASSWORD';
GRANT ALL ON wuxia.* TO 'wuxia'@'localhost'; FLUSH PRIVILEGES;"

A3WordPress files

Terminal
sudo mkdir -p /var/www/wuxia
curl -sL https://wordpress.org/latest.tar.gz | sudo tar -xz -C /var/www/wuxia --strip-components=1
sudo chown -R www-data:www-data /var/www/wuxia

A4The Cloudflare origin certificate

Cloudflare talks to your server over HTTPS with a free certificate that lasts 15 years.

  1. In Cloudflare: SSL/TLS → Origin Server → Create Certificate, keep the defaults, Create.
  2. On the server, paste the two blocks it shows:
Terminal
sudo mkdir -p /etc/nginx/certs
sudo nano /etc/nginx/certs/origin.pem   # paste "Origin Certificate", save
sudo nano /etc/nginx/certs/origin.key   # paste "Private Key", save
sudo chmod 600 /etc/nginx/certs/origin.key

A5Visitor IPs behind Cloudflare

Without this every visitor looks like a Cloudflare address, and the media rate limit would slow everyone down together.

Terminal
{ for ip in $(curl -s https://www.cloudflare.com/ips-v4) $(curl -s https://www.cloudflare.com/ips-v6); do
    echo "set_real_ip_from $ip;"; done
  echo "real_ip_header CF-Connecting-IP;"; } | sudo tee /etc/nginx/conf.d/cloudflare-realip.conf

A6The media rules

Terminal
sudo curl -sL -o /etc/nginx/wuxia-media.conf \
  https://raw.githubusercontent.com/Wuxia-KH/wuxia-press-published-/main/server/nginx-wuxia-media.conf
sudo mkdir -p /var/cache/nginx/wuxia && sudo chown -R www-data /var/cache/nginx/wuxia

Part 1 of that file belongs inside http { } and part 2 inside server { }. Split it into two files:

Terminal
sudo sed -n '/PART 1: http/,/PART 2: server/p' /etc/nginx/wuxia-media.conf | sudo tee /etc/nginx/conf.d/wuxia-media-http.conf >/dev/null
sudo sed -n '/PART 2: server/,$p' /etc/nginx/wuxia-media.conf | sudo tee /etc/nginx/snippets/wuxia-media-server.conf >/dev/null

The file already points at Ubuntu 24.04’s PHP (unix:/run/php/php8.3-fpm.sock) and CA bundle, so nothing needs changing on this path.

A7The site

Change example.com in the sed line near the end to your domain before running it.

Terminal
sudo tee /etc/nginx/sites-available/wuxia >/dev/null <<'EOF'
server {
	listen 80;
	server_name YOUR_DOMAIN www.YOUR_DOMAIN;
	return 301 https://$host$request_uri;
}

server {
	listen 443 ssl;
	http2 on;
	server_name YOUR_DOMAIN www.YOUR_DOMAIN;
	ssl_certificate     /etc/nginx/certs/origin.pem;
	ssl_certificate_key /etc/nginx/certs/origin.key;

	root /var/www/wuxia;
	index index.php;
	client_max_body_size 32m;
	server_tokens off;

	# Video (Wuxia). Its ^~ locations take priority over the rules below.
	include snippets/wuxia-media-server.conf;

	location ~ /\. { deny all; }
	location = /xmlrpc.php { deny all; }
	location ~* ^/wp-content/uploads/.*\.php$ { deny all; }

	location / {
		try_files $uri $uri/ /index.php?$args;
	}

	location ~ \.php$ {
		try_files $uri =404;
		include fastcgi_params;
		fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
		fastcgi_pass unix:/run/php/php8.3-fpm.sock;
	}

	location ~* \.(css|js|webp|avif|jpg|jpeg|png|svg|ttf|woff2)$ {
		expires 30d;
		access_log off;
	}
}
EOF
sudo sed -i 's/YOUR_DOMAIN/example.com/g' /etc/nginx/sites-available/wuxia   # ← your domain here
sudo ln -sf /etc/nginx/sites-available/wuxia /etc/nginx/sites-enabled/wuxia
sudo rm -f /etc/nginx/sites-enabled/default
sudo nginx -t && sudo systemctl reload nginx
Expected

nginx -t prints syntax is ok and test is successful. Anything else: see Troubleshooting.

Now set up Cloudflare, then open https://YOUR_DOMAIN and run the WordPress installer: database wuxia, user wuxia, the password from A2.

3Path B · a hosting panel that runs nginx

Create the WordPress site in the panel as usual, then add the rules as custom nginx configuration:

PanelWhere
CloudPanelSite → Vhost (edit the file directly)
RunCloudWeb Application → NGINX Config → a config of type location.main-before, and one of type http
SpinupWPSite → Custom Nginx Config (or /etc/nginx/sites-available/<site>/server/ over SSH)
GridPane/var/www/<site>/nginx/ includes, over SSH
PleskDomain → Apache & nginx Settings → Additional nginx directives, with “Proxy mode” off
  1. Part 1 at http level

    From nginx-wuxia-media.conf, put part 1 where the panel accepts http-level config. If it has none, ask your host to add those two lines (limit_req_zone … and proxy_cache_path …); both are harmless to other sites.

  2. Part 2 in the site

    Put part 2 in the site’s server block.

  3. Point it at PHP

    Find fastcgi_pass in the site’s vhost and copy that value exactly into part 2.

  4. Cache folder

    mkdir -p /var/cache/nginx/wuxia, owned by nginx’s user (or ask your host).

  5. Save

    The panel reloads nginx. If it reports an error, the message names the line.

4Path C · a server that already runs nginx

  1. Download

    nginx-wuxia-media.conf.

  2. Part 1

    Inside http { }, usually as a new file in /etc/nginx/conf.d/.

  3. Part 2

    Inside the site’s server { }, next to its other location blocks.

  4. Lines marked CHANGE

    fastcgi_pass: the same value as your location ~ \.php$ block. On RHEL, Alma or Rocky the CA bundle is /etc/pki/tls/certs/ca-bundle.crt.

  5. Cache folder

    sudo mkdir -p /var/cache/nginx/wuxia && sudo chown -R www-data /var/cache/nginx/wuxia (the user is nginx on RHEL-family systems).

  6. Behind Cloudflare?

    Add the visitor-IP file from step A5.

  7. Reload

    sudo nginx -t && sudo systemctl reload nginx

5Path D · Docker with Nginx Proxy Manager

Nginx Proxy Manager gives you a web panel for domains and free HTTPS certificates. It only forwards traffic, so the Wuxia media rules live in a small nginx container behind it. Everything runs in Docker from one folder.

  1. Cloudflareoptional
  2. Nginx Proxy Managerdomains and HTTPS
  3. nginxthe two media rules
  4. WordPressPHP-FPM
  5. MariaDByour catalogue
Tested end to end

An episode’s playlist and video segments played through Nginx Proxy Manager, the second time from nginx’s cache.

D1Docker and the firewall

On a fresh Ubuntu 24.04 (Debian works too):

Terminal
curl -fsSL https://get.docker.com | sudo sh
sudo ufw allow OpenSSH
sudo ufw allow 80,443/tcp
sudo ufw --force enable

D2The files

Terminal
mkdir -p ~/wuxia/nginx && cd ~/wuxia
RAW=https://raw.githubusercontent.com/Wuxia-KH/wuxia-press-published-/main/server
curl -sLo docker-compose.yml "$RAW/nginx-proxy-manager/docker-compose.yml"
curl -sLo nginx/site.conf "$RAW/nginx-proxy-manager/nginx/site.conf"
curl -sLo nginx/nginx-wuxia-media.conf "$RAW/nginx-wuxia-media.conf"
sed -n '/PART 1: http/,/PART 2: server/p' nginx/nginx-wuxia-media.conf > nginx/wuxia-media-http.conf
sed -n '/PART 2: server/,$p' nginx/nginx-wuxia-media.conf | sed 's#unix:/run/php/php8.3-fpm.sock#wordpress:9000#' > nginx/wuxia-media-server.conf
for ip in $(curl -s https://www.cloudflare.com/ips-v4) $(curl -s https://www.cloudflare.com/ips-v6); do
  echo "set_real_ip_from $ip;"; done > nginx/cloudflare.conf
ls nginx

ls lists cloudflare.conf, nginx-wuxia-media.conf, site.conf, wuxia-media-http.conf and wuxia-media-server.conf. The Cloudflare file lets nginx see visitors’ real addresses behind Cloudflare; without Cloudflare it does nothing. The files: docker-compose.yml, site.conf.

D3Passwords, then start

This puts random database passwords into docker-compose.yml; you never need to type them.

Terminal
sed -i "s/A_STRONG_DB_PASSWORD/$(openssl rand -hex 16)/g; s/A_STRONG_ROOT_PASSWORD/$(openssl rand -hex 16)/" docker-compose.yml
sudo docker compose up -d
sudo docker compose ps
Expected

All four services (npm, db, wordpress, web) show Up.

D4Open the Nginx Proxy Manager panel

The panel (port 81) listens only on the server itself, so it is never exposed to the internet. From your own computer, open a tunnel:

Your computer
ssh -L 8181:127.0.0.1:81 YOUR_USER@YOUR_SERVER_IP

Keep that window open and browse to http://localhost:8181. On the first visit, create the admin account (name, email, password). Older versions show a login instead: admin@example.com / changeme, then ask you to change both.

D5The proxy host

Point YOUR_DOMAIN and www at the server’s IP first (see Cloudflare). Then Hosts → Proxy Hosts → Add Proxy Host, Details tab:

FieldValue
Domain NamesYOUR_DOMAIN and www.YOUR_DOMAIN
Schemehttp
Forward Hostname / IPweb
Forward Port80
Cache Assetsoff
Block Common Exploitsoff (not needed: WordPress and nginx check every media link themselves)
Websockets Supportoff

SSL tab: Request a new SSL Certificate, turn on Force SSL and HTTP/2 Support, agree to the terms, Save.

  • With Cloudflare’s orange cloud on, tick Use a DNS Challenge, choose Cloudflare, and paste an API token that can edit the zone’s DNS (Cloudflare → My Profile → API Tokens → Edit zone DNS template).
  • Or use a Cloudflare Origin Certificate: SSL Certificates → Add SSL Certificate → Custom, upload the certificate and key from Cloudflare’s SSL/TLS → Origin Server, then pick it on the proxy host’s SSL tab.

D6WordPress

Open https://YOUR_DOMAIN: the installer asks only for a site title and your admin account (the database is already connected). Then continue with Install the theme and plugin.

On this path, wp-config.php settings go in docker-compose.yml, under the wordpress service’s WORDPRESS_CONFIG_EXTRA, one define per line after the one already there; then sudo docker compose up -d.

6Deploy with an AI assistant

AI assistants such as ChatGPT, Claude, Gemini and Copilot can read this guide and walk you through it, one step at a time. It is published for them as plain text at llms-full.txt, with an index at llms.txt.

Copy this into the assistant and fill in the brackets:

Prompt
Help me deploy "Wuxia for WordPress" so that its pages load and its episodes play.
First read the deployment guide and follow it exactly:
https://wuxia-kh.github.io/wuxia-press-published-/llms-full.txt

My setup:
- Hosting: [a new Ubuntu 24.04 VPS | a hosting panel called ... | a server already running nginx | Docker with Nginx Proxy Manager]
- Domain: [example.com]
- Cloudflare in front: [yes | no]

Give me one step at a time with the exact command, and tell me what I should see
before I go on. If something fails, use the guide's troubleshooting table.
Never ask me to paste passwords, API keys or tokens into this chat.
  • Assistants that can run commands on your server (Claude Code, Codex CLI, Gemini CLI and similar) can follow the guide directly. Read each command before you approve it.
  • Keep secrets out of the chat. The guide generates passwords on the server; an assistant never needs to see them.
  • The guide is the reference. If an assistant’s advice disagrees with it, trust the guide, especially the nginx rules.

7Cloudflare

  1. DNS

    A records for @ and www pointing at the server, Proxied (orange cloud).

  2. SSL

    SSL/TLS → Overview → Full (strict). Never Flexible: it loops wp-admin redirects.

  3. Cache video

    Caching → Cache Rules → Create rule “Wuxia media”: URI Path starts with /media/ → Eligible for cache, Edge TTL Use cache-control header if present. Segments then come from Cloudflare instead of your server.

  4. Never cache admin

    Another cache rule: bypass cache for /wp-admin, /wp-login.php and /wp-json/.

  5. Leave these alone

    Never cache episode pages (/episode/…) and never put /media/ behind a Worker: the first sets each viewer’s session, the second skips WordPress’s checks.

  6. Optional

    Security → WAF → Rate limiting rules: /media/ at about 600 requests per minute per IP.

8Install the theme and plugin

  1. Plugin

    Plugins → Add New Plugin → Upload Plugin → wuxia-core.zip → Activate.

  2. Theme

    Appearance → Themes → Add New Theme → Upload Theme → wuxia-theme.zip → Activate.

  3. Readable links

    Settings → Permalinks → Post name → Save.

  4. A media key, before adding storage

    Recommended. Add to wp-config.php, above /* That's all, stop editing! */, a value from openssl rand -hex 32:

wp-config.php
define( 'WUXIA_MEDIA_KEY', 'PASTE_64_RANDOM_CHARACTERS' );
Back it up with the database

If the key is lost or changed, storage credentials must be entered again. Without it, the site’s WordPress salts are used.

Optional: password-reset and notification mail through an SMTP relay (Brevo, Mailgun, …), also in wp-config.php:

wp-config.php
define( 'WUXIA_SMTP_HOST', 'smtp-relay.brevo.com' );
define( 'WUXIA_SMTP_PORT', '587' );
define( 'WUXIA_SMTP_USER', 'YOUR_SMTP_LOGIN' );
define( 'WUXIA_SMTP_PASS', 'YOUR_SMTP_KEY' );
define( 'WUXIA_MAIL_FROM', 'no-reply@YOUR_DOMAIN' );

Then: Drama Manager → Storage Providers to connect storage, Drama Manager → Import for iVault JSON (or Add drama), and Drama Manager → Settings for Khmer or English.

9Check that video plays

  1. Play

    Open any episode and press play.

  2. Network tab

    Open the browser’s developer tools → Network, filter media.

  3. 200

    master.m3u8 and the segments (.ts, .m4s, .single, .married) answer 200.

  4. Cache

    A segment’s response headers show x-wuxia-origin-cache: MISS the first time and HIT after.

Or, from a terminal (a deliberately bad link):

Terminal
curl -s -o /dev/null -w "%{http_code}\n" https://YOUR_DOMAIN/media/1/0.AAAAAAAAAAAAAAAAAAAAAA/master.m3u8
403 or 404

The rules are working.

501

The rules are missing. See the first row below.

10Troubleshooting

You seeMeaningFix
501 “Media is served through nginx”The request reached WordPress without the media rules.Add part 2 to the site’s server { } and reload nginx. On Apache or LiteSpeed hosting this cannot be fixed; move to nginx.
nginx -t: “proxy_cache” zone “wuxia_media” is unknownPart 1 is missing.Add part 1 inside http { }.
nginx -t: “limit_req_zone” directive is not allowed herePart 1 was put inside server { }.Move it to http { }.
Episodes fail; the error log says Permission denied under /var/cache/nginx/wuxianginx’s workers can’t write the cache folder.sudo chown -R www-data /var/cache/nginx/wuxia (nginx on RHEL-family systems), then reload nginx.
502 on every media request; the error log says connect() to unix:… failedfastcgi_pass in part 2 doesn’t match your PHP.Copy the value from your location ~ \.php$ block. ls /run/php/ lists the sockets.
502 “Storage did not answer”nginx couldn’t reach your storage.Check the provider in Storage Providers. The error log may say could not be resolved (the resolver line) or certificate verify failed (the CA-bundle line).
503 “Storage is busy”Telegram asked to slow down.It retries by itself.
403 “This link has expired”Media links last six hours.Reload the page.
404That episode or file doesn’t exist.Check the episode’s link and provider.
429Too many requests from one IP.Behind Cloudflare, do step A5 so viewers aren’t counted as one.
wp-admin loops “too many redirects”Cloudflare SSL is Flexible.Set Full (strict) with the origin certificate.
Episode pages or /shorts/ answer 404Permalinks are “Plain”.Settings → Permalinks → Post name → Save.
Path D: Nginx Proxy Manager shows 502 Bad GatewayIt can’t reach the web container.sudo docker compose ps: all four services must be up. The proxy host forwards to web, port 80, scheme http.
Path D: the certificate request failsLet’s Encrypt couldn’t check the domain.The domain must already point at the server. Behind Cloudflare’s orange cloud, use Use a DNS Challenge with a Cloudflare API token.
Path D: 501 although everything runssite.conf or the split media files are missing.In ~/wuxia, ls nginx must list the five files from step D2; then sudo docker compose restart web.

nginx’s error log is /var/log/nginx/error.log; media requests are also in the access log. On Path D: sudo docker compose logs web (and logs npm for the proxy).

11Updates and backups

  • Update: download the new zips and upload them the same way; WordPress asks to replace the installed version. Every release lists SHA256SUMS. Check each release’s notes for a new nginx-wuxia-media.conf.
  • Replaced a video at the same address? Empty nginx’s cache, or it keeps the old copy for up to 7 days: sudo rm -rf /var/cache/nginx/wuxia/* (Path D: sudo docker compose exec web sh -c 'rm -rf /var/cache/nginx/wuxia/*')
  • Path D, updating the containers: cd ~/wuxia && sudo docker compose pull && sudo docker compose up -d
  • Back up the database and wp-config.php (it holds WUXIA_MEDIA_KEY; on Path D, docker-compose.yml) every day, and keep a copy off the server. Path D: sudo docker compose exec -T db sh -c 'mariadb-dump -uroot -p"$MARIADB_ROOT_PASSWORD" wuxia' | gzip > ~/wuxia-$(date +%F).sql.gz. Paths A to C:
Terminal
sudo mysqldump --single-transaction wuxia | gzip > ~/wuxia-$(date +%F).sql.gz