--- title: "Gitea: Ultralight Self-Hosted Git Server" # Title of the blog post. date: 2022-07-22 # Date of post creation. lastmod: 2023-01-19 description: "Deploying the lightweight Gitea Git server on Oracle Cloud's free Ampere Compute." featured: false # Sets if post is a featured post, making appear on the home page side bar. draft: false # Sets whether to render this page. Draft of true will not be rendered. toc: true # Controls if a table of contents should be generated for first-level links automatically. usePageBundles: true # menu: main # featureImage: "file.png" # Sets featured image on blog post. # featureImageAlt: 'Description of image' # Alternative text for featured image. # featureImageCap: 'This is the featured image.' # Caption (optional). thumbnail: "gitea-logo.png" # Sets thumbnail image appearing inside card on homepage. # shareImage: "share.png" # Designate a separate image for social media sharing. codeLineNumbers: false # Override global value for showing of line numbers within code block. categories: Self-Hosting tags: - caddy - linux - docker - cloud - tailscale - selfhosting comments: true # Disable comment if false. --- I recently started using [Obsidian](https://obsidian.md/) for keeping notes, tracking projects, and just generally organizing all the information that would otherwise pass into my brain and then fall out the other side. Unlike other similar solutions which operate entirely in *The Cloud*, Obsidian works with Markdown files stored in a local folder[^sync], which I find to be very attractive. Not only will this allow me to easily transfer my notes between apps if I find something I like better than Obsidian, but it also opens the door to using `git` to easily back up all this important information. Some of the contents might be somewhat sensitive, though, and I'm not sure I'd want to keep that data on a service outside of my control. A self-hosted option would be ideal. Gitlab seemed like an obvious choice, but the resource requirements are a bit higher than would be justified by my single-user use case. I eventually came across [Gitea](https://gitea.io/), a lightweight Git server with a simple web interface (great for a Git novice like myself!) which boasts the ability to run on a Raspberry Pi. This sounded like a great candidate for running on an [Ampere ARM-based compute instance](https://www.oracle.com/cloud/compute/arm/) in my [Oracle Cloud free tier](https://www.oracle.com/cloud/free/) environment! In this post, I'll describe what I did to get Gitea up and running on a tiny ARM-based cloud server (though I'll just gloss over the cloud-specific configurations), as well as how I'm leveraging [Tailscale](/secure-networking-made-simple-with-tailscale/) to enable SSH Git access without having to expose that service to the internet. I based the bulk of this on the information provided in Gitea's [Install With Docker](https://docs.gitea.io/en-us/install-with-docker/) documentation. [^sync]: Obsidian *does* offer a paid [Sync](https://obsidian.md/sync) plugin for keeping the content on multiple devices in sync, but it's somewhat spendy at $10 month. And much of the appeal of using a Markdown-based system for managing my notes is being in full control of the content. Plus I wanted an excuse to build a git server. ### Create the server I'll be deploying this on a cloud server with these specs: | | | |------------------|-----------------------| | Shape | `VM.Standard.A1.Flex` | | Image | Ubuntu 22.04 | | CPU Count | 1 | | Memory (GB) | 6 | | Boot Volume (GB) | 50 | I've described the [process of creating a new instance on OCI in a past post](/federated-matrix-server-synapse-on-oracle-clouds-free-tier/#instance-creation) so I won't reiterate that here. The only gotcha this time is switching the shape to `VM.Standard.A1.Flex`; the [OCI free tier](https://docs.oracle.com/en-us/iaas/Content/FreeTier/freetier_topic-Always_Free_Resources.htm) allows two AMD Compute VMs (which I've already used up) as well as *up to four* ARM Ampere A1 instances[^free_ampere]. [^free_ampere]: The first 3000 OCPU hours and 18,000 GB hours per month are free, equivalent to 4 OCPUs and 24 GB of memory allocated however you see fit. ### Prepare the server Once the server's up and running, I go through the usual steps of applying any available updates: ```shell sudo apt update # [tl! .cmd:1] sudo apt upgrade ``` #### Install Tailscale And then I'll install Tailscale using their handy-dandy bootstrap script: ```shell curl -fsSL https://tailscale.com/install.sh | sh # [tl! .cmd] ``` When I bring up the Tailscale interface, I'll use the `--advertise-tags` flag to identify the server with an [ACL tag](https://tailscale.com/kb/1068/acl-tags/). ([Within my tailnet](/secure-networking-made-simple-with-tailscale/#acls)[^tailnet], all of my other clients are able to connect to devices bearing the `cloud` tag but `cloud` servers can only reach back to other devices for performing DNS lookups.) ```shell sudo tailscale up --advertise-tags "tag:cloud" # [tl! .cmd] ``` [^tailnet]: [Tailscale's term](https://tailscale.com/kb/1136/tailnet/) for the private network which securely links Tailscale-connected devices. #### Install Docker Next I install Docker and `docker-compose`: ```shell sudo apt install ca-certificates curl gnupg lsb-release # [tl! .cmd:2] curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /usr/share/keyrings/docker-archive-keyring.gpg echo \ "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/docker-archive-keyring.gpg] https://download.docker.com/linux/ubuntu \ $(lsb_release -cs) stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null sudo apt update # [tl! .cmd:1] sudo apt install docker-ce docker-ce-cli containerd.io docker-compose docker-compose-plugin ``` #### Configure firewall This server automatically had an iptables firewall rule configured to permit SSH access. For Gitea, I'll also need to configure HTTP/HTTPS access. [As before](/federated-matrix-server-synapse-on-oracle-clouds-free-tier/#firewall-configuration), I need to be mindful of the explicit `REJECT all` rule at the bottom of the `INPUT` chain: ```shell sudo iptables -L INPUT --line-numbers # [tl! .cmd] Chain INPUT (policy ACCEPT) # [tl! .nocopy:8] num target prot opt source destination 1 ts-input all -- anywhere anywhere 2 ACCEPT all -- anywhere anywhere state RELATED,ESTABLISHED 3 ACCEPT icmp -- anywhere anywhere 4 ACCEPT all -- anywhere anywhere 5 ACCEPT udp -- anywhere anywhere udp spt:ntp 6 ACCEPT tcp -- anywhere anywhere state NEW tcp dpt:ssh 7 REJECT all -- anywhere anywhere reject-with icmp-host-prohibited ``` So I'll insert the new rules at line 6: ```shell sudo iptables -I INPUT 6 -m state --state NEW -p tcp --dport 80 -j ACCEPT # [tl! .cmd:1] sudo iptables -I INPUT 6 -m state --state NEW -p tcp --dport 443 -j ACCEPT ``` And confirm that it did what I wanted it to: ```shell sudo iptables -L INPUT --line-numbers # [tl! focus .cmd] Chain INPUT (policy ACCEPT) # [tl! .nocopy:10] num target prot opt source destination 1 ts-input all -- anywhere anywhere 2 ACCEPT all -- anywhere anywhere state RELATED,ESTABLISHED 3 ACCEPT icmp -- anywhere anywhere 4 ACCEPT all -- anywhere anywhere 5 ACCEPT udp -- anywhere anywhere udp spt:ntp 6 ACCEPT tcp -- anywhere anywhere state NEW tcp dpt:https # [tl! focus:1] 7 ACCEPT tcp -- anywhere anywhere state NEW tcp dpt:http 8 ACCEPT tcp -- anywhere anywhere state NEW tcp dpt:ssh 9 REJECT all -- anywhere anywhere reject-with icmp-host-prohibited ``` That looks good, so let's save the new rules: ```shell sudo netfilter-persistent save # [tl! .cmd] run-parts: executing /usr/share/netfilter-persistent/plugins.d/15-ip4tables save # [tl! .nocopy:1] run-parts: executing /usr/share/netfilter-persistent/plugins.d/25-ip6tables save ``` {{% notice note "Cloud Firewall" %}} Of course I will also need to create matching rules in the cloud firewall, but I'm going not going to detail [those steps](/federated-matrix-server-synapse-on-oracle-clouds-free-tier/#firewall-configuration) again here. And since I've now got Tailscale up and running I can remove the pre-created rule to allow SSH access through the cloud firewall. {{% /notice %}} ### Install Gitea I'm now ready to move on with installing Gitea itself. #### Prepare `git` user I'll start with creating a `git` user. This account will be set as the owner of the data volume used by the Gitea container, but will also (perhaps more importantly) facilitate [SSH passthrough](https://docs.gitea.io/en-us/install-with-docker/#ssh-container-passthrough) into the container for secure git operations. Here's where I create the account and also generate what will become the SSH key used by the git server: ```shell sudo useradd -s /bin/bash -m git # [tl! .cmd:1] sudo -u git ssh-keygen -t ecdsa -C "Gitea Host Key" ``` The `git` user's SSH public key gets added as-is directly to that user's `authorized_keys` file: ```shell sudo -u git cat /home/git/.ssh/id_ecdsa.pub | sudo -u git tee -a /home/git/.ssh/authorized_keys # [tl! .cmd:1] sudo -u git chmod 600 /home/git/.ssh/authorized_keys ``` When other users add their SSH public keys into Gitea's web UI, those will get added to `authorized_keys` with a little something extra: an alternate command to perform git actions instead of just SSH ones: ```text command="/usr/local/bin/gitea --config=/data/gitea/conf/app.ini serv key-1",no-port-forwarding,no-X11-forwarding,no-agent-forwarding,no-pty ``` {{% notice note "Not just yet" %}} No users have added their keys to Gitea just yet so if you look at `/home/git/.ssh/authorized_keys` right now you won't see this extra line, but I wanted to go ahead and mention it to explain the next step. It'll show up later. I promise. {{% /notice %}} So I'll go ahead and create that extra command: ```shell # [tl! .cmd:1,1] cat < ignoreregex = ``` Next I create the jail, which tells Fail2ban what to do: ```shell sudo vi /etc/fail2ban/jail.d/gitea.conf # [tl! .cmd] ``` ```ini # torchlight! {"lineNumbers": true} # /etc/fail2ban/jail.d/gitea.conf [gitea] enabled = true filter = gitea logpath = /opt/gitea/data/gitea/log/gitea.log maxretry = 5 findtime = 3600 bantime = 86400 action = iptables-allports ``` This configures Fail2ban to watch the log file (`logpath`) inside the data volume mounted to the Gitea container for messages which match the pattern I just configured (`gitea`). If a system fails to log in 5 times (`maxretry`) within 1 hour (`findtime`, in seconds) then the offending IP will be banned for 1 day (`bantime`, in seconds). Then I just need to enable and start Fail2ban: ```shell sudo systemctl enable fail2ban # [tl! .cmd:1] sudo systemctl start fail2ban ``` To verify that it's working, I can deliberately fail to log in to the web interface and watch `/var/log/fail2ban.log`: ```shell sudo tail -f /var/log/fail2ban.log # [tl! .cmd] 2022-07-17 21:52:26,978 fail2ban.filter [36042]: INFO [gitea] Found ${MY_HOME_IP}| - 2022-07-17 21:52:26 # [tl! .nocopy] ``` Excellent, let's now move on to creating some content. ### Work with Gitea #### Mirror content from GitHub As an easy first sync, I'm going to simply link a new repository on this server to an existing one I have at GitHub, namely [this one](https://github.com/jbowdre/vrealize) which I'm using to track some of my vRealize work. I'll set this up as a one-way mirror so that it will automatically pull in any new upstream changes but new commits made through Gitea will stay in Gitea. And I'll do that by clicking the **+** button at the top right and selecting **New Migration**. ![New migration menu](new_migration.png) Gitea includes support for easy migrations from several content sources: ![Migration sources](migration_sources.png) I pick the GitHub one and then plug in the details of the GitHub repo: ![Migrating from GitHub](migrate_github.png) And after just a few moments, all the content from my GitHub repo shows up in my new Gitea one: ![Mirrored repo](mirrored_repo.png) You might noticed that I unchecked the *Make Repository Private* option for this one, so feel free to browse the mirrored repo at https://git.bowdre.net/vPotato/vrealize if you'd like to check out Gitea for yourself. #### Create a new repo The real point of this whole exercise was to sync my Obsidian vault to a Git server under my control, so it's time to create a place for that content to live. I'll go to the **+** menu again but this time select **New Repository**, and then enter the required information: ![New repository](new_repository.png) Once it's created, the new-but-empty repository gives me instructions on how I can interact with it. Note that the SSH address uses the special `git.tadpole-jazz.ts.net` Tailscale domain name which is only accessible within my tailnet. ![Empty repository](empty_repo.png) Now I can follow the instructions to initialize my local Obsidian vault (stored at `~/obsidian-vault/`) as a git repository and perform my initial push to Gitea: ```shell cd ~/obsidian-vault/ # [tl! .cmd:5] git init git add . git commit -m "initial commit" git remote add origin git@git.tadpole-jazz.ts.net:john/obsidian-vault.git git push -u origin main ``` And if I refresh the page in my browser, I'll see all that content which has just been added: ![Populated repo](populated_repo.png) ### Conclusion So now I've got a lightweight, web-enabled, personal git server running on a (free!) cloud server under my control. It's working brilliantly in conjunction with the community-maintained [obsidian-git](https://github.com/denolehov/obsidian-git) plugin for keeping my notes synced across my various computers. On Android, I'm leveraging the free [GitJournal](https://play.google.com/store/apps/details?id=io.gitjournal.gitjournal) app as a simple git client for pulling the latest changes (as described [on another blog I found](https://orth.uk/obsidian-sync/#clone-the-repo-on-your-android-phone-)).