Tag: WordPress

  • Invisible Bridge — Exposing Our Home Blog Externally with Cloudflare Tunnel

    Invisible Bridge — Exposing Our Home Blog Externally with Cloudflare Tunnel

    Invisible Bridge — Exposing Our Home Blog Externally with Cloudflare Tunnel

    Self-hosting build guide, Part 9. After buying a domain, how do we pull traffic all the way to our home computer?

    ← Previous: Part 8 — Bots Arrive in 24 Hours (Security P0)
    → Next: Part 10 — What Not to Publish (Privacy Review)

    TL;DR

    • Problem: WordPress runs on our home mini-PC, but how do we connect the purchased domain (sticknstone.org) to it from outside?
    • Old way: Open a router port → register public-IP DNS → renew SSL. Five headaches.
    • New way: Cloudflare Tunnel — the mini-PC pre-opens an outbound bridge to Cloudflare. No router hole, no IP exposure.
    • Tool: Run the cloudflared daemon with Docker. Five minutes.
    • Free bonus: WAF, DDoS protection, CDN, SSL, traffic stats — all free.
    • Cost: $0 (domain renewal ~$10/yr separate).

    1. The Problem to Solve

    The blog (WordPress) is hosted on our home mini-PC. The domain (sticknstone.org) is bought. So how do the two meet?

    The ordinary path (port forwarding): five headaches

    Headache Why
    Router hole Opening port 443 invites bot attacks forever
    Shared IP The ISP keeps changing it; needs an auto-renew script
    Location exposure The IP alone hints at your rough neighborhood
    SSL renewal Let’s Encrypt by hand every 90 days
    Traffic surge A DDoS can paralyze your home internet

    2. Another Path: Build a Bridge

    A shift in thinking: don’t accept connections coming in from outside. Instead, pre-create an outgoing connection from home to somewhere external.

    flowchart LR
        subgraph oldway["Old way — Port Forwarding"]
            A1[Visitor] --> A2[Open router port 443]
            A2 --> A3[Mini-PC public IP]
            A3 --> A4[WordPress]
            A5[Hacker] -.attack.-> A2
        end
        subgraph newway["New way — Cloudflare Tunnel"]
            B1[Visitor] --> B2[Cloudflare Seoul DC]
            B2 -. pre-opened outbound channel .-> B3[Mini-PC cloudflared]
            B3 --> B4[WordPress]
            B5[Hacker] -.blocked.-> B2
        end
        style A5 fill:#fdd,stroke:#a00
        style B5 fill:#dfd,stroke:#0a0
        style A3 fill:#fdd
        style B3 fill:#dfd

    The key: the mini-PC reaches out first. Instead of outside → home, keep an always-open outbound channel home → Cloudflare. Visitor requests are forwarded by Cloudflare through that channel to the mini-PC.

    Hotel analogy

    A hotel guest (the mini-PC) wants to meet a friend.

    • Ordinary path: Post your room number at the entrance. The friend walks in by that number — and so does the thief.
    • Bridge path: Leave a note with the front desk (Cloudflare): connect anyone asking for my friend to my room. The friend just gives a name; the desk routes them. The room number is never exposed, and the desk screens suspicious visitors first.

    3. Traffic Flow — When a Visitor Types the Domain

    sequenceDiagram
        participant V as Visitor browser
        participant CF as Cloudflare Seoul DC
        participant CD as cloudflared (mini-PC)
        participant WP as WordPress container
        V->>CF: GET https://sticknstone.org
        Note over CF: WAF/DDoS first filter
        CF->>CD: Forward via pre-opened outbound channel
        CD->>WP: http://localhost:8090
        WP-->>CD: HTML response
        CD-->>CF: Reply via outbound channel
        CF-->>V: SSL termination + CDN cache + response

    Four steps: Visitor → Cloudflare (filter) → bridge → mini-PC → reverse. The mini-PC IP never appears anywhere.


    4. The Bridge Keeper: cloudflared

    The small daemon that holds this outbound channel open from home is cloudflared — Cloudflare’s official open source.

    Why run it with Docker

    Docker is already running on the mini-PC. It’s natural for cloudflared to stay in the same hotel as the other containers.

    flowchart TB
        subgraph minipc["Mini-PC (Ubuntu 24.04)"]
            subgraph docker["Docker host"]
                CD[cloudflared / network_mode host]
                WP[wordpress-wordpress-1 :8090]
                DB[(wordpress-db-1 MariaDB)]
                RD[(wordpress-redis-1 Object Cache)]
                UM[umami_umami-1 :3001]
                UDB[(umami_db-1 Postgres)]
            end
            WP --> DB
            WP --> RD
            UM --> UDB
        end
        CF[Cloudflare network] -. outbound .-> CD
        CD --> WP
        CD --> UM
        style CD fill:#ffe4b5,stroke:#d97706
        style CF fill:#dbeafe,stroke:#1d4ed8

    Only cloudflared runs with network_mode: host so it can reach localhost:8090 (WP) and localhost:3001 (Umami) on the host.

    5-minute install guide

    Folder + config files:

    /home/user/cloudflared/
    ├── .env                # TUNNEL_TOKEN=eyJh... (token from the Cloudflare dashboard)
    └── docker-compose.yml

    docker-compose.yml:

    services:
      cloudflared:
        image: cloudflare/cloudflared:latest
        container_name: cloudflared
        restart: unless-stopped
        network_mode: host
        command: tunnel --no-autoupdate run --token ${TUNNEL_TOKEN}
        env_file: .env

    Start + verify logs:

    cd /home/user/cloudflared
    sudo docker compose up -d
    sudo docker logs cloudflared --tail 20

    A successful log shows four lines (simultaneous connections to four Cloudflare Seoul data centers):

    Registered tunnel connection ... location=icn06 protocol=quic
    Registered tunnel connection ... location=icn01 protocol=quic
    Registered tunnel connection ... location=icn05 protocol=quic
    Registered tunnel connection ... location=icn06 protocol=quic

    The mini-PC connects to Cloudflare over four backup channels. If one dies, the other three stay alive.


    5. Tokens Are Risky — Where to Keep Them

    The token cloudflared uses to authenticate with Cloudflare is shown only once at issuance.

    Principles for handling the token

    • Store it in plaintext in .env, with chmod 600 (owner read/write only).
    • Never paste it in plaintext into chats, notes, or git commits.
    • If you suspect exposure, revoke and reissue from the Cloudflare dashboard immediately.

    The exact commands

    sudo chown user:user /home/user/cloudflared/.env
    sudo chmod 600 /home/user/cloudflared/.env

    6. What Comes Along for Free

    Just by laying this bridge on Cloudflare’s free plan, these bonuses come along:

    Bonus What it does Typical price
    WAF Auto-blocks known attacks like SQL injection and XSS $20/mo~
    DDoS protection Absorbs traffic surges automatically, unlimited $200/mo~
    CDN Caches static files worldwide; fast for foreign visitors $10/mo~
    Auto SSL Zero worry about Let’s Encrypt renewals $5/mo~
    Traffic stats Graphs in the Cloudflare dashboard (analytics tools)

    Total cost: $0. Only ~$10/yr for domain renewal.


    7. Pitfalls — What We Actually Hit

    Pitfall 1: Confusing the Operating System choice

    The Cloudflare dashboard’s cloudflared install screen asks you to pick an OS. At first I picked Windows, thinking of my own Windows PC. Wrong. cloudflared installs on the mini-PC (Ubuntu), not my Windows machine. The right choice was Docker.

    Pitfall 2: Pasting the token straight into LLM chat

    If you send the copied token into an LLM chat, that content is stored on an external server. The token is as sensitive as a card PIN. Revoke it from the Cloudflare dashboard as soon as you’re done.

    Pitfall 3: How does cloudflared forward to other services?

    Just starting the cloudflared container connects it to Cloudflare, but where incoming requests go is a separate setting — the Public Hostname config. Covered in the next part (Part 10).


    8. Validation

    Right after startup, check Connection Status at the bottom of the Tunnel page in the Cloudflare dashboard.

    Before:  No connection detected yet
    After:   Connected (check)

    When that one line flips, the bridge is up.


    FAQ

    Q. How is this different from Tailscale Funnel?
    Both bypass NAT and hide the mini-PC IP. Cloudflare Tunnel also bundles WAF, DDoS, CDN, and use of your own domain for free. Tailscale Funnel forces a dedicated domain and offers no shield. For a blog, Cloudflare wins.

    Q. What if the mini-PC shuts down?
    The bridge drops. Visitors see a 521 (Web server is down) from Cloudflare. When the mini-PC boots again, cloudflared reconnects automatically (Docker restart: unless-stopped).

    Q. Does Cloudflare see the traffic? Privacy concern?
    Cloudflare terminates SSL, so it can see plaintext traffic. The trust model assumes Cloudflare is on your side. If you don’t trust it, the Tunnel isn’t a fit (alternative: your own reverse proxy + Let’s Encrypt + dynamic DNS).

    Q. Limits of the free plan?
    Unlimited bandwidth, unlimited sites, unlimited tunnels. Extras like Workers and R2 have separate limits. For running one blog, the free plan is enough for life.

    Q. Does the domain have to be .org?
    No. .com / .net / .io / .dev all work the same. Using Cloudflare DNS for the domain is just the easiest (automatic DNS registration).


    Next Part Preview

    Part 10 sets the Public Hostname to map sticknstone.org → WordPress, and covers what you must not write in a post (privacy review).


    One-Line Summary

    To run a blog on a home computer, skip the old method of punching a router hole and exposing your IP. Instead, pre-open an outbound channel from the computer to Cloudflare — that’s Cloudflare Tunnel. The little daemon that keeps the channel open is cloudflared. Five minutes with Docker.


    References

  • Bots Arrive Within 24 Hours — Security P0 Before Domain Disclosure

    Bots Arrive Within 24 Hours — Security P0 Before Domain Disclosure

    Bots Arrive Within 24 Hours — Security P0 Before Domain Disclosure

    Self-hosting guide, part 8. Essential 25 minutes to complete before launching the domain.


    TL;DR

    • Disclosing a WordPress domain = bots arrive within 24 hours for brute force attacks. A statistical fact.
    • A 25-minute task can block 99%: ① Avoid username admin ② Block xmlrpc ③ Change wp-login slug.
    • These three constitute security P0. Complete this before disclosing your domain.
    • Wordfence and 2FA are P1 (to be added after domain launch).

    1. The Truth of the First 24 Hours

    When a WordPress site is exposed to the internet, within the first 24 hours:

    Attack Frequency Target
    /wp-login.php brute force Thousands per hour Admin password
    /xmlrpc.php pingback amp Continuous DDoS amplification
    Scanning for known plugin vulnerabilities Continuous Outdated plugins
    SQL injection attempts Automatic bots ?id=1 OR 1=1 URL
    User enumeration (?author=1) Automatic bots Discovering usernames

    The goal here is not just to block but to eliminate the surface itself, which is P0.


    2. P0 — Three Security Setup

    

    3. ① Avoiding Username Admin

    When creating the first user with the WordPress installation wizard, do not enter admin. Bots primarily target this username for brute force attacks.

    Good examples: jamjam, sailor904, personal nicknames
    Bad examples: admin, administrator, root, wpadmin
    

    If you’ve already created an admin user, create a new user via wp-cli, then revoke admin rights or delete the old user:

    wp user create newname [email protected] --role=administrator --user_pass=$(openssl rand -base64 24) --allow-root
    wp user delete admin --reassign=newname --allow-root
    

    4. ② Blocking xmlrpc.php — mu-plugin

    /xmlrpc.php is an old API for WordPress, rarely used (only by Jetpack users). Bots exploit it for brute force amplification (attempting thousands of passwords with a single request).

    Create /var/www/html/wp-content/mu-plugins/disable-xmlrpc.php:

    <?php
    /**
     * Plugin Name: Disable XML-RPC
     * Description: Block xmlrpc.php to reduce attack surface.
     */
    if (!defined('ABSPATH')) exit;
    
    add_filter('xmlrpc_enabled', '__return_false');
    add_filter('wp_headers', function ($headers) {
        unset($headers['X-Pingback']);
        return $headers;
    });
    add_filter('xmlrpc_methods', function ($methods) {
        unset($methods['pingback.ping']);
        unset($methods['pingback.extensions.getPingbacks']);
        return $methods;
    });
    add_action('init', function () {
        $uri = $_SERVER['REQUEST_URI'] ?? '';
        if (stripos($uri, '/xmlrpc.php') !== false) {
            status_header(403);
            exit('xmlrpc disabled');
        }
    });
    

    Verification:

    curl -I http://localhost:8090/xmlrpc.php
    # HTTP/1.1 403  ← Success
    

    5. ③ Changing wp-login Slug

    /wp-login.php is the login URL common to all WordPress sites. Bots start automatically hitting this URL. It should be obscured with a different slug.

    Install the plugin wps-hide-login:

    wp plugin install wps-hide-login --activate --allow-root
    wp option update whl_page starport --allow-root
    

    starport is a secret slug chosen by I, difficult to guess yet easy to remember. For example: byeolgorae-gate, secret-door-2026, starport.

    Verification:

    curl -I -L http://localhost:8090/wp-login.php
    # 301 redirect → Fake URL (bot fails there)
    
    curl -I -L http://localhost:8090/wp-admin/
    # 404 (Admin page appears nonexistent when logged out)
    
    curl -I -L http://localhost:8090/starport/
    # 200 (Login page known only to I)
    

    If I forget the slug, I won’t be able to access it either. Bookmarking and note-taking are essential.


    6. Results — From the Bot’s Perspective

    Attack Response after Blocking
    GET /wp-login.php 301 → Fake URL → Bot attempts there → Forever fails
    POST /xmlrpc.php Blocked immediately with 403. Amplification not possible
    GET /wp-admin/ 404. The admin page pretends to be nonexistent
    Username brute force Attempting admin → Our site has no admin
    ?author=1 user enum Rank Math automatically blocks it (Disable Author Archives)

    Resources expended by bots = Gains from I’s site = 0.


    7. P0 vs P1 vs P2 — Prioritization

    Stage Task Timing
    P0 (Mandatory, before domain disclosure) Username, xmlrpc, wp-login slug This 25 minutes
    P1 (Immediately after domain launch) Wordfence wizard + 2FA GUI operations by I
    P2 (Monthly) Plugin security updates + WPA (vulnerability scan) Operational phase

    If I launch the domain without completing P0 — bots will arrive within 24 hours to start brute force attempts. Even without setup, it is impenetrable, yet logs will accumulate brute force attempts and consume mini-PC CPU. Preventing such occurrences is vital for mental well-being.


    8. Traps

    Trap 1. mu-plugin Auto Activation

    mu-plugins run forced above regular plugins. Consequently, security policies, like blocking xmlrpc, that must never be disabled, are suitable for mu-plugins.

    Trap 2. Forgetting WPS Hide Login Slug

    Since the whl_page option is directly written into the DB, if I forget the slug, I won’t be able to access it either. Recovery method: wp option get whl_page --allow-root (via mini-PC SSH) or temporarily deactivate the mu-plugin.

    Trap 3. Automatic Bypass After Authentication

    WPS Hide Login only blocks those who do not know the slug. A logged-in session continues to use /wp-admin/. Once I log in, I have free access to the admin page for the next 30 minutes.

    Trap 4. Entry with a New Slug Upon Logout

    After I log out, the next login will be at /starport. Updating bookmarks is essential.


    FAQ

    Q. What if I already disclosed the domain but haven’t set up P0?
    Immediately turn off cloudflared or disable the Tailscale Funnel → Set up P0 → Re-disclose. 5 minutes.

    Q. Why is the Wordfence wizard (P1) not P0?
    P0 removes the surface itself. Wordfence detects and blocks attacks while the surface is still present. It serves as a supplementary measure.

    Q. Can I change the slug to /admin?
    /admin is also a common slug targeted by bots. Not recommended. Choose a word that only I can think of, which is difficult to guess.

    Q. Is P0 sufficient without 2FA?
    Sufficient + entirely enough. 2FA acts as the final shield when passwords are compromised. P0 + 2FA = Effectively invulnerable.

    Q. If I block xmlrpc, can I still use Jetpack?
    Some Jetpack functionalities are disabled. It is irrelevant if Jetpack is not used at all. Our Star Whale does not utilize Jetpack.


    Next Episode Preview

    Part 9 — The Invisible Bridge: Cloudflare Tunnel. Finally connecting the domain to the mini-PC. No need to open router ports, without exposing the mini-PC IP, and employing a free WAF, DDoS protection, and CDN.


    Summary

    Disclosing a WordPress domain = Bots will arrive within 24 hours. A P0 setup (username, xmlrpc, wp-login slug) in 25 minutes can block 99%. Complete this before launching the domain.


  • One-Pyeong Server — Running WordPress with Docker

    One-Pyeong Server — Running WordPress with Docker

    One-Pyeong Server — Running WordPress with Docker

    Self-hosting deployment part 2. Infrastructure skeleton in 30 minutes.


    TL;DR

    • Launch WordPress + MariaDB + Redis three containers with a single docker-compose.yml file.
    • Only expose host port 127.0.0.1:8090. External routing handled by Cloudflare Tunnel (part 9).
    • Use Redis as an Object Cache for database query caching. Free and improves page response time by 30%.
    • Five traps: Korean file name SCP, healthcheck --connect, persisted password in named volume, WP_REDIS_HOST=redis, WORDPRESS_CONFIG_EXTRA X-Forwarded-Proto.

    1. Why Docker — Host Installation vs Container

    Comparison Direct Host (apt install) Docker Compose ⭐
    Installation Simplicity apt install apache2 php mariadb-server, etc. docker compose up -d
    Dependency Conflicts Conflicts like PHP 8.1 vs 8.3 arise Container Isolation
    Mini-PC OS Reinstallation Start from scratch Just compose.yml + data backup
    Updates System package manager (complexity) docker compose pull && up -d
    Coexistence of Different Containers (WP, Umami, cloudflared) Difficult Natural

    If the mini-PC is already running other services with Docker, the answer is Docker.


    2. Structure on a Page

    

    The three containers communicate within the same Docker network using hostnames (db, redis). Only the host 127.0.0.1:8090 is exposed externally.


    3. Key docker-compose.yml

    services:
      wordpress:
        image: wordpress:php8.3-apache
        restart: unless-stopped
        depends_on:
          db:
            condition: service_healthy
        environment:
          WORDPRESS_DB_HOST: db
          WORDPRESS_DB_USER: wp
          WORDPRESS_DB_PASSWORD: ${DB_PASSWORD}
          WORDPRESS_DB_NAME: wp
          WORDPRESS_CONFIG_EXTRA: |
            if (!empty($$_SERVER['HTTP_X_FORWARDED_PROTO']) && $$_SERVER['HTTP_X_FORWARDED_PROTO'] === 'https') {
              $$_SERVER['HTTPS'] = 'on';
            }
        volumes:
          - wordpress_data:/var/www/html
        ports:
          - "127.0.0.1:8090:80"
    
      db:
        image: mariadb:11
        restart: unless-stopped
        environment:
          MARIADB_DATABASE: wp
          MARIADB_USER: wp
          MARIADB_PASSWORD: ${DB_PASSWORD}
          MARIADB_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}
        volumes:
          - wordpress_db_data:/var/lib/mysql
        healthcheck:
          test: ["CMD", "healthcheck.sh", "--connect"]
          interval: 10s
          timeout: 5s
          retries: 6
          start_period: 30s
    
      redis:
        image: redis:7-alpine
        restart: unless-stopped
        volumes:
          - wordpress_redis_data:/data
    
    volumes:
      wordpress_data:
      wordpress_db_data:
      wordpress_redis_data:
    

    Separate .env:

    DB_PASSWORD=<openssl rand -hex 24>
    DB_ROOT_PASSWORD=<openssl rand -hex 24>
    

    chmod 600 .env. Do not include in git.


    4. Start + Verification

    cd /home/user/wordpress
    docker compose up -d
    docker compose ps
    curl -I http://localhost:8090
    

    If the response is HTTP/1.1 302 (redirecting to WordPress installation page), it is successful.

    Access http://localhost:8090 in a browser → 5-minute wizard → create admin account (do not use admin as the name, choose your own to avoid initial brute force).


    5. Activate Redis Object Cache

    After installing the redis-cache plugin:

    wp config set WP_REDIS_HOST redis --allow-root
    wp config set WP_REDIS_PORT 6379 --allow-root
    wp config set WP_REDIS_PREFIX byeolgorae: --allow-root
    wp config set WP_CACHE true --raw --allow-root
    wp redis enable --allow-root
    

    WP_REDIS_HOST=redis is key. It is the Docker hostname. Using 127.0.0.1 results in failure as it only looks within its own container.


    6. Traps — Real Experiences Faced

    Trap 1. Korean and Box Characters in File SCP

    While writing docker-compose.yml with minipc_write_file, the SSH escape broke due to a combination of Korean comments and special characters (bash: -c: unexpected end of file while looking for matching quotation mark).

    → Write plain text in c:\tmp\ on Windows → scp → mini-PC /tmp/ → circumvent by sudo cp.

    Trap 2. mariadb healthcheck unsupported --innodb-initialized

    Following the web guide with ["CMD", "healthcheck.sh", "--connect", "--innodb-initialized"] led to an unknown option error.

    → Use only --connect + extend start_period: 30s.

    Trap 3. “Database Error” after changing .env password

    After changing the DB password and running docker compose up -d, a 500 error appeared. Reason: The old password was retained in the MariaDB DB file stored in the named volume. The new password failed authentication.

    → Run docker compose down -v (the -v removes volumes) → restart with up -d. Do not do this if operational data exists; only at initial setup stage.

    Trap 4. Redis Connection refused 127.0.0.1:6379

    After setting WP_REDIS_HOST=127.0.0.1, it resulted in the container trying to access itself. Redis is in another container.

    → Set WP_REDIS_HOST=redis (Docker network hostname).

    Trap 5. HTTPS Reverse Proxy Infinite Redirect

    With Cloudflare Tunnel in front, it forwards HTTP to the mini-PC after terminating HTTPS. WordPress identifies itself as HTTP → redirects saying it must go to “HTTPS” → infinite loop.

    → Handle processing for X-Forwarded-Proto in WORDPRESS_CONFIG_EXTRA (see above yml).


    FAQ

    Q. How much memory is used?
    WP (php-fpm or apache) ~200MB, MariaDB ~150MB, Redis ~30MB. Total ~400MB. Plenty of room with 8GB RAM on the mini-PC.

    Q. What about disk space?
    Image combined ~1GB. DB size varies (100 articles + images = ~500MB).

    Q. Can I use PHP 8.2 instead of 8.3?
    WordPress 6.x supports both 8.2 and 8.3. 8.3 is faster (JIT stabilization). It is recommended to use the latest.

    Q. What if I use mysql instead of mariadb?
    mariadb is compatible with mysql + free licensing + no Oracle dependency. Both will work. mariadb is recommended for single-user operations.

    Q. How to backup?
    Details covered in part 4 (backup, cache, images). Daily cron at 03:00 runs mariadb-dump → gzip → stored in Obsidian Vault.


    Next Part Preview

    Part 3 — For AI to Read: robots.txt and llms.txt. Specify allowances for 14 types of AI crawlers instead of discrimination against bots. Content exposure strategy for the GEO era.


    Summary in One Line

    Launching three containers (WordPress, MariaDB, Redis) through a single docker-compose.yml file in 30 minutes. Only exposing host 127.0.0.1:8090; external routing delegated to Cloudflare Tunnel. Traps include Korean SCP, healthcheck issues, password persistence in volumes, Redis host misconfiguration, and X-Forwarded-Proto problems.