↓ Skip to main content

How a programmer can start their own blog

·2045 words·10 mins
Alexander Sokolov aka s0k0l
Author
Alexander Sokolov aka s0k0l
I emulate user behavior, extract data and protect information. I take projects from idea to release.

Intro
#

The first thing you need is a server. Just google “Debian VPS” and pick from what comes up.

Important: if you’re in Russia, it’s better to choose a hosting provider and a server in your own country. Then search Yandex for “аренда виртуального сервера Debian”.

While the server is being set up, you have about an hour to buy a domain name and register a Cloudflare account. The first one is obvious, and CF is needed to hide your server’s IP address from site visitors. This definitely improves server security, but it has to be done before the domain is first pointed at the server. So after buying the domain, go to the control panel (where you bought it) and change the NS servers to the ones Cloudflare gives you after you add the project in your account. After that it takes some time (up to 3 days) for the domain to be re-parked, since the whole internet has to learn about it. But after that no visitor will be able to find out your site’s IP, unless you leak it, of course. We’ll talk about that some other time.

What you have now:

  1. the IP and root password
  2. a domain name parked at CF
  3. records set up in CF:
    1. yourdomain.com - IP - the root record, the domain name opens the blog home page
    2. git.yourdomain.com - IP - a subdomain for Gitea

Let’s go
#

Initial Debian server setup ->

Install Docker with

curl -fsSL https://get.docker.com -o install-docker.sh
sh install-docker.sh

Log out of the server, because now we need to prepare everything locally.

On your computer, install Hugo from the GitHub repository, because the Debian package repository has an old version.

https://github.com/gohugoio/hugo/releases

Install Git on your computer to work with the repository.

On the server we’ll run 2 applications in Docker:

  • git - Gitea, a git service, something like GitHub
  • hugo - the site builder and Caddy. Caddy serves the finished pages and is also the only entry point from outside, Gitea traffic goes through it too

Hugo builds blog pages from Markdown files, converting everything to HTML. The workflow looks like this:

  1. create an md file in the repository
  2. fill it in, commit and push
  3. the builder in Docker checks the repository once a minute and sees a new commit
  4. it builds the site and swaps the old version for the new one

Okay, now pick a place on your computer and create an apps folder, where we’ll keep the configuration of the Docker applications on the server. The final structure will look like this:

apps/
├── git/
│   └── docker-compose.yaml
└── hugo/
    ├── docker-compose.yaml
    ├── caddy.conf
    ├── dot_env
    ├── .gitignore
    └── data/
        └── builder/
            └── build.sh

There are only configs here. All data (repositories, certificates, the built site) lives in Docker named volumes, not in folders next to the configs. This way apps can safely be kept in git and copied to the server without fear of overwriting data. I’ll cover backups below.

Both applications talk over a shared web network. Only Caddy exposes ports to the outside.

git
#

apps/git/docker-compose.yaml:

# Git application for docker-server

services:
  gitea:
    image: gitea/gitea:28.0-rootless
    restart: unless-stopped
    environment:
      GITEA__server__DOMAIN: git.yourdomain.com
      GITEA__server__ROOT_URL: https://git.yourdomain.com/
      GITEA__server__DISABLE_SSH: "true"
      GITEA__database__DB_TYPE: sqlite3
      GITEA__service__DISABLE_REGISTRATION: "true"
    volumes:
      - data:/var/lib/gitea
      - config:/etc/gitea
    networks: [ web ]


networks:
  web:
    external: true


volumes:
  data:
  config:

We use the rootless image, so inside the container Gitea doesn’t run as root. This is where named volumes really fit: when creating a volume, Docker sets its owner from the image, and you don’t have to chown anything by hand.

We don’t expose any ports, only Caddy reaches Gitea over the web network. SSH is disabled because Cloudflare doesn’t proxy it, and we don’t want to expose the server’s IP. We’ll push over https. Registration is closed so nobody but you can create accounts there.

hugo
#

apps/hugo/docker-compose.yaml:

# Landing page and blog site on Hugo

services:
  builder:
    image: ghcr.io/gohugoio/hugo:v0.167.0
    restart: unless-stopped
    user: root
    entrypoint: [ "sh", "/build.sh" ]
    environment:
      REPO_URL: ${REPO_URL}
    volumes:
      - ./data/builder/build.sh:/build.sh:ro
      - src:/src
      - public:/public
    networks: [ web ]          # to reach git-gitea-1

  web:
    image: caddy:2.10.2-alpine
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./caddy.conf:/etc/caddy/Caddyfile:ro
      - public:/srv:ro
      - caddy_data:/data
      - caddy_config:/config
    networks: [ web ]          # to proxy to git-gitea-1


networks:
  web:
    external: true


volumes:
  src:
  public:
  caddy_data:
  caddy_config:

You don’t need to build your own image. builder is the official Hugo image, it already includes git. Use the same version as on your computer, you can check it with hugo version. user: root is needed because the image runs as a regular user by default, while Docker creates volumes as root.

web is Caddy. It faces the outside on 80 and 443, serves the site from the public volume shared with builder, and proxies the git subdomain to Gitea. It keeps its certificates in caddy_data.

apps/hugo/caddy.conf:

yourdomain.com {
	tls internal
	encode gzip
	root * /srv/live
	file_server

	handle_errors {
		rewrite * /404.html
		file_server
	}
}

www.yourdomain.com {
	tls internal
	redir https://yourdomain.com{uri} permanent
}

git.yourdomain.com {
	tls internal
	reverse_proxy git-gitea-1:3000
}

The site is served from /srv/live, why exactly from there will become clear from the build script. handle_errors makes non-existent pages show the theme’s nice 404 instead of an empty response. www simply redirects to the main domain so the site has a single address.

Docker Compose builds the name git-gitea-1 itself from the folder name and the service name: folder git, service gitea, first instance. That’s why it matters that the folder is named exactly like that.

tls internal means Caddy issues a certificate for itself. The browser never sees it, because visitors connect to Cloudflare, and Cloudflare connects to us. So in the Cloudflare panel, under SSL/TLS, set the mode to Full. Not Flexible, otherwise traffic from Cloudflare to the server goes unencrypted, and not Full (strict), which won’t accept such a certificate.

apps/hugo/dot_env is a template for the variables:

REPO_URL=http://git-gitea-1:3000/<name>/site.git

On the server you copy it to .env and fill in your username and repository. The .env itself doesn’t go into git, that’s what the .gitignore next to it with a single .env line is for. The builder clones the repository straight from the Gitea container over the internal network, without going out to the internet. If the repository is private, add a token: http://<name>:<token>@git-gitea-1:3000/<name>/site.git. The token is issued in the Gitea user settings, under Applications. In that case hide the file from prying eyes with chmod 600 .env.

apps/hugo/data/builder/build.sh:

#!/bin/sh
# Once a minute: new commit -> build into /public/next -> swap /public/live.
# Build error - the previous site stays.
# The repository address is taken from REPO_URL on every start: change in .env + redeploy = new source.
cd /src
if [ -d .git ]; then
	git remote set-url origin "$REPO_URL"
else
	git clone --recurse-submodules "$REPO_URL" . || exit 1
fi
last=''
while true; do
	if git fetch -q origin HEAD && git reset -q --hard FETCH_HEAD \
		&& git submodule sync -q --recursive && git submodule update -q --init --recursive; then
		rev=$(git rev-parse HEAD)
		if [ "$rev" != "$last" ]; then
			rm -rf /public/next
			if hugo --minify -d /public/next; then
				rm -rf /public/old
				[ -d /public/live ] && mv /public/live /public/old
				mv /public/next /public/live
				last=$rev
				echo "published $rev"
			else
				echo "build of $rev failed, site unchanged"
			fi
		fi
	fi
	sleep 60
done

The main trick of the script is that the site is built into a separate next folder, and only if the build succeeds does it replace the working live one. If you push a broken commit, the site simply stays as it was, and the log shows what broke. The previous version is kept in old in case you need to roll back quickly.

git reset --hard is used here on purpose instead of git pull. If you force push or rewrite history, a regular pull will break, while reset just takes whatever is in the repository. And set-url at startup lets you change the source: edit REPO_URL in .env, restart the container, and the builder pulls from the new place.

Uploading to the server
#

The configs are ready, let’s send the folder to the server. <name> is the host name from ~/.ssh/config that we set up in the previous article:

s0k0l:~$ scp -r apps <name>:/opt/

Before starting, we need to open the firewall for the site. In the previous article we closed all incoming traffic, and I warned there that Docker lives by its own rules. Let’s sort it out now.

Open /etc/nftables.conf on the server. In the input chain, uncomment the line for the site:

		tcp dport { 80, 443 } accept

And change the forward chain to this:

	chain forward {
		type filter hook forward priority 0; policy drop;

		ct state established,related accept
		ct status dnat accept
		iifname "docker0" accept
		iifname "br-*" accept
	}
}

The thing is, traffic to containers goes not through input but through forward. With our strict policy drop, containers can neither accept connections nor reach the internet. These rules let through traffic to the ports Docker published (in our case only Caddy’s 80 and 443) and outgoing traffic from the containers themselves. Everything else is still closed.

Check and apply the same way as last time, with a timer just in case:

nft -c -f /etc/nftables.conf
(sleep 120 && nft flush ruleset) &
nft -f /etc/nftables.conf

Check that ssh is alive, cancel the timer with kill %1 and restart Docker:

systemctl restart docker

This is mandatory. Our config starts with flush ruleset, which also wipes the rules Docker created for itself. After a restart Docker creates them again. Remember this: every time you restart nftables, restart Docker too.

Launch
#

Create the shared network through which the containers will see each other:

docker network create web

Bring up Gitea and, for now, only Caddy without the builder. The builder has nothing to clone until there’s a repository in Gitea:

cd /opt/apps/git && docker compose up -d
cd /opt/apps/hugo && cp dot_env .env && docker compose up -d web

Open https://git.yourdomain.com in the browser. Gitea will show the initial setup page, the database is already set in the config. At the bottom of the page, in the administrator account section, create your user. We closed registration, so this is the only way to get an account.

Create a site repository in Gitea. Now push the site there from your computer:

s0k0l:~$ cd my-blog
s0k0l:~$ git remote add origin https://git.yourdomain.com/<name>/site.git
s0k0l:~$ git push -u origin main

If the theme is included as a git submodule, the theme repository must also be reachable by the builder at the URL in .gitmodules. The easiest way is to make a mirror of the theme in your Gitea and put its address in .gitmodules.

Put your real username into /opt/apps/hugo/.env and start the builder:

cd /opt/apps/hugo && docker compose up -d

Check that it built the site:

docker logs -f hugo-builder-1

If published <commit hash> shows up in the log, open https://yourdomain.com, the blog is working.

Backups
#

Since the data lives in named volumes, you can’t see it in apps. You can list them with docker volume ls. Two of them matter here: git_data and git_config, they hold all the repositories and Gitea settings. The site doesn’t need backing up, it’s built from the repository, and Caddy will issue certificates again.

Back up Gitea:

docker compose -f /opt/apps/git/docker-compose.yaml stop
docker run --rm -v git_data:/data -v git_config:/config -v /root:/backup alpine \
  tar czf /backup/gitea-$(date +%F).tgz /data /config
docker compose -f /opt/apps/git/docker-compose.yaml start

We stop Gitea during the backup so the SQLite database isn’t written halfway. Pull the finished archive to your machine with scp.

How to write now
#

The whole process looks like this:

  1. create a post with hugo new content blog/my-post/index.md
  2. write it and preview it locally with hugo server
  3. commit and push
  4. a minute later the post is on the site

For convenience I added a Makefile to the repository so I don’t have to type the commands by hand:

all:
	hugo new content $(path)

publish:
	git add .
	git commit -m 'chore: $(msg)'
	git push

A new post is make path=blog/my-post/index.md, publishing is make publish msg="new post".

That’s it. A server, your own git, a blog and auto-deploy, all on one inexpensive VPS with no third-party services except Cloudflare.