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 a platform to deploy my Hugo blog and a DNS to keep the domain name human-readable.

What about version control and continuous delivery? For instance, I want the platform to automatically pull the latest blog commit and deploy it. I can definitely accomplish this with services such as Heroku and Github. But isn’t that too simple?

I also wanted the option to create subdomains in which I can deploy other useful softwares in case I want to escape the Google ecosystem.

The first piece of software happened to be Gitea, which is a lightweight Git service. Don’t get me wrong; I have nothing against Github. But if I decided to go for the self-host life, why not use Git too?

Plus, I know I’m going to learn a lot from doing all of this from scratch. And indeed I did. I never regretted spending several weeks working on this.

The Services

  • Blog: Hugo
  • Mail: ProtonMail
  • Cloud: DigitalOcean
  • Domain: name.com
  • Git: Gitea

Summaries of documented steps

  • Created a Droplet on DigitalOcean.
  • Set up and deployed my Hugo blog locally.
  • Created a Domain on name.com.
  • Setup email domains for ProtonMail in name.com.
  • Setup DNS forwarding in DigitalOcean and name.com.
  • Set up Nginx as a reverse proxy in Docker.
  • Set up Hugo blog in Docker.
  • Set up Gitea in Docker.

Goals

  • Want a self-hosted Git for code version control.
  • Want a Hugo blog stored on VPS, managed by the VPS’s Gitea service.
  • Want to containerize all the services: Nginx, Hugo blog, Gitea, etc.
  • Have a subdomain for Gitea.

Architecture overview

At first, I was wondering whether if I should install the software packages directly on my Droplet. I had the option to have Gitea installed directly on my Debian droplet. That would be a much simpler way to solve the problem I had in mind. But I figured what if at some points I want to remove Gitea and all of its dependencies? From my experience with Linux, I know that installing and removing software can clutter my machine with unnecessary packages. There also comes with the problem of keeping every service updated on my Linux machine. That’s another dependency nightmare if anything were to break.

This led me to the decision to virtualize all the software and services that I was going to use. So here we have Docker and the diagram above.

Setting up Gitea on DigitalOcean droplet

Since Docker is pre-installed on DigitalOcean droplet, I only need to pull the Docker images. I found the process of creating and pulling Docker images to be cumbersome. And I knew that I would repeatedly download and remove different containers as I experimented with Docker. That’s another pain I did not want to go through. Thus, I decided to use Docker Compose, which allows users to define the services they want to use and spin them up with a single command.

Installing Gitea as a Docker container

  1. Creating the Git user

Because users use SSH for accessing the repositories on Gitea, I need to create a git user on the host machine.

sudo adduser --system --shell /bin/bash --gecos 'Git Version Control' --group --disabled-password --home /home/git git

This command creates a system user that use bash as its shell but does not have a login password and sets the user’s home directory to /home/git. Here’s the sample output

Output
Adding system user `git' (UID 114) ...
Adding new group `git' (GID 108) ...
Adding new user `git' (UID 114) with group `git' ...
Creating home directory `/home/git' …

Note that the UID and GID here would be used in Docker Compose step.

  1. Installing the Gitea Docker image.
version: "3"

networks:
  gitea:
    external: false

services:
  server:
    image: gitea/gitea:1.18.0
    container_name: gitea
    environment:
      - USER_UID=UID_from_step_1
      - USER_GID=GID_from_step_1
    restart: always
    networks:
      - gitea
    volumes:
      - ./gitea:/data
      - /home/git/.ssh/:/data/git/.ssh
      - /etc/timezone:/etc/timezone:ro
      - /etc/localtime:/etc/localtime:ro
    ports:
      - "127.0.0.1:8080:3000"
      - "127.0.0.1:2221:22"

Here’s a short walkthrough of what this file does

  • version "3": Specifies the version of the config file for Docker Compose.
  • networks: this section declares the networking setup of a collection of containers. In this case, a gitea network is created, but is not exposed externally.
  • services
    • image: gitea/gitea:1.18.0: this specifies that I will be using Gitea version 1.18.0; here are some useful release :1, :latest, or :dev.
    • environment: environment variables that will be available to the image during installation and running. In this case, the variables are the UID and GID we acquired above.
    • restart: always: Tells Docker to always restart the container if it goes down or the host machine goes down.
    • networks: the Gitea service will have access to and be accessible on the network named above.
    • ./gitea:/data and /home/git/.ssh/:/data/git/.ssh: these are the locations where Gitea will store its repositories and related data. Currently, this is mapped to the folder named gitea in the current directory. Docker will create this folder when the container starts if it doesn’t exist. The .ssh folder will be needed later.
    • /etc/timezone and /etc/localtime: these two files contain information about the timezone and time on the host machine. By mapping these directly into the container as read-only files, the container will have the same information as the host.
    • ports: Gitea listens for connections on two ports. It listens for HTTP connections on port 3000, where it serves the web interface for the source code repository, and it listens for SSH connections on port 22. In this case, I am keeping port 8080 for HTTP connections by mapping it to port 3000. Also, I am mapping the port on Gitea’s container from the usual 22 to 2221 to avoid port-clashing. In Step 6, I need to set up an SSH shim to direct traffic to Gitea when requested.

Save the text file. In the same folder, shoot up this command:

docker compose up -d

The -d option is for Docker Compose to run in detached mode.

  1. Installing Nginx as a Reverse Proxy.

Since I was going to be running multiple services on a single VPS, I needed a way to reroute all the traffics to specific ports.

It is a common practice to run web services behind a reverse proxy. Modern software such as Apache or Nginx can easily handle multiple services on a single machine and handle SSL. This also allows us to set up a domain name pointing to the Gitea instance running on HTTP(S) ports.

As I have said in the beginning, I opted to containerize every software packages with Docker to keep the system clean. I used the pre-built Nginx Docker Image. I updated docker-compose.yml with the following content

  nginx:
    image: nginx:latest
    restart: always
    logging:
      driver: syslog
      options:
        tag: "{{.DaemonName}}(image={{.ImageName}};name={{.Name}};id={{.ID}})"
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./nginx/conf/:/etc/nginx/conf.d/:ro
      - ./certbot/www/certbot_main:/var/www/certbot_main/:ro
      - ./certbot/www/certbot_code:/var/www/certbot_code/:ro
      - ./certbot/conf/:/etc/nginx/ssl/:ro
      - ./2023-blog/public:/var/www/blog/:ro
    network_mode: host
    ipc: host
    command: /bin/sh -c "nginx -g 'daemon off;'"

Couple of explanation and notes:

  • Use port 443 for SSL and 80 for HTTP.
  • Map the ./nginx/conf/ to /etc/nginx/conf.d/ using read-only flag.
  • Map the /.certbot/www/cerbot_main:/var/www/certbot_main/ using read-only flag. This is where the certfication for the main web service resides.
  • Map the /.certbot/www/certbot_code:/var/www/certbot_code/ using read-only flag. This is where the certification for the *Gitea service resides.
  • Map the /.certbot/conf/ to /etc/nginx/ssl/ for Nginx to locate the SSL configs.
  • Map ./2023-blog/public to /var/www/blog/. This is where Nginx will load the HTML files and serve them to the client.

Now, I need to create 2 configuration files for Nginx.

cd to ./nginx/conf. This is where the configuration files for Nginx are located. Create a .conf for Gitea. I named it io.minhdb.code.conf in this case.

server {
	listen 80;
  	listen [::]:80;

	server_name code.minhdb.io www.code.minhdb.io;
	server_tokens off;

	location /.well-known/acme-challenge/ {
		root /var/www/certbot_code/;
	}

	location / {
		return 301 https://$server_name$request_uri;
	}
}

server {
	listen 443 ssl http2;
	listen [::]:443 ssl http2;

	server_name code.minhdb.io;

	ssl_certificate /etc/nginx/ssl/live/code.minhdb.io/fullchain.pem;
	ssl_certificate_key /etc/nginx/ssl/live/code.minhdb.io/privkey.pem;

	location / {
		# ...
		proxy_pass http://127.0.0.1:8080;

		# Pass on information about the requests to the proxied service using headers
		proxy_set_header X-Forwarded-For $remote_addr;
        	proxy_set_header Host $http_host;
		#proxy_redirect off;
  		#proxy_set_header X-Forwarded-Proto $scheme;
   		#proxy_set_header X-Real-IP $remote_addr;
    		#proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
	}
}
  1. Installing MariaDB

I found an image of MariaDB on Docker Hub. I needed to hook it up to Docker Compose.

Created a folder mariadb in the same location as the docker compose file.

Here’s the Docker Compose setting for MariaDB.

  mariadb:
    image: mariadb
    restart: always
    environment:
      - MYSQL_ROOT_PASSWORD=[password for Gitea admin]
      - MYSQL_USER=[username for Gitea admin]
      - MYSQL_PASSWORD=[password for Gitea admin]
      - MYSQL_DATABASE=gitea
    networks:
      - gitea
    logging:
      driver: syslog
      options:
        tag: "{{.DaemonName}}(image={{.ImageName}};name={{.Name}};id={{.ID}})"
    volumes:
      - ./mariadb:/mariadb

Restart Docker Compose and spin up the container.

  1. Configure Gitea

Now, given that I had the correctly configured DNS in both DigitalOcean and domain name server, I could try to navigate to https://your_domain (in my case, it’s https://code.minhdb.io). Here’s a sample Gitea configuration screen.

Everything should be straight forward here, except for the email section which I skipped.

Current Docker Compose settings:

version: "3"

networks:
  gitea:
    external: false

services:
  server:
    image: gitea/gitea:1.18.0
    container_name: gitea
    environment:
      - USER_UID=108
      - USER_GID=114
    restart: always
    networks:
      - gitea
    volumes:
      - ./gitea:/data
      - /home/git/.ssh/:/data/git/.ssh
      - /etc/timezone:/etc/timezone:ro
      - /etc/localtime:/etc/localtime:ro
    ports:
      - "8080:3000"
      - "2221:22"

  hugo:
    image: klakegg/hugo:latest
    command: server
    volumes:
      - "./2023-blog/:/src"
    ports:
      - "1313:1313"
    stdin_open: true # docker run -i
    tty: true        # docker run -t

  nginx:
    image: nginx:latest
    restart: always
    logging:
      driver: syslog
      options:
        tag: "{{.DaemonName}}(image={{.ImageName}};name={{.Name}};id={{.ID}})"
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./nginx/conf/:/etc/nginx/conf.d/:ro
      - ./certbot/www/certbot_main:/var/www/certbot_main/:ro
      - ./certbot/www/certbot_code:/var/www/certbot_code/:ro
      - ./certbot/conf/:/etc/nginx/ssl/:ro
      - ./2023-blog/public:/var/www/blog/:ro
    network_mode: host
    ipc: host
    command: /bin/sh -c "nginx -g 'daemon off;'"

  certbot:
    image: certbot/certbot:latest
    volumes:
      - ./certbot/www/certbot_main:/var/www/certbot_main:rw
      - ./certbot/www/certbot_code:/var/www/certbot_code:rw
      - ./certbot/conf/:/etc/letsencrypt/:rw

  mariadb:
    image: mariadb
    restart: always
    environment:
      - MYSQL_ROOT_PASSWORD=[password for Gitea admin]
      - MYSQL_USER=[username for Gitea admin]
      - MYSQL_PASSWORD=[password for Gitea admin]
      - MYSQL_DATABASE=gitea
    networks:
      - gitea
    logging:
      driver: syslog
      options:
        tag: "{{.DaemonName}}(image={{.ImageName}};name={{.Name}};id={{.ID}})"
    volumes:
      - ./mariadb:/mariadb

Nginx configuration files

Gitea configuration file for Nginx reverse proxy

server {
	listen 80;
  	#listen [::]:80;

	server_name code.minhdb.io www.code.minhdb.io;
	#server_tokens off;
	location / {
		proxy_pass http://127.0.0.1:3000;

		# Pass on information about the requests to the proxied service using headers
        	proxy_set_header Host $host;
		proxy_redirect off;
  		#proxy_set_header X-Forwarded-Proto $scheme;
   		#proxy_set_header X-Real-IP $remote_addr;
    	#proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
	}
}

Hugo configuration file for Nginx reverse proxy

server {
	listen 80;
	server_name minhdb.io www.minhdb.io;

	root /var/www/blog/;

	location / {
		proxy_set_header Host $host;
		proxy_pass http://0.0.0.0:1313;
		proxy_redirect off;
		#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;
	}
}

Enabling SSL for my main domain and subdomain

Certbot commands

I want to test that everything is working by running 2 commands

For Gitea subdomain:

docker compose run --rm certbot certonly --webroot --webroot-path /var/www/certbot_code/ --dry-run -d code.minhdb.io

For main domain:

docker compose run --rm certbot certonly --webroot --webroot-path /var/www/certbot_main/ --dry-run -d minhdb.io

I should have zero problems running these commands. Now, I could create certificates for the server. I wanted to use these certificates in nginx to handle secure connections from users’ browsers.

Certbot create the certificates in the /etc/letsencrypt/ folder. I used volumes to share the files between containers.

// ...
  nginx:
    image: nginx:latest
    restart: always
    logging:
      driver: syslog
      options:
        tag: "{{.DaemonName}}(image={{.ImageName}};name={{.Name}};id={{.ID}})"
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./nginx/conf/:/etc/nginx/conf.d/:ro
      - ./certbot/www/certbot_main:/var/www/certbot_main/:ro
      - ./certbot/www/certbot_code:/var/www/certbot_code/:ro
      - ./certbot/conf/:/etc/nginx/ssl/:ro
      - ./minhdb/public:/var/www/blog/:ro
    network_mode: host
    ipc: host
    command: /bin/sh -c "nginx -g 'daemon off;'"

  certbot:
    image: certbot/certbot:latest
    volumes:
      - ./certbot/www/certbot_main:/var/www/certbot_main:rw
      - ./certbot/www/certbot_code:/var/www/certbot_code:rw
      - ./certbot/conf/:/etc/letsencrypt/:rw
// ...

Restarted my containers using docker compose restart. Nginx should now have access to the folders where Certbot creates the certificates. These folders were empty. Previously, I only ran certbot commands to test if there’s no issue in generating the certificates. I could run the actual commands to create the certificates, without the –dry-run flag:

docker compose run --rm certbot certonly --webroot --webroot-path /var/www/certbot_main/ -d minhdb.io

docker compose run --rm certbot certonly --webroot --webroot-path /var/www/certbot_code/ -d code.minhdb.io

Now that I had my certificates, all that’s left was to configure the 443 on nginx. Add the 443 block to nginx configuration files for each domain.

Nginx configuration for SSL

The certificates for my domains were up. It’s time to update the Nginx configuration files.

Here’s the updated configuration files to serve Nginx.

server {
     listen 80;
     listen [::]:80;

     server_name minhdb.io www.minhdb.io;
     server_tokens off;

     #root /var/www/blog/;

     location /.well-known/acme-challenge/ {
         root /var/www/certbot_main/;
     }

     location / {
         return 301 https://$server_name$request_uri;
     }
 }

 server {
     listen 443 ssl http2;
     listen [::]:443 ssl http2;

     server_name minhdb.io;

     ssl_certificate /etc/nginx/ssl/live/minhdb.io/fullchain.pem;
     ssl_certificate_key /etc/nginx/ssl/live/minhdb.io/privkey.pem;

     # root /var/www/blog/;

     location / {
         root /var/www/blog/;
         #proxy_pass http://0.0.0.0:1313;

         #proxy_set_header X-Forwarded-For $remote_addr;
         #proxy_set_header Host $http_host;
         #proxy_redirect off;
         #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;
     }
 }

Here’s the updated configuration file for Gitea

server {
     listen 80;
     listen [::]:80;

     server_name code.minhdb.io www.code.minhdb.io;
     server_tokens off;

     location /.well-known/acme-challenge/ {
         root /var/www/certbot_code/;
     }

     location / {
         return 301 https://$server_name$request_uri;
     }
 }

 server {
     listen 443 ssl http2;
     listen [::]:443 ssl http2;

     server_name code.minhdb.io;

     ssl_certificate /etc/nginx/ssl/live/code.minhdb.io/fullchain.pem;
     ssl_certificate_key /etc/nginx/ssl/live/code.minhdb.io/privkey.pem;

     location / {
         # ...
         proxy_pass http://127.0.0.1:8080;

         # Pass on information about the requests to the proxied service using headers
         proxy_set_header X-Forwarded-For $remote_addr;
             proxy_set_header Host $http_host;
         #proxy_redirect off;
         #proxy_set_header X-Forwarded-Proto $scheme;
         #proxy_set_header X-Real-IP $remote_addr;
             #proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
     }
 }

Renewing certificates

Certificates generated by Certbot and Let’s Encrypt only last for 3 months. I need to regularly renew them. To renew, run this command in Docker.

$ docker compose run --rm certbot renew

I can automate this process every 3 months.

References

Self-hosted git service

How to configure Nginx as a reverse proxy on Ubuntu 22.04

Hosting Multiple Websites with SSL using Docker, Nginx, and a VPS

How to deploy a Go web application with Docker and Nginx on Ubuntu 18.04

HTTPS using Nginx and Let’s encrypt in Docker