How video reaches a viewer
- Viewerpresses play
- Cloudflarecaches segments
- nginxthe two media rules
- WordPresschecks the signed link, names the file
- 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.
The site works, but every episode answers 501 Media is served through nginx.
1Pick your hosting
| Your hosting | Pages | Video | Follow |
|---|---|---|---|
A VPS or dedicated server you can ssh into (Ubuntu 24.04) | Yes | Yes | Path A |
| A panel that runs nginx and accepts custom nginx config (CloudPanel, RunCloud, SpinupWP, GridPane, Plesk with nginx) | Yes | Yes | Path B |
| A server where you already run WordPress on nginx | Yes | Yes | Path C |
| A server with Docker, where you want Nginx Proxy Manager to handle domains and HTTPS certificates | Yes | Yes | Path D |
| Shared hosting, cPanel, Apache or LiteSpeed | Yes | No | Move 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
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 curlA2Database
Replace A_STRONG_DB_PASSWORD; you need it again in the WordPress installer.
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
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/wuxiaA4The Cloudflare origin certificate
Cloudflare talks to your server over HTTPS with a free certificate that lasts 15 years.
- In Cloudflare: SSL/TLS → Origin Server → Create Certificate, keep the defaults, Create.
- On the server, paste the two blocks it shows:
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.keyA5Visitor IPs behind Cloudflare
Without this every visitor looks like a Cloudflare address, and the media rate limit would slow everyone down together.
{ 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.confA6The media rules
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/wuxiaPart 1 of that file belongs inside http { } and part 2 inside server { }. Split it into two files:
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/nullThe 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.
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 nginxnginx -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:
| Panel | Where |
|---|---|
| CloudPanel | Site → Vhost (edit the file directly) |
| RunCloud | Web Application → NGINX Config → a config of type location.main-before, and one of type http |
| SpinupWP | Site → Custom Nginx Config (or /etc/nginx/sites-available/<site>/server/ over SSH) |
| GridPane | /var/www/<site>/nginx/ includes, over SSH |
| Plesk | Domain → Apache & nginx Settings → Additional nginx directives, with “Proxy mode” off |
- Part 1 at http level
From
nginx-wuxia-media.conf, put part 1 where the panel acceptshttp-level config. If it has none, ask your host to add those two lines (limit_req_zone …andproxy_cache_path …); both are harmless to other sites. - Part 2 in the site
Put part 2 in the site’s server block.
- Point it at PHP
Find
fastcgi_passin the site’s vhost and copy that value exactly into part 2. - Cache folder
mkdir -p /var/cache/nginx/wuxia, owned by nginx’s user (or ask your host). - Save
The panel reloads nginx. If it reports an error, the message names the line.
4Path C · a server that already runs nginx
- Download
- Part 1
Inside
http { }, usually as a new file in/etc/nginx/conf.d/. - Part 2
Inside the site’s
server { }, next to its otherlocationblocks. - Lines marked CHANGE
fastcgi_pass: the same value as yourlocation ~ \.php$block. On RHEL, Alma or Rocky the CA bundle is/etc/pki/tls/certs/ca-bundle.crt. - Cache folder
sudo mkdir -p /var/cache/nginx/wuxia && sudo chown -R www-data /var/cache/nginx/wuxia(the user isnginxon RHEL-family systems). - Behind Cloudflare?
Add the visitor-IP file from step A5.
- 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.
- Cloudflareoptional
- Nginx Proxy Managerdomains and HTTPS
- nginxthe two media rules
- WordPressPHP-FPM
- MariaDByour catalogue
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):
curl -fsSL https://get.docker.com | sudo sh
sudo ufw allow OpenSSH
sudo ufw allow 80,443/tcp
sudo ufw --force enableD2The files
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 nginxls 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.
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 psAll 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:
ssh -L 8181:127.0.0.1:81 YOUR_USER@YOUR_SERVER_IPKeep 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:
| Field | Value |
|---|---|
| Domain Names | YOUR_DOMAIN and www.YOUR_DOMAIN |
| Scheme | http |
| Forward Hostname / IP | web |
| Forward Port | 80 |
| Cache Assets | off |
| Block Common Exploits | off (not needed: WordPress and nginx check every media link themselves) |
| Websockets Support | off |
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:
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
- DNS
Arecords for@andwwwpointing at the server, Proxied (orange cloud). - SSL
SSL/TLS → Overview → Full (strict). Never Flexible: it loops wp-admin redirects.
- 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. - Never cache admin
Another cache rule: bypass cache for
/wp-admin,/wp-login.phpand/wp-json/. - 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. - Optional
Security → WAF → Rate limiting rules:
/media/at about 600 requests per minute per IP.
8Install the theme and plugin
- Plugin
Plugins → Add New Plugin → Upload Plugin →
wuxia-core.zip→ Activate. - Theme
Appearance → Themes → Add New Theme → Upload Theme →
wuxia-theme.zip→ Activate. - Readable links
Settings → Permalinks → Post name → Save.
- A media key, before adding storage
Recommended. Add to
wp-config.php, above/* That's all, stop editing! */, a value fromopenssl rand -hex 32:
define( 'WUXIA_MEDIA_KEY', 'PASTE_64_RANDOM_CHARACTERS' );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:
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
- Play
Open any episode and press play.
- Network tab
Open the browser’s developer tools → Network, filter
media. - 200
master.m3u8and the segments (.ts,.m4s,.single,.married) answer 200. - Cache
A segment’s response headers show
x-wuxia-origin-cache: MISSthe first time andHITafter.
Or, from a terminal (a deliberately bad link):
curl -s -o /dev/null -w "%{http_code}\n" https://YOUR_DOMAIN/media/1/0.AAAAAAAAAAAAAAAAAAAAAA/master.m3u8403 or 404The rules are working.
501The rules are missing. See the first row below.
10Troubleshooting
| You see | Meaning | Fix |
|---|---|---|
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 unknown | Part 1 is missing. | Add part 1 inside http { }. |
nginx -t: “limit_req_zone” directive is not allowed here | Part 1 was put inside server { }. | Move it to http { }. |
Episodes fail; the error log says Permission denied under /var/cache/nginx/wuxia | nginx’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:… failed | fastcgi_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. |
404 | That episode or file doesn’t exist. | Check the episode’s link and provider. |
429 | Too 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 404 | Permalinks are “Plain”. | Settings → Permalinks → Post name → Save. |
| Path D: Nginx Proxy Manager shows 502 Bad Gateway | It 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 fails | Let’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 runs | site.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 newnginx-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 holdsWUXIA_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:
sudo mysqldump --single-transaction wuxia | gzip > ~/wuxia-$(date +%F).sql.gz