Tag: self-hosting

  • No Open Ports — Reaching My Server from Anywhere with Tailscale

    No Open Ports — Reaching My Server from Anywhere with Tailscale

    No Open Ports — Reaching My Server from Anywhere with Tailscale

    Self-hosting build guide part 16. If part 9 was the road for visitors to enter the blog (Cloudflare Tunnel), this is the road for me alone to get inside my server.

    The companion part: Part 9 — Invisible Bridge (Cloudflare Tunnel)


    TL;DR

    • Problem: I want to reach my home server by remote desktop or SSH — from outside, or from a laptop in another room. But opening a port is dangerous.
    • Old method: Open a remote-desktop port on the router → bots brute-force the login 24/7.
    • New method: Tailscale — bundle my devices (server, laptop, phone) into one encrypted private network. Zero holes in the router.
    • Why it’s safe: Nothing is exposed to the internet + end-to-end encryption between devices + only my logged-in devices may enter.
    • Tool: Install the Tailscale app on each device and sign in with the same account. 5 minutes.
    • Cost: $0 (free for up to 100 devices on a personal plan).

    1. The Problem — How Do I Get Into My Server?

    Once you run a small server at home, this need shows up fast: you want to reach it by remote desktop (see the screen as-is) or SSH (a terminal) — from outside, or from another room in the same house.

    The classic old way is to open a port on the router (port forwarding). And that’s where the trouble starts.

    Trouble Why
    Brute-force attacks Open a remote-desktop port to the internet and bots start guessing passwords the moment it’s up
    One thin password If a bot breaks it, the whole server is gone. The login is the only line of defense
    Location exposure The public IP alone hints at your rough neighborhood
    Renewal hassle When the ISP rotates your public IP, the connection address changes too

    Exposing remote desktop directly to the internet is close to security suicide. (Part 9 handled visitor traffic; this is a channel only I, the admin, use, so it’s even more sensitive.)


    2. A Different Idea — Don’t Open Ports, Network the Devices

    Here’s the shift in thinking: Don’t open a door for the outside to come in. Instead, build a separate encrypted private network that only my own devices share.

    Tailscale does exactly this. Its engine is WireGuard (a modern, fast encrypted-tunnel technology), and Tailscale lays down those WireGuard tunnels automatically, with no configuration.

    Once installed, my devices join one private network (a “tailnet”), and each device gets a private address of the form 100.x.x.x. That address is invisible to the internet and reachable only by my own devices. Even far apart, they connect as if on the same LAN cable.


    3. Why It’s Safe — A Matchmaker and a Phone Call

    Tailscale’s cleverest design is separating coordination (control) from data. A matchmaker analogy makes it click.

    • Coordination server = the matchmaker: Tailscale’s servers authenticate devices and introduce them to each other. “These two belong to the same owner, so they may connect” — and they hand over public keys. But they never listen to the actual conversation.
    • Data = the actual call: Real traffic, like your remote-desktop screen, flows directly between devices (peer-to-peer), end-to-end encrypted by WireGuard. It does not pass through the company’s servers.

    That yields three layers of security:

    Safeguard What it means
    Zero exposed ports The server’s remote-desktop port doesn’t even appear to the internet. There’s no target for scanners
    End-to-end encryption WireGuard encrypts every link between devices. Intercept it and you still can’t read it
    Identity-based entry Not a shared password — only devices signed into my account join the network. A lost device can be cut off instantly from the console

    Each device’s private key never leaves that device. Only public keys go to the coordination server, so even if that server were breached, your traffic stays unreadable.


    4. Traffic Flow — Old vs New

    

    The old way punches a hole in the router that anyone can knock on. The new way wires an encrypted tunnel directly between my own devices and opens no door at all to outsiders.


    5. How Is This Different from Cloudflare Tunnel?

    It’s easy to confuse this with Cloudflare Tunnel from part 9. They aren’t competitors — they’re complementary tools with different jobs.

    Cloudflare Tunnel (part 9) Tailscale (this part)
    Goal Publish the blog to visitors Private access for me
    Audience Anyone, worldwide (https) Only my signed-in devices
    Used for Exposing a blog/website Remote desktop, SSH, admin
    Analogy Hotel front desk (guides guests) A staff-only back hallway

    Publish the blog to the world with Cloudflare Tunnel, and get into that same server to manage it with Tailscale. Running both on one server doesn’t conflict at all.


    6. How to Use It — 5 Minutes

    The idea is simple: install the app on every device you want to reach, sign in with the same account, done.

    1. Install Tailscale on the server (e.g., a Linux mini-PC) → sign in
    2. Install the Tailscale app on your laptop and phone → sign in with the same account
    3. Now every device joins one private network and gets a 100.x.x.x address
    4. For remote desktop or SSH, connect to the server’s Tailscale address

    Turn on MagicDNS and you can connect by device name instead of the address (e.g., myserver → that device). The private address, once assigned, doesn’t change, so you can keep it like a bookmark.


    7. Pitfalls — What We Actually Hit

    Pitfall 1. Connecting via the LAN IP gets blocked

    A device on the same home network has two addresses — the LAN IP the router gave it (192.168.x.x) and the private address Tailscale gave it (100.x.x.x). If you “safely” lock the server’s firewall to allow only the Tailscale path, then connecting over the LAN IP gets dropped by the firewall.

    The symptom is nasty: ping works and SSH (22) works, but remote desktop alone fails — because the firewall blocks only that port. Even inside the same house, you must connect via the Tailscale address. Not knowing this, we burned a while wondering “why doesn’t it work even at home?” (The saved connection address was set to the LAN IP — that was the culprit.)

    Pitfall 2. Both ends must be running

    Tailscale must be up on both devices to connect. If Tailscale is logged out on the laptop, it isn’t on the network, so of course it can’t connect. It usually auto-starts at boot, but when something’s off, suspect this first.

    Pitfall 3. Long-idle devices drop offline

    A device you haven’t powered on for a while (a phone, say) shows as “offline” in the console. Turn it on and sign in to bring it back. Tidying the device list now and then helps.


    8. Verification

    The fastest way to confirm the connection (with Tailscale on at both ends):

    ① Is the server’s private address alive?ping <server's Tailscale address> and watch for replies.

    ② Does the target port actually reach? — on Windows, in PowerShell:

    Test-NetConnection -ComputerName <server Tailscale address> -Port <port>
    

    If TcpTestSucceeded : True shows up, the private tunnel is healthy. Now point remote desktop or SSH at that address.


    FAQ

    Q. Can I use it together with Cloudflare Tunnel?
    Yes. Different jobs. Publish the blog via Cloudflare Tunnel, and manage the server (remote desktop, SSH) via Tailscale. No conflict.

    Q. What’s the free limit?
    The personal plan is free for up to 100 devices and 3 users. For bundling a few home servers, that’s effectively free forever.

    Q. Does Tailscale’s company see my traffic?
    No. The company’s servers only introduce; the actual data flows directly between devices, encrypted. (On tricky networks where a direct link fails, it goes through an encrypted relay — but even then it’s decrypted only at the two ends, so the relay can’t read it.)

    Q. What if power or internet drops?
    If the server goes down, it leaves the network. When it comes back, it rejoins automatically. The network itself is tied to your account and stays intact.

    Q. Is this used at companies too?
    Yes. It’s widely used in companies as a “zero trust” (trust nothing by default) way to reach internal networks. A home lab just applies the same principle for free.


    One-Line Summary

    To reach a home server from anywhere, instead of the old way of opening a router port and inviting bot attacks, build an encrypted private network of just your own devices with Tailscale. WireGuard encrypts end to end, and not a single port is opened to the internet. Setup is installing the app on each device and signing in with the same account — 5 minutes.


    References

  • The Link That Won’t Tap — Opening Obsidian Notes From Telegram

    The Link That Won’t Tap — Opening Obsidian Notes From Telegram

    Self-Hosting Build Guide, Part 15. Finishing the build guide isn’t the end — once you actually use it, follow-on problems surface. The note we synced in Part 13: its link wouldn’t tap on the phone.

    ← Previous: Part 13 — My Notes on My Server (Obsidian LiveSync)

    TL;DR

    • An Obsidian note carries its own link, shaped like obsidian://open?vault=...&file=...
    • Send that link over Telegram and you get gray dead text, not a blue link — it won’t tap
    • Why: chat apps auto-linkify only http/https. Custom schemes like obsidian:// are ignored
    • Fix: insert one https hop — a “shuttle” page that receives over https and bounces the browser to obsidian://
    • We put that shuttle on a Cloudflare Worker (serverless) instead of the home server — works even when the home machine is off, $0

    1. Symptom — A Link Gone Gray and Dead

    Obsidian gives every note its own link. Right-click a note and hit “Copy Obsidian URL” and you get something like obsidian://open?vault=MyVault&file=folder/note. I expected it to turn blue in Telegram, but it came up as gray plain text. Tapping does nothing. It was never a link — just letters.

    2. Cause — Chat Apps Only Linkify http/https

    A chat app scans the text of a message and, when it spots a URL, turns it into a tappable link on its own. But that auto-conversion applies only to http:///https:// (and Telegram’s own tg://). App-specific custom schemes like obsidian://, notion://, slack:// aren’t converted — they stay as plain text. Telegram’s iOS repo has the same issue on file: links with a custom URI scheme aren’t clickable.

    Can’t you route around it with a button or a markdown link? No. The inline buttons and links a bot attaches to a message also allow only http/https/tg. Custom schemes are rejected.

    3. The Standard Pattern — Insert One https “Shuttle”

    Since the custom scheme can’t go directly, route it through https once in the middle.

    1. Send Telegram an https link → it becomes a blue link
    2. Tap it and the browser opens that https page
    3. The page’s JavaScript bounces to obsidian:// → Obsidian opens

    This pattern isn’t new. A free public service called obsid.net does exactly this one thing — a static page that takes vault and file and hands the browser off to obsidian://. You could just use it.

    4. So Why Build My Own

    The reason I built my own anyway, despite obsid.net, is the same one that runs through this series.

    • No third party in the path — my vault name and note paths don’t pass through someone else’s server (even one that claims no logging)
    • My own domain — one line, o.mydomain, and the link is mine
    • I control the page — I can add the button to tap when the auto-redirect is blocked, and the filename that shows which note it is (this matters because of the traps in section 7)

    At first I dropped the page into the static folder of another app I already had running. It worked, but it meant wedging a notification redirect inside an unrelated app — the boundaries got dirty. A redirect belongs to no single app; it’s a generic utility. So I pulled it out and let it stand alone.

    5. Where to Put It — Home Server vs the Edge

    If it’s going to stand alone, it’s one of two places.

    A small web server on the home box Cloudflare Worker (edge)
    Runs when only if the home machine is on even when the home machine is off
    Certificate issue/renew yourself automatic
    Cost electricity free (100k requests/day)
    Dependency none Cloudflare

    A redirect is a one-second static response, so there’s no reason to keep the home server awake for it. I picked the Cloudflare Worker — put a snippet of code on the cloud edge and it answers without you running a server (this is called serverless). The domain I bought in Part 6 is already on Cloudflare, so attaching one subdomain is all it takes.

    6. How It Works — https Receives, Bounces to obsidian://

    What the Worker does is short. It takes ?f=note-path and returns a single HTML page that turns it into an obsidian:// link.

    export default {
      async fetch(request) {
        const url = new URL(request.url);
        const file = url.searchParams.get('f') || '';
        const vault = url.searchParams.get('v') || 'MyVault';
        const uri = 'obsidian://open?vault=' +
          encodeURIComponent(vault) + '&file=' + encodeURIComponent(file);
        const html = '<!doctype html><meta charset=utf-8>'
          + '<p>' + file + '</p>'
          + '<a href="' + uri + '">Open note</a>'
          + '<script>setTimeout(function(){location.href=' + JSON.stringify(uri) + '},350)</' + 'script>';
        return new Response(html, {
          headers: { 'content-type': 'text/html; charset=utf-8' }
        });
      }
    }
    

    To Telegram you send only https://o.mydomain/?f=note-path. That’s https, so it becomes a blue link; tap it and the page above comes up and hands off to Obsidian.

    7. Three Traps

    ① The auto-redirect gets blocked. Even if you set location.href = obsidian://... for an automatic jump, mobile browsers (especially Android Chrome and the in-app browsers inside chat apps) sometimes block, for security, a custom-scheme navigation the user didn’t tap themselves (“Navigation is blocked”). So you can’t lean on the auto-redirect alone — always include an “Open note” button for the user to tap. That’s why an auto-only page fails now and then.

    ② The vault name differs per device. The X in obsidian://open?vault=X must exactly match the vault’s display name registered on that device. If it opens on your PC but the phone says “note not found,” nine times out of ten the vault name differs. The Obsidian forum has the same case: the app opens on Android but the note doesn’t. Match it with &v=PhoneVaultName in the link.

    ③ Sync comes first. A link opens a note; it doesn’t create one. The note has to be synced to the phone (Part 13, LiveSync) for it to open.

    8. Cost

    • Cloudflare Worker: $0 (free plan, 100k requests/day)
    • Domain: already owned (Part 6), no extra charge for a subdomain
    • Home server load: none (the edge answers)

    In One Line

    Chat apps turn only http/https into links. An app link like obsidian:// needs one https shuttle inserted to become tappable. Put that shuttle on a Cloudflare Worker and it runs free, independent of your home server. Just include a button, since the auto-redirect can be blocked, and match the vault name to the device.

  • 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

  • Star Whale Analytics Lab — Visitor Statistics with Umami

    Star Whale Analytics Lab — Visitor Statistics with Umami

    Star Whale Analytics Lab — Visitor Statistics with Umami

    Self-hosting build series part 5. Taking data sovereignty to the mini-PC.


    TL;DR

    • Google Analytics free = I give visitor data to Google servers + forced cookie consent pop-ups
    • Umami self-hosted = own analysis on mini-PC. No cookies, data owned by me, automatic exemption from GDPR
    • Docker Compose in 5 minutes. Automatic injection of one line tracker into WP = done
    • Display: visitor count, popular posts, traffic sources, devices, countries, dwell time

    1. The Hidden Costs of Google Analytics

    Item Google Analytics 4 Umami self-hosted
    Cost Free (personal use) Free (open source)
    Data Ownership Google Own mini-PC
    Cookie Consent Popup Required (GDPR/CCPA) Unnecessary (0 cookies)
    Page Load Load ~50KB JS ~2KB JS
    User Tracking Cross-site (linked to Google ads) Same site only
    South Korea PIPA Compliance Complicated Automatic exemption
    Setup Complexity 1 hour+ 5 minutes

    Google Analytics externalizes the real costs to the user (the visitor). Is it normal for Google to take all that data just because a visitor entered my site? For a personal blog, it is right to operate it ourselves.


    2. Structure

    

    When the visitor browser receives the Star Whale page, the script in the head <script defer src="https://analytics.sticknstone.org/script.js" data-website-id="..."> executes → asynchronous request to Umami → Umami logs to Postgres. The page display is not interrupted.


    3. Docker Compose

    /home/user/umami/docker-compose.yml:

    services:
      umami:
        image: ghcr.io/umami-software/umami:postgresql-latest
        restart: unless-stopped
        depends_on:
          db:
            condition: service_healthy
        environment:
          DATABASE_URL: postgresql://umami:${DB_PASSWORD}@db:5432/umami
          DATABASE_TYPE: postgresql
          APP_SECRET: ${APP_SECRET}
        ports:
          - "3001:3000"
    
      db:
        image: postgres:16-alpine
        restart: unless-stopped
        environment:
          POSTGRES_DB: umami
          POSTGRES_USER: umami
          POSTGRES_PASSWORD: ${DB_PASSWORD}
        volumes:
          - umami_db:/var/lib/postgresql/data
        healthcheck:
          test: ["CMD-SHELL", "pg_isready -U umami"]
          interval: 5s
          timeout: 5s
          retries: 6
    
    volumes:
      umami_db:
    

    .env:

    APP_SECRET=$(openssl rand -hex 32)
    DB_PASSWORD=$(openssl rand -hex 24)
    

    Run:

    cd /home/user/umami
    docker compose up -d
    curl -I http://localhost:3001  # HTTP/1.1 200
    

    Default login: admin / umami. Change the password immediately.


    4. Registering Star Whale + Receiving Tracker

    Umami dashboard → Websites → Add Website:

    Field Value
    Name Star Whale Logbook
    Domain sticknstone.org

    Save → Click site → Edit → Tracking Code:

    <script defer src="https://analytics.sticknstone.org/script.js" data-website-id="1892c870-dd4d-4ce7-9fd6-2635f8ef6220"></script>
    

    5. Automatic Injection into WP — mu-plugin

    The code is automatically placed, not manually by me for every post.

    /var/www/html/wp-content/mu-plugins/umami-tracker.php:

    <?php
    /**
     * Plugin Name: Umami Analytics Tracker
     * Description: Injects Umami tracker into front-end pages. Excludes logged-in users.
     */
    if (!defined('ABSPATH')) exit;
    
    add_action('wp_head', function () {
        if (is_admin()) return;
        if (is_user_logged_in()) return;  // Exclude my own visits
        ?>
        <script defer src="https://analytics.sticknstone.org/script.js" data-website-id="1892c870-dd4d-4ce7-9fd6-2635f8ef6220"></script>
        <?php
    }, 100);
    

    Checking is_user_logged_in() = the number of times I’ve previewed my own posts will not be counted in the statistics. Surprisingly important.


    6. What is Visible on the Dashboard

    Screen Data
    Overview Daily, weekly, monthly visitors, page views, sessions, dwell time
    Pages Ranking of popular posts
    Referrers Traffic sources (Google, Twitter, other sites)
    Browsers Chrome, Safari, Firefox ratio
    OS Windows, Mac, iOS, Android
    Devices Desktop, mobile, tablet
    Countries Visitors by country
    Languages Browser languages
    Events Clicks and downloads defined by me

    Automatically updates every 5 seconds. Realtime menu = see who is currently viewing which page.


    7. Reason to Handle Domain Linking Together

    When the tracker is first received, the URL is http://192.168.0.x:3001/script.js (LAN IP). This causes:
    – HTTP → Browser Mixed Content block
    – LAN IP → Uninterpretable from external visitor PCs

    Umami must be activated after connecting the domain (part 9). It needs to be exposed externally as analytics.sticknstone.org to function.


    FAQ

    Q. Is there a free cloud plan for Umami?
    Umami Cloud starts at $9 per month. Self-hosted is unlimited and free. If you have a mini-PC, self-hosted is the answer.

    Q. Can I migrate from Google Analytics?
    No past data import. It’s a fresh start. However, both tools can be operational concurrently (both trackers can be placed).

    Q. Do I need to worry about South Korea’s PIPA (Personal Information Protection Act)?
    Umami does not store IP addresses (only hashes), and there are no cookies. It qualifies for PIPA exemption.

    Q. Is there a mobile app?
    No official app. The mobile web is responsive, so it displays well on phones.

    Q. Can I export data as CSV?
    CSV and JSON export are available on the dashboard. API is also provided.


    Next Part Preview

    Part 6 — Domain Fraud: Cloudflare Registrar. Reasons for purchasing from Cloudflare with Gabia and WhoIs (price, management integration, future Tunnel connection).


    Summary in One Line

    The real cost of Google Analytics is the externalization of user data. Umami self-hosted = 5 minutes of Docker on the mini-PC + automatic WP mu-plugin injection = data sovereignty + no cookies + the same information.

  • 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.


  • AI Should Read — robots.txt and llms.txt

    AI Should Read — robots.txt and llms.txt

    AI Should Read — robots.txt and llms.txt

    Self-hosting construction log, part 3. The era of SEO is fading, and the era of GEO is beginning. Which side are we on?


    TL;DR

    • 31.3% of the U.S. population will use generative AI search by 2026. 83% of queries are satisfied without site visits (eMarketer).
    • Old method: Blocking AI bots in robots.txt. A defensive mindset about search.
    • New method: Explicitly allowing 14 types of AI bots and guiding them with llms.txt. Cited sites win.
    • Star Whale mu-plugin in one file + /llms.txt in one file = 5 minutes.

    1. From SEO to GEO — What Has Changed

    

    Key changes:
    Traffic ↓ — AI responses delivered instantly without going through search results. Site visits themselves decrease.
    Citations ↑ — When AI responds, the site name is displayed with “source: ~”.
    Trust = Citation Count — If Perplexity·ChatGPT frequently cites our articles, that becomes new authority.

    Blocking bots is suicidal. If not cited, existence becomes moot.


    2. Not Blocking but Allowing — 14 Types of AI Crawlers

    Dynamically generate robots.txt with mu-plugin:

    <?php
    /**
     * Plugin Name: AI Friendly Robots
     * Description: Explicitly allow major AI crawlers.
     */
    add_filter('robots_txt', function ($output, $public) {
        if ($public != '1') return $output;
    
        $output .= "n# === AI search engines / LLM crawlers (explicitly allowed) ===n";
        $bots = [
            'GPTBot',           // OpenAI Learning
            'OAI-SearchBot',    // OpenAI Search
            'ChatGPT-User',     // ChatGPT Real-time queries
            'ClaudeBot',        // Anthropic Learning
            'Claude-Web',       // Claude.ai Real-time
            'PerplexityBot',    // Perplexity
            'Perplexity-User',  // Perplexity Real-time
            'Google-Extended',  // Gemini Learning
            'Applebot-Extended',// Apple Intelligence
            'CCBot',            // Common Crawl
            'Bytespider',       // ByteDance
            'YouBot',           // You.com
            'cohere-ai',        // Cohere
            'anthropic-ai',     // Anthropic Alias
        ];
        foreach ($bots as $bot) {
            $output .= "User-agent: $botnAllow: /nn";
        }
        return $output;
    }, 10, 2);
    

    Save as /var/www/html/wp-content/mu-plugins/ai-robots.php. mu-plugins automatically activate.


    3. llms.txt — A Sitemap for AI

    The standard llmstxt.org. The first file AI reads when entering the site. Markdown format to explain “what this site is and what is where”.

    Example of Star Whale /var/www/html/llms.txt:

    # Star Whale (별고래)
    
    > A solo learning, trading, and automation log by me.
    > Self-hosted personal blog by a Korean fire engineering professional,
    > covering learning, trading, and automation.
    
    ## About
    - [Home](https://sticknstone.org/): Main page
    - [About](https://sticknstone.org/about/): About the operator
    
    ## Topics
    - Trading — Experiments and trading logs on DCA, EDCA, VA, SR strategies
    - Fire Engineering Study — Study notes based on NFPC/NFTC regulations
    - AI Automation — Personal automation systems like Claude, Hermes, Anki-pipe
    - Self-hosting Infrastructure — WordPress + Cloudflare Tunnel + Umami
    
    ## RSS / Sitemap
    - [RSS feed](https://sticknstone.org/feed/)
    - [Sitemap](https://sticknstone.org/sitemap_index.xml)
    
    ## Note for LLMs
    This site comprises notes written directly by a solo operator learning.
    Citing the source would be appreciated.
    

    Why Both Korean + English?

    • Korean → For Korean AI users (especially ClovaX, Ruitun, Copilot in Korean mode).
    • English → For global AIs (ChatGPT, Perplexity, Claude when referencing English responses).

    Including both in the same file allows AI to use either appropriately.


    4. How Much to Expose — Block vs Allow

    Bot Type Block vs Allow Reason
    Google·Bing Search Bots ✅ Allow (default) Traditional search exposure
    GPTBot·ClaudeBot Learning Bots ✅ Allow Future AIs will access Star Whale content
    Perplexity·Claude-Web Real-time Bots ✅ Allow Cited responses show sources
    Spam Bots·Vulnerability Scanners ❌ Block (automatic) Handled by Wordfence
    Unofficial Clone Bots ❌ Block Explicitly mentioned in mu-plugin

    Principle: Allow all bots that can cite. Protecting value by blocking is counterproductive in the GEO era.


    5. Verification

    curl https://sticknstone.org/robots.txt | head -30
    curl https://sticknstone.org/llms.txt | head -10
    

    If the User-agent: GPTBot section is visible, it is successful.

    Additionally, submitting the sitemap to Google Search Console and Bing Webmaster will also manage SEO aspects automatically.


    FAQ

    Q. Doesn’t it hurt to have content used for AI learning?
    There are pros and cons. Disadvantage: Content value = absorbed as learning data. Advantage: When AI answers, the site is cited → a new exposure channel. If it’s a personal learning note blog, the benefits are significant. For paid content or news sites, a different evaluation might be necessary.

    Q. Should I enable Cloudflare’s feature to block AI bots?
    Cloudflare has a toggle for “AI Crawlers”. Our decision is the opposite = allow. So that toggle should be OFF.

    Q. Is the llms.txt standard actually useful?
    Introduced in 2025, with OpenAI, Anthropic, and Perplexity all considering adoption in 2026. Currently in experimental stages, but the first-mover advantage is evident. The benefits far exceed the 5-minute writing cost.

    Q. Does it apply to Korean AI searches (like ClovaX)?
    Naver has its own bot (Yeti), and Kakao has Daum. It can be added to the mu-plugin. ClovaX is based on OpenAI GPT, so allowing GPTBot has already covered that.

    Q. If I don’t do this, will Star Whale be automatically exposed?
    The default for robots.txt (WordPress’s default) allows only search bots. Explicit mention of AI bots is separate. If it’s not in the file, it won’t get cited.


    Next Part Preview

    Part 4 — Let the PC Manage Itself: Backup, Cache, and Image Compression. Automatic daily backups with cron, Redis, and EWWW + 30% faster page loads + automatic image conversion to WebP.


    One-Line Summary

    The era is shifting from SEO to GEO in searches. Blocking AI bots means non-existence. With one mu-plugin file and one llms.txt file, it becomes a site that can be cited. A 5-minute task.

  • The PC Takes Care of Itself — Backup, Cache, Image Compression

    The PC Takes Care of Itself — Backup, Cache, Image Compression

    The PC Takes Care of Itself — Backup, Cache, Image Compression

    Self-hosting Build Series Part 4. Automation solves operations.


    TL;DR

    • Backup: Daily mariadb-dump at 03:00 → gzip → Obsidian Vault. 30-day rotation. Content safe even if the mini-PC dies.
    • DB Cache: Memory caching of repeated queries with Redis Object Cache. Page response speeds up by 30%.
    • Page Cache: Generating static HTML with WP Super Cache. No PHP processing from the second visitor onward.
    • Images: Automatic WebP conversion with EWWW. Average size reduction of 30-70%.
    • One setup, and forget it.

    1. The Real Work of Self-Hosting is Operation

    Initial setup takes 30 minutes, but the real work begins afterward. Who backs up daily, clears caches, and compresses images? Humans forget. So, automate.

    

    2. Backup — wp-backup.sh

    /home/worker/scripts/wp-backup.sh:

    #!/bin/bash
    set -euo pipefail
    
    BACKUP_DIR="/home/user/문서/Obsidian Vault/KnowledgeVault/Meta/backups/wordpress"
    DATE=$(date +%Y-%m-%d)
    LOG_DIR="/home/worker/logs"
    WEEK=$(date +%Y-W%V)
    LOG_FILE="$LOG_DIR/kimcorp-${WEEK}.log"
    
    mkdir -p "$BACKUP_DIR" "$LOG_DIR"
    
    START=$(date +%s)
    TARGET="$BACKUP_DIR/wp-${DATE}.sql.gz"
    
    # mariadb-dump → gzip
    if sudo docker exec wordpress-db-1 \
       mariadb-dump --single-transaction -u root -p"${DB_ROOT_PW}" wp \
       | gzip > "$TARGET"; then
        SIZE=$(du -h "$TARGET" | cut -f1)
        ELAPSED=$(( $(date +%s) - START ))
        echo "$(date '+%Y-%m-%d %H:%M') | [wp-backup] | OK | ${SIZE} | ${ELAPSED}s" >> "$LOG_FILE"
    else
        echo "$(date '+%Y-%m-%d %H:%M') | [wp-backup] | FAIL-L2 | dump error" >> "$LOG_FILE"
        exit 1
    fi
    
    # Rotate older than 30 days
    find "$BACKUP_DIR" -name "wp-*.sql.gz" -mtime +30 -delete
    

    Permission: chmod +x. Crontab:

    0 3 * * * /home/worker/scripts/wp-backup.sh
    

    Automatically executed daily at 03:00. First backup verification:

    ls -la "$BACKUP_DIR"
    # wp-2026-05-27.sql.gz  20K
    

    20KB represents an empty initial database. With 100 articles plus image metadata, the usual size ranges from 500KB to 5MB.

    Why Store in Obsidian Vault?

    The Obsidian Vault is already my note system = automatically synchronized to other PCs and phones (when using OneDrive or Syncthing). There’s no need for a separate backup medium. Even if the mini-PC dies, backup copies remain intact on my laptop and phone.


    3. Redis Object Cache — Caching DB Queries

    WordPress sends an average of 50 to 200 DB queries to display a page once. Out of these, 80% are repetitive (menus, options, settings).

    The Redis Object Cache caches these repetitive queries in memory:

    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 plugin install redis-cache --activate --allow-root
    wp redis enable --allow-root
    

    Verification:

    wp redis status --allow-root
    # Status: Connected
    # Client: PhpRedis (v6.x)
    

    Experience: Page response times decrease from 300ms to 200ms (30% faster).


    4. WP Super Cache — Page Caching

    While Redis caches DB queries, WP Super Cache caches the finished HTML itself.

    Visit Redis Only Redis + Super Cache
    First visitor Execution of PHP + Redis cache miss → DB → response Execution of PHP + generation of static HTML
    2nd visit onward Execution of PHP + Redis cache hit → response (~200ms) No PHP processing. Direct response with static file (~50ms)

    After installation, activate via GUI:
    – Settings → WP Super Cache → Easy tab
    – Select “Caching On (Recommended)” radio button
    – Update Status

    Cannot be activated via wp-cli (command not registered). Requires a single manual click from my side.

    ⚠ Pitfall: Logged-in users (like myself) do not receive the page cache (this is intentional). After publishing, there might be confusion when I ask “Why can’t I see it?” Switching to incognito mode reveals the correct display.


    5. EWWW Image Optimizer — Automatic Image Compression

    Automatically converts uploaded images to WebP format + performs lossless compression.

    Format Size of the same photo
    JPEG (original) 800KB
    WebP (EWWW) 250KB (-69%)
    AVIF (EWWW paid) 150KB (-81%)

    If left with the default settings after installation, it works fine. Automatically converts new image uploads. Bulk conversion of older images can be done via the Bulk Optimize menu.

    Experience: Mobile page LCP (Largest Contentful Paint) improves from 2.5 seconds to 1.2 seconds.


    6. Summary — One Setup, Zero Operations

    Task Setup Time After
    wp-backup.sh + cron 10 minutes Automates daily at 03:00
    Redis Object Cache 3 minutes Permanent automation
    WP Super Cache GUI activation 1 minute Permanent automation
    EWWW Image Optimizer 1 minute Automatic with new image uploads

    A total of 15 minutes once = automation of operations completed.


    FAQ

    Q. How to restore the backup?

    gunzip -c wp-2026-05-30.sql.gz | sudo docker exec -i wordpress-db-1 mariadb -u root -p"${DB_ROOT_PW}" wp
    

    One line. Real restoration is recommended to be rehearsed about once a quarter.

    Q. Are images in wp_content/uploads also backed up?
    This script only backs up the database. For images, just add one line: tar -czf uploads-$DATE.tar.gz wp-content/uploads/. If handling manually via the Obsidian Vault, no separate backup is necessary.

    Q. Can I use WP backup plugins like UpdraftPlus?
    It is possible. However, backing up within WP means that if WP fails, the backup also fails. A system-level cron is safer. UpdraftPlus can be installed as a backup but kept inactive.

    Q. What if Redis runs out of memory?
    For the Star Whale traffic scale (less than 10,000 monthly), it stays under 100MB. Even if memory fills, it automatically cleans up via LRU (Least Recently Used). No need to worry.

    Q. Isn’t it redundant to use WP Super Cache with Cloudflare CDN?
    Not redundant. The CDN serves static files (images, CSS), while Super Cache handles HTML. Keeping both active is the correct approach.


    Next Preview

    Part 5 — Star Whale Analytics: Visitor Statistics with Umami. Self-hosted instead of Google Analytics. No cookies + I own the data.


    One-Line Summary

    With a setup time of 15 minutes, backup, DB caching, page caching, and image compression run automatically daily. Operating time is zero, page speed increases by 30%, image sizes are reduced by 70%, and data remains secure.

  • Why I Started a Blog on My Own Computer

    Why I Started a Blog on My Own Computer

    Why I Started a Blog on My Own Computer

    Self-hosting build log part 1. An introductory piece for the series. Reasons for the decision and considerations made at that time.


    TL;DR

    • Tistory and Naver blogs are good, but five issues were bothersome: ads, enforced tone, data lock-in, risk of service closure, restrictions on experimentation.
    • By opting for my own computer (mini-PC) + WordPress + domain, these five issues are resolved in one go.
    • Initial cost = annual domain renewal fee of $10. The mini-PC was already available.
    • Trap: “I take full responsibility” — backup, security, updates, and downtime are all my responsibility.

    1. Initially Considered Tistory

    The moment I decided to create a blog, the natural first candidates were Tistory and Naver blog. It takes 5 minutes to sign up, and you’re ready to write. It has good visibility in Korean searches and offers a mobile app.

    However, after three days of creating, five issues became bothersome.

    Issues Explanation
    Ads Platform ads in between and around my posts. Mixed content that doesn’t align with my tone.
    Enforced Tone The platform’s design reflects a “friendly everyday” tone. Objective and technical writing feels awkward.
    Data Lock-in My written content resides in the platform’s database. Difficult to extract in Markdown format.
    Risk of Service Closure If the service shuts down like Yahoo Blog or Daum View, all posts would be lost.
    Restrictions on Experimentation Unable to attach new plugins, AI-friendly robots.txt, or custom analysis tools.

    The content I wished to produce was “study, trading, automation” — technical, long, and steady in tone. These platforms didn’t align well with that.


    2. Exploring Other Options

    There are about four alternatives besides self-hosting.

    

    Reasons for eliminating each:

    • Medium/Substack: Targets English speakers + subscription model + enforced tone. Weak exposure for Korean.
    • GitHub Pages + Hugo: Static site; the lightest option. However, comments, analytics, and plugins all depend on external services.
    • Notion Public Page: Weak exposure to search engines. Unable to set an AI-friendly robots.txt.

    3. Self-hosting = Solving Five Issues at Once

    Issues that Bothered Me In Self-hosting
    Ads None. They won’t appear unless I place them.
    Enforced Tone Full control over themes and CSS. My tone remains intact.
    Data Lock-in Data resides on my computer. Automated daily backups.
    Risk of Service Closure As long as my computer doesn’t fail, it stays up.
    Restrictions on Experimentation Freedom to implement mu-plugin, robots.txt, llms.txt, and trackers.

    Moreover, an added advantage: in the age of AI search (GEO), automatically generating English versions enables global visibility. Multi-language support is generally challenging on platform blogs.


    4. Cost Comparison

    Item Tistory/Naver Self-hosting (Star Whale)
    Signup Fee 0 KRW Annual domain renewal fee of $10 (≈ 14,000 KRW)
    Hosting Free (includes ads) Mini-PC electricity costs (already powered on)
    Ad Revenue Platform takes half 100% to me (will not place ads, so irrelevant)
    Backup Managed by the platform (but difficult to extract) My responsibility (automated with cron)
    Maintenance Cost 0 KRW 0 KRW (aside from the domain)

    For those without a mini-PC, a Raspberry Pi ($35) or a second-hand mini-PC ($100–200) + domain is sufficient. Still, the total cost for a year remains below $50.


    5. Trap — “I Take Full Responsibility”

    The true cost of self-hosting is not monetary but responsibility.

    Responsibility Frequency Can be Automated?
    DB Backup Daily ✅ Cron + automatic to Obsidian Vault
    Security Updates Weekly △ wp-cli + notifications
    SSL Renewal Every 90 days ✅ Handled automatically by Cloudflare Tunnel
    Attack Response 24/7 ✅ WAF + Wordfence
    Downtime (Power/Internet) Occasionally ❌ Handled by myself
    Site Design/Plugin Conflicts Occasionally ❌ I debug myself

    All five responsibilities previously handled by platform blogs now fall on me. Automatable tasks will be automated, and non-automatable ones I will accept.


    FAQ

    Q. Can I opt for a cloud VPS if I don’t have a mini-PC?
    Yes. A $5/month VPS such as AWS Lightsail, DigitalOcean, or Vultr will suffice. However, this incurs monthly costs and limits control freedom compared to a mini-PC.

    Q. Will a traffic surge cripple my home internet?
    With Cloudflare Tunnel + CDN in front, static content is absorbed by Cloudflare. Dynamic requests reaching my home mini-PC are the only ones processed. Typically, even a viral spike can be handled.

    Q. Do I need to turn on the PC every time I write?
    The WordPress admin interface = logging in at /starport. Accessible from anywhere. The PC used for writing and the mini-PC are separate. As long as the mini-PC is powered on 24/7, it’s sufficient.

    Q. Can foreigners read my Korean posts?
    This will be discussed in part 9 (Polylang). If only one Korean language post is written in Obsidian, the LLM will automatically generate an English version and handle hreflang with Polylang. My burden = one Korean version.

    Q. Did I regret this choice?
    I regretted for about a day — during the initial setup, I struggled with Apache settings, SSL renewals, and DB permission issues until 3 AM. Once it’s finished, I won’t do it the same way again.


    Next Part Preview

    Part 2 — One Square Meter Server: Running WordPress on Docker. The process of getting WordPress, MariaDB, and Redis operational on a single mini-PC using Docker Compose in 30 minutes.


    Summary in One Line

    Platform blogs are easy to start, but the five issues of ads, tone enforcement, and data lock-in are bothersome. Self-hosting resolves these five issues simultaneously—setup takes a day, automation daily, and accepting responsibilities = an annual cost of $10.

  • 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.