Couple thoughts on self-host
The idea came to me when I was exploring options to host my Hugo blog, a place for me to ramble.
I needed somewhere to deploy the blog and a domain name that would remain human-readable. Then I started thinking about version control and continuous delivery. I could have put the site on an established platform and called it a day. But isn't that too simple?
I also wanted room for subdomains and other useful software in case I wanted to escape parts of the Google ecosystem. The first piece happened to be Gitea, a lightweight Git service. I have nothing against GitHub, but if I was going for the self-host life, why not host Git too?
More importantly, I knew I would learn a lot by putting the pieces together myself. Indeed I did. I never regretted spending several weeks working on it, although the system looks quite different from my first attempt.
The services
- Blog: Hugo
- Git: Gitea
- Reverse proxy: Nginx
- Database: MariaDB
- Certificates: Certbot and Let's Encrypt
- Mail: Proton Mail
- Cloud: DigitalOcean
- Domain registrar: Name.com
The goals stayed simple: keep my code in a private Git service, build the Hugo site automatically, serve both sites over HTTPS, and keep every long-running application isolated in Docker.
Why Docker
At first I wondered whether I should install everything directly on the Droplet. Installing Gitea that way would have been simpler in the short term. But removing it later, keeping dependencies compatible, and adding more services would eventually clutter the host.
That led me to Docker Compose. Containers do not eliminate operational work, but they give each service a clear boundary and make the relationships visible in configuration.

What the deployment looks like now
The production request path is small:
Internet
|
v
Nginx on ports 80 and 443
| \
| \ serves static files
v v
Gitea on host port 8080 Published Hugo output
|
v
MariaDB on a private
Compose network
Gitea SSH is published separately on host port 2221.
Hugo and Certbot run only when a build or renewal is needed.
Nginx terminates TLS for both domains. It proxies code.minhdb.io to Gitea and serves the generated files for minhdb.io directly. Hugo's development server is not part of the production request path.
The configuration now lives in a private infrastructure-as-code repository. Runtime data, database files, certificates, backups, and passwords remain outside Git. That separation is more important than the exact directory names.
The current Compose model
The complete Compose file changes as services evolve, so duplicating every option here would make this article stale again. This sanitized excerpt records the important relationships:
name: docker
services:
server:
# Pin Gitea so recreating the container cannot cause an unplanned upgrade.
image: gitea/gitea:1.26.2
container_name: gitea
restart: always
environment:
# These IDs must match the owner of the persistent Gitea data.
USER_UID: "108"
USER_GID: "114"
# This is a private personal service rather than a public Git host.
GITEA__service__DISABLE_REGISTRATION: "true"
# Compose DNS resolves the stable service name. A container IP can
# change whenever MariaDB is recreated.
GITEA__database__DB_TYPE: mysql
GITEA__database__HOST: mariadb:3306
GITEA__database__NAME: gitea
GITEA__database__USER: gitea
# Read the password from a mounted file instead of embedding it in
# Compose, Git history, or the resolved environment.
GITEA__database__PASSWD__FILE: /run/secrets/gitea_database_password
# Gitea speaks HTTP internally; Nginx provides the public HTTPS origin.
GITEA__server__PROTOCOL: http
GITEA__server__DOMAIN: code.minhdb.io
GITEA__server__ROOT_URL: https://code.minhdb.io/
networks:
- gitea
volumes:
# /data contains Gitea configuration, repositories, and attachments.
- /opt/example-state/gitea:/data
- /etc/timezone:/etc/timezone:ro
- /etc/localtime:/etc/localtime:ro
ports:
# Only the host-networked Nginx proxy needs the HTTP listener.
- "127.0.0.1:8080:3000"
# Git clients use a separate public SSH port.
- "2221:22"
secrets:
- gitea_database_password
mariadb:
# Replace the placeholder with a deliberately tested version.
image: mariadb:<tested-version>
restart: always
environment:
MYSQL_ROOT_PASSWORD_FILE: /run/secrets/mariadb_root_password
MYSQL_USER: gitea
MYSQL_PASSWORD_FILE: /run/secrets/gitea_database_password
MYSQL_DATABASE: gitea
networks:
- gitea
volumes:
# Mount the existing production database at MariaDB's real data path.
- mariadb_data:/var/lib/mysql
secrets:
- mariadb_root_password
- gitea_database_password
nginx:
# Replace the placeholder with a deliberately tested version.
image: nginx:<tested-version>
restart: always
# Host networking lets Nginx bind ports 80/443 and reach Gitea through
# the host's loopback-only port 8080.
network_mode: host
volumes:
# Nginx reads configuration, certificates, and generated site files;
# it does not own or modify them.
- ./nginx/conf.d:/etc/nginx/conf.d:ro
- /opt/example-state/certbot/www/main:/var/www/certbot_main:ro
- /opt/example-state/certbot/www/code:/var/www/certbot_code:ro
- /opt/example-state/certbot/conf:/etc/nginx/ssl:ro
- /srv/example-blog/current:/var/www/minhdb.io:ro
hugo:
# Hugo is a one-off build tool, not a production web server.
image: klakegg/hugo:0.107.0
volumes:
- /opt/example-blog/source:/src
certbot:
# Certbot needs write access to challenge and certificate state.
image: certbot/certbot:<tested-version>
volumes:
- /opt/example-state/certbot/www/main:/var/www/certbot_main
- /opt/example-state/certbot/www/code:/var/www/certbot_code
- /opt/example-state/certbot/conf:/etc/letsencrypt
networks:
gitea:
volumes:
mariadb_data:
# Reuse the volume created by the original deployment. Without this,
# Compose could create a new empty database volume under a new name.
external: true
name: <existing-database-volume>
secrets:
# Secret values remain in restricted host files outside the repository.
mariadb_root_password:
file: /opt/example-secrets/mariadb-root-password
gitea_database_password:
file: /opt/example-secrets/gitea-database-password
The secret files are restricted on the host and never committed. The MariaDB volume is declared as external because it existed before the Compose configuration moved into its new repository. Before changing that mount or recreating the database, I make a native dump and verify the live volume. An unexpected empty database is often a mount problem, not lost data.
I also avoid publishing fully interpolated output from docker compose config or unfiltered docker inspect; both can reveal credentials.
Nginx for the Hugo site
The blog is static. Nginx reads the output created by CI instead of proxying to hugo server:
server {
listen 80;
listen [::]:80;
server_name minhdb.io;
server_tokens off;
location /.well-known/acme-challenge/ {
# Certbot writes HTTP-01 challenge files on the host; Nginx receives
# that directory through its read-only Compose mount.
root /var/www/certbot_main;
}
location / {
# Keep the challenge path on HTTP and redirect normal traffic.
return 301 https://minhdb.io$request_uri;
}
}
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name minhdb.io;
server_tokens off;
ssl_certificate /etc/nginx/ssl/live/minhdb.io/fullchain.pem;
ssl_certificate_key /etc/nginx/ssl/live/minhdb.io/privkey.pem;
# CI publishes into the host directory mounted here read-only.
root /var/www/minhdb.io;
index index.html;
location / {
# Support Hugo's directory-style URLs and return a real 404 when
# neither a file nor a directory exists.
try_files $uri $uri/ =404;
}
error_page 404 /404.html;
}
A server_name entry does not create DNS or add a name to a certificate. I include another hostname only after its DNS record exists and its certificate contains that Subject Alternative Name.
Nginx for Gitea
Gitea listens over HTTP behind the proxy. Its public origin remains HTTPS:
server {
listen 80;
listen [::]:80;
server_name code.minhdb.io;
server_tokens off;
location /.well-known/acme-challenge/ {
# Use a separate challenge directory for the Gitea hostname.
root /var/www/certbot_code;
}
location / {
return 301 https://code.minhdb.io$request_uri;
}
}
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name code.minhdb.io;
server_tokens off;
ssl_certificate /etc/nginx/ssl/live/code.minhdb.io/fullchain.pem;
ssl_certificate_key /etc/nginx/ssl/live/code.minhdb.io/privkey.pem;
# Permit the repository operations I expect while retaining a bound.
client_max_body_size 512m;
location / {
# Gitea listens over HTTP on the host; TLS terminates in Nginx.
proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1;
# Preserve the browser-facing host, scheme, client address, and proxy
# chain so Gitea generates correct links and records useful logs.
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Preserve protocol-upgrade requests used by compatible features.
proxy_set_header Connection $http_connection;
proxy_set_header Upgrade $http_upgrade;
}
}
The forwarded headers describe each incoming request. Gitea's ROOT_URL separately defines the canonical public origin used for clone links, webhooks, OAuth callbacks, and messages generated outside a request.
I validate Nginx before reloading it:
docker compose run --rm --no-deps nginx nginx -t
docker compose exec nginx nginx -t
docker compose exec nginx nginx -s reload
Builds and infrastructure changes
The blog and the infrastructure have separate pipelines.
A push to the blog's protected main branch builds Hugo, publishes the generated files, and verifies the public endpoint. The details belong in Deploying Hugo automatically with Gitea Actions and Building and serving a production Hugo site with Docker and Nginx.
Compose and Nginx changes go through a different private repository. Pull requests receive containerized validation but no production access. After a reviewed change reaches protected main, a repository-scoped host runner deploys the exact validated commit, reloads Nginx, and checks MariaDB, Gitea, and the blog. I documented that boundary in Deploying Docker and Nginx configuration with Gitea Actions.
This split keeps a blog post from recreating the database and keeps an infrastructure edit from rebuilding unrelated content.
Certificate renewal
Let's Encrypt certificates are short-lived, so a systemd timer asks Certbot to check every day. Certbot renews only certificates near expiration.
[Unit]
Description=Renew Let's Encrypt certificates with Docker Compose
After=network-online.target docker.service
Wants=network-online.target
Requires=docker.service
[Service]
Type=oneshot
WorkingDirectory=/opt/example-infrastructure
ExecStart=/usr/bin/docker compose run --rm certbot renew
ExecStartPost=/usr/bin/docker compose exec -T nginx nginx -t
ExecStartPost=/usr/bin/docker compose exec -T nginx nginx -s reload
[Timer]
OnCalendar=daily
RandomizedDelaySec=1h
Persistent=true
Unit=certbot-renew-docker.service
[Install]
WantedBy=timers.target
I test renewal with certbot renew --dry-run, inspect the service journal, and check the certificate presented by the public server. Looking only at a file on disk does not prove that Nginx loaded it.
What I verify
After a deployment, I check each layer rather than treating a running container as proof that the whole system works:
docker compose ps
docker compose logs --tail 100 mariadb
docker compose logs --tail 100 server
docker compose exec nginx nginx -t
docker compose exec nginx test -s /var/www/minhdb.io/index.html
curl --fail --location --output /dev/null https://minhdb.io/
curl --fail --location --output /dev/null https://code.minhdb.io/
ssh -T -p 2221 git@127.0.0.1
The HTTP checks cover the public path through TLS and Nginx. The local SSH check separates Gitea key authorization from cloud and host firewall problems.
What I learned
The first version proved that I could host these services myself. The current version is less exciting in the best possible way: configuration is reviewed, secrets and state stay outside Git, deployments are validated, and routine maintenance is automated.
This is still a self-host life. The interesting work is no longer getting the first container to start. It is making every later change reviewable, recoverable, and boring enough to trust.