Gitea
Deploying a self-hosted Git server, configuring SSH key auth, pushing the project, and establishing branch protection.
What I Will Be Completing In This Part
- Provision a dedicated VM for Gita and install Docker + Docker Compose
- Deploy Gitea with a PostgresSQL database backend using Docker Compose
- Complete the intial Gitea web configuration and create an admin account
- Generate an SSH key pair on
ansible-ctrland register it in Gitea - Create the
network-automation-labrepository in Gitea - Push the existing local Git history from Part 1 to the Gitea remote
- Configure branch protection on
mainwith required approvals
I created a dedicated VM for Gitea because I didn’t want to mix other services with it. The Git server is its own piece of infrastructure and it needs to be available even if the control node is being rebuilt.
Keeping Git and PostgresSQL off the Ansible control node means Ansible always has full access to CPU and memory when running large parallel playbook executions.
01 VM Specifications
OS: Ubuntu Server 22.04 LTS
Hostname: gitea
CPU: 2 vCPU
RAM: 2 GB
Disk: 30 GB
Network: 1 NIC (bridged to management network)As I did with the control node VM, I also assigned a static IP and added a DNS entry for this VM.
02 System Setup
After the OS installation, I ran system updates and installed Docker Engine and Docker Compose.
| |
- Line 1:
- Updates the package list and upgrades all installed packages.
- Line 2:
- Prerequisites for adding Docker’s GPG key and repository.
ca-certificatesandcurlhandle HTTPS,gnupghandles key verification.
| |
- Line 1:
- Created the
/etc/apt/keyringsdirectory for storing GPG keys. The-m 0755flag sets permissions so all users can read the directory. - Lines 2-3:
- Downloaded Docker’s official GPG key and converted it from ASCII-armored format to binary.
- Line 4:
- Ensured the key file is world-readable so the APT process can verify package signatures.
| |
- Line 1:
- Refreshed the package list.
- Line 2:
docker-ceis the Docker daemon.docker-ce-cliis the command-line client.containerd.iois the container runtime.docker-compose-pluginprovidesdocker composeas a Docker CLI subcommand.
Next I added my user to the docker group so I can run Docker commands with sudo.
| |
- Line 1:
- Added my user to the
dockergroup. The-aflag appends and-Gspecifies the supplementary group. - Line 2:
- Activated the new group membership without requiring a logout/login.
Then I verified Docker was running.
| |
03 Docker Compose File
I created a directory structure for Gitea’s Docker Compose deployment and created the Compose file. Keeping all Docker service deployments in /opt/ makes things predictable across all VMs.
| |
| |
- Line 5:
- Used the official Gitea image with the
latesttag. - Lines 8-9:
USER_UIDandUSER_GIDtell Gitea to run its internal processes as UID/GID 1000, which matches the default user on Ubuntu. This ensures file permissions on the mounted volumes are correct.- Lines 10-14:
- Database configuration using Gitea’s environment variable convention. This tells Gitea to connect to the
dbservice on port 5432 with the specified credentials. - Line 15:
unless-stoppedmeans the container restarts automatically after a crash or reboot.- Lines 17-19:
- Mounted Gitea’s data directory to
./data/giteaon the host for persistence. - Lines 21-22:
- Port
3000is Gitea’s web UI. Port2222maps to the container’s SSH port (22). - Lines 27-35:
- PortgresSQL 16 container. The credentials must match what I gave the Gitea container.
I created an .env file alongside the Compose file to hold the credentials separately.
| |
Then I updated the Compose file to reference those variables.
| |
Docker Compose automatically reads a .env file in the same directory as the docker-compose.yml file.
04 Deploying Gitea
After creating the compose and .env files, I brought the stack up.
| |
- Line 2:
- The
-dflag runs the containers in detached mode (background).
To check the status, I ran:
| |
NAME IMAGE STATUS PORTS
gitea gitea/gitea:latest Up 30 seconds 0.0.0.0:3000->3000/tcp, 0.0.0.0:2222->22/tcp
gitea-db postgres:16 Up 31 seconds 5432/tcpIf either show ‘Restarting’ or ‘Exited’, I can check the logs:
| |
05 Initial Configuration
I went to http://gitea:3000 and was presented with the initial configuration page. Most of the database settings should already be populated.
- Site Title
- Network Automation Lab
- SSH Server Domain
- 10.33.99.62
- SSH Server Port
- 2222
- Gitea Base URL
- http://gitea:3000/
Then clicked on ‘Install Gitea’.
06 SSH Key Authentication
To make it so I can run git push and git pull over SSH without prompting for a password I setup SSH key authentication.
On ansible-ctrl I genereated an ed25519 key pair.
| |
- -t ed25519:
- Ed25519 is the modern default for SSH keys.
- -C:
- A comment embedded in the public key.
- -f:
- Output file path.
I started ssh-agent and added the key so the passphrase only needs to be entered once per session
| |
- Line 1:
- Starts the ssh-agent daemon and sets the necessary environment variables in the current shell. The
evalwrapper ensures theSSH_AUTH_SOCKandSSH_AGENT_PIDvariables are exported. - Line 3:
- Adds the Gitea private key to the agent.
I set a strong passphrase when prompted since a key without a passphrase is equivalent to a password written on a sticky note.
So I didn’t have to start the ssh-agent manually on every login, I added the following to ~/.bashrc:
# Start ssh-agent if not already running
if [ -z "$SSH_AUTH_SOCK" ]; then
eval "$(ssh-agent -s)" > /dev/null
ssh-add ~/.ssh/gitea 2>/dev/null
fiThis will start the agent and load the key on login. The passphrase prompt will appear once, and then all following Git and SSH operations will use the cached key.
Next, I configured SSH to use this specific key when conencting to Gitea:
Host gitea
HostName gitea
Port 2222
User git
IdentityFile ~/.ssh/gitea- Host gitea:
- This is the alias that SSH (and Git) will match on.
- HostName:
- The actual hostname or IP address to connect to.
- Port 2222:
- Maps to the Docker port I configured in the Compose file.
- User git:
- Gitea handles all SSH Git operations under the
gituser. - IdentityFile:
- Points SSH to the specific private key for this host.
I then set the proper permissions on ansible-ctrl.
| |
- Lines 1-2:
- The SSH config and private key must be readable only by the owner.
- Line 3:
- The public key can be readable by anyone.
I then copied the public key conent and added it to Gitea.
| |
I made sure I copied the output, then went to Gitea’s web UI. From there I went to Settings > SSH/GPG Keys > Add Key.
Then verified the SSH connection works:
| |
Hi there, git! You've successfully authenticated with the key named ansible-ctrl, but Gitea does not provide shell access.The ‘does not provide shell access’ message is normal since Git hosting platforms don’t offer interactive shells.
07 Creating the Repository
In the Gitea web UI, I clicked the + button in the top right and selected New Repository:
Repository Name: network-automation-lab
Visibility: Private
Description: Ansible network automation
Initialize Repo: (unchecked)
.gitignore: None
License: None
README: None
Default Branch: mainBack on ansible-ctrl I added the Gitea repo as a remote and pushed the existing history.
| |
- Line 2:
- Added the Gitea repo as the
originremote. - Line 3:
- Pushed the
mainbranch.
I confirmed the push by refreshing the Gitea web UI. The repository should show all the files committed.
08 Branch Protection
I configured branch protection on main to enforce a pull request workflow. Even though I’m the only user right now, this builds the habit of never pushing directly to main.
In the Gitea web UI, I went to Repository Settings > Branches > Add Branch Protection Rule and configured it this way:
Branch Name Pattern: main
Enable Branch Protection: Checked
Disable Push: Checked
Enable Push Whitelist: Unchecked
Require Approvals: Checked
Required Approvals: 1
Block Merge on Rejected Reviews: Checked
Block Merge on Outdated Branch: Checked
Block Merge on Official Review: Unchecked
Enable Status Checks: Unchecked (for now)Disable Push prevents anyone (including the admin) from pushing directly to main. All changes must come through a pull request. This guarantees that every change to main is deliberate, reviewed, and traceable.
Block Merge on Outdated Branch means that if main has changed sign the PR branch was created, the PR branch much be rebased or merged with the latest main before the PR can be merged. This prevents situations where a PR was valid against an old version of main but conflicts with recent changes.
The daily workflow for making changes will look like this:
- Create a feature branch from main
| |
- Make changes, stage, commit
| |
- Push the feature branch to Gitea
| |
Open a Pull Request in the Gitea UI
Review the diff, approve, merge
Delete the feature branch after merge
Return to main locally
| |
- Created a new branch and switched to it.
- Pushed the feature branch to Gitea (the PR workflow handles the merge).
- After the PR is merged, I switch back to
mainlocally and pull the latest changes.
09 Verification
I ran through a final checklist to confirm everything is working.
Gitea containers running:
| |
Both gitea and gitea-db should show status of Up.
SSH connectivity from ansible-ctrl:
| |
I verified branch protection is active by attempting a direct push to main:
| |
Now I have a self-hosted Gitea instance running on a dedicated VM with PostgresSQL backend, SSH key authentication configured between ansible-ctrl and Gitea.