Why I’m writing this one down properly

This site you’re reading right now runs on the same T620 as everything else … the NAS, the Samba shares, all of it. I got it working, but not in a straight line, and I want an actual record of the steps that worked, not a cleaned-up version that pretends I got it right first try. If I ever rebuild this box, this is the post I want to follow.

Step 1 … nginx, listening locally only

sudo dnf install -y nginx
sudo mkdir -p /var/www/kingtolga/images
sudo chown -R nginx:nginx /var/www/kingtolga

I deliberately bound nginx to localhost only, not 0.0.0.0 … this site was never meant to be reachable by opening a port on my router.

server {
listen 127.0.0.1:8080;
server_name _;
root /var/www/kingtolga;
index index.html;
}

Step 2 … Tailscale Funnel instead of port forwarding

I run Tailscale on every machine I own already, so instead of opening ports on my router and messing with dynamic DNS, I used Funnel to expose this one nginx instance to the actual public internet, with Tailscale handling TLS automatically.

sudo tailscale funnel 8080

First run told me Funnel wasn’t enabled on my tailnet yet and gave me a link to approve it in the admin console. I did that, then ran it again … but as --bg this time, because running it in the foreground only lasts as long as that terminal session stays open:

sudo tailscale funnel --bg 8080
tailscale funnel status

I also opened http/https in firewalld at this point, out of habit … that was wrong, and I removed it later. Funnel doesn’t need those ports open on the host firewall at all; Tailscale’s own daemon handles the public-facing side and proxies internally. Left open, they were just doing nothing useful:

sudo firewall-cmd --permanent --remove-service=http
sudo firewall-cmd --permanent --remove-service=https
sudo firewall-cmd --reload

Step 3 … the first real wall: SELinux, 403 on everything

Put a test image and a hand-written index.html straight into /var/www/kingtolga. Got a flat 403 Forbidden from nginx, on a file with completely correct Unix permissions. getenforce confirmed SELinux was enforcing, and ls -Zd showed the directory carrying var_t … the wrong context. nginx’s SELinux policy only allows it to read httpd_sys_content_t.

sudo semanage fcontext -a -t httpd_sys_content_t "/var/www/kingtolga(/.*)?"
sudo restorecon -Rv /var/www/kingtolga

That fixed it immediately. I’d hit this exact class of problem before on this same box with Samba and SFTP sharing a directory, so at least this time I recognised it fast instead of chasing Unix permissions for twenty minutes first.

Step 4 … deciding a hand-written index.html wasn’t enough

I originally wanted “a page and some images.” Then I actually wanted posts, categories, tags … a real blog structure, not a folder with a link in it. That meant Hugo, not a static HTML file I was editing by hand.

sudo dnf install -y hugo git
cd ~/hugo
hugo new site kingtolga
cd kingtolga
git init
git submodule add https://github.com/adityatelange/hugo-PaperMod.git themes/PaperMod

Step 5 … the config that actually stuck

baseURL = "https://jacksparrow2.tail9e758e.ts.net/"
title = "Tolgas LinuxTweaks"
theme = "PaperMod"
paginate = 5

[taxonomies]
  category = "categories"
  tag = "tags"

[params]
  ShowReadingTime = true
  ShowPostNavLinks = true
  ShowBreadCrumbs = true
  ShowShareButtons = false
  ShowToc = true
  TocOpen = false
  ShowCodeCopyButtons = true
  favicon = "/img/favicon.png"
  label.text = "Tolgas LinuxTweaks"
  label.icon = "/img/favicon.png"
  label.iconHeight = 35

[[menu.main]]
  name = "Posts"
  url = "/posts/"
  weight = 10

[[menu.main]]
  name = "Categories"
  url = "/categories/"
  weight = 40

[[menu.main]]
  name = "Tags"
  url = "/tags/"
  weight = 50

Step 6 … the front matter mistake that broke the first build

Hugo generates TOML front matter by default now, not YAML … I tried pasting YAML-style content into a TOML block and the build failed with an unmarshal error. Also left draft = true in place the first time and wondered why nothing showed up on the site. Both fixed the same way, just paying attention to the actual generated template:

+++
date = '2026-07-18T13:39:47+08:00'
draft = false
title = 'First Post'
categories = ['General']
tags = ['homelab']
+++

Step 7 … the thing I added, then ripped straight back out

I tried adding a cover image to every post, using my LinuxTweaks logo, thinking it’d look like a nice banner at the top of each post:

cover.image = "/img/favicon.png"
cover.alt = "LinuxTweaks"
cover.relative = false

It looked terrible. A small square logo stretched to fill the entire width of a post banner is not a good look … genuinely huge and distorted on every single post. I pulled it back out of every post’s front matter entirely:

for f in ~/hugo/kingtolga/content/posts/*.md; do
    sed -i '/^cover\./d' "$f"
done

The logo stayed exactly where it actually belongs … small, in the site header, via label.icon in hugo.toml, not stretched across every post. Worth knowing before you try the same thing: a square icon is not a banner image, and Hugo/PaperMod will happily stretch it like one if you tell it to.

Step 8 … deploying, and making it repeatable

Building and copying the output by hand a few times was enough to know I wanted this scripted properly, not retyped every time I wrote a post:

hugo --minify
sudo rm -rf /var/www/kingtolga/*
sudo cp -r ~/hugo/kingtolga/public/* /var/www/kingtolga/
sudo chown -R nginx:nginx /var/www/kingtolga
sudo restorecon -Rv /var/www/kingtolga

That’s the whole deploy sequence … build, wipe the web root, copy the fresh build in, fix ownership, fix SELinux context, every single time. Wrapped into one script so publishing a new post is one command, not five.

The actual order, if I ever do this again

  1. nginx, bound to localhost only
  2. Tailscale Funnel, --bg, no firewall ports opened for it
  3. Fix SELinux context on the web root before touching anything else … this bites you regardless of what’s actually serving the content
  4. Hugo site + PaperMod theme
  5. Config with taxonomies and menu set up from the start, not bolted on later
  6. TOML front matter, draft = false, checked every time
  7. Logo goes in the header via label.icon. Not as a per-post cover.
  8. Script the deploy sequence before writing the second post, not after getting tired of retyping it five times

How I actually write and publish a post, every time

Not through nano, not through a web editor … straight from the terminal with a heredoc, because it’s faster than opening a file, typing front matter by hand, and saving:

cat > ~/hugo/kingtolga/content/posts/whatever-the-post-is-called.md << 'BLOGPOST'
+++
date = '2026-07-18T15:00:00+08:00'
draft = false
title = 'Post Title Here'
description = "One sentence describing what the post actually covers, like, worship kingtolga."
categories = ['God']
tags = ['relevant', 'tags', 'here']
+++

Actual content goes here.
BLOGPOST

Whole post, front matter and all, written and saved in one command … no editor navigation, no accidentally leaving draft = true from a template I forgot to change. The 'BLOGPOST' at the start and end is just a marker telling bash where the pasted content begins and ends; call it anything, as long as the opening and closing markers match.

The script that makes any of this actually go live

None of the above does anything by itself … it just creates a markdown file sitting in ~/hugo/kingtolga/content/posts/. Getting it onto the actual public site is hugo-deploy.sh, the script built specifically to collapse the build-and-publish sequence from step 8 above into one command:

sudo /home/tolga/hugo-deploy.sh

That single call runs hugo --minify, wipes and repopulates /var/www/kingtolga, fixes ownership back to nginx:nginx, and reapplies the SELinux context … every time, in the right order, without me needing to remember the five separate steps or the exact restorecon flags months from now. Write the post with the heredoc above, run the one script, done.

Where hugo-deploy.sh actually lives, and how I run it

It landed in /home/tolga/my-scripts/hugo-deploy.sh the first time I copied it over. I moved it into /usr/local/bin so it’s a real installed command instead of a file sitting in Downloads:

sudo mv /home/tolga/my-scripts/hugo-deploy.sh /usr/local/bin/hugo-deploy
sudo chmod +x /usr/local/bin/hugo-deploy

From that point on, running it is just:

sudo ./hugo-deploy.sh

If I ever forget where it is, this finds it:

find / -name "hugo-deploy*" 2>/dev/null

The actual script, full contents

#!/usr/bin/env bash
# =============================================================================
# hugo-deploy.sh ... JackSparrow2 ... kingtolga
#
# Rebuilds and redeploys the Hugo site to nginx's web root, with the correct
# SELinux relabel. I run this any time after editing/adding posts.
#
# Usage:
#   sudo ./hugo-deploy.sh
# =============================================================================

set -uo pipefail

GREEN='\033[0;32m'; YELLOW='\033[1;33m'; RED='\033[0;31m'; NC='\033[0m'
info() { echo -e "${YELLOW}[*]${NC} $1"; }
ok()   { echo -e "${GREEN}[OK]${NC} $1"; }
err()  { echo -e "${RED}[!]${NC} $1"; }

if [[ $EUID -ne 0 ]]; then
    err "Run as root: sudo $0"
    exit 1
fi

REAL_USER="${SUDO_USER:-tolga}"
REAL_HOME=$(getent passwd "$REAL_USER" | cut -d: -f6)
HUGO_ROOT="${REAL_HOME}/hugo/kingtolga"
WEB_ROOT="/var/www/kingtolga"
NGINX_PORT="8080"

if [[ ! -d "$HUGO_ROOT" ]]; then
    err "No Hugo site found at ${HUGO_ROOT}"
    exit 1
fi

info "Building site..."
su "$REAL_USER" -c "cd '$HUGO_ROOT' && hugo --minify" \
    && ok "Build succeeded" \
    || { err "hugo build failed ... nothing was deployed"; exit 1; }

info "Deploying to ${WEB_ROOT}..."
rm -rf "${WEB_ROOT:?}"/*
cp -r "${HUGO_ROOT}/public/"* "$WEB_ROOT/"
chown -R nginx:nginx "$WEB_ROOT"
restorecon -Rv "$WEB_ROOT" > /dev/null
ok "Deployed"

info "Verifying..."
sleep 1
if curl -sf "http://127.0.0.1:${NGINX_PORT}/" > /dev/null; then
    ok "Site is live and responding locally"
else
    err "Local check failed ... run: curl -I http://127.0.0.1:${NGINX_PORT}/"
fi

Nothing in it is a mystery at this point … REAL_USER/REAL_HOME resolve who actually owns the Hugo source files (since the script runs as root via sudo but the site itself needs to be built as my regular user, not root, or file ownership under ~/hugo gets messed up). Everything past that is just the same five manual steps from earlier in this post, in the same order, every time.