Self-Hosting Gitea on AWS: Architecture, Configuration, and the 502s I Debugged

Table of Contents
“GitHub is fine until you’re rate-limited at 11 PM pushing a large repo before a deadline. That’s when you decide to build your own.”
This is a full breakdown of running Gitea on AWS EC2 â not a happy-path tutorial, but the actual build including every error I hit and how I resolved it. Architecture, configuration, the 502 debugging session, backup automation, and what three months of running this in production looks like.
Architecture#
Internet â AWS EC2 (Ubuntu 22.04)
ââ Nginx (Reverse Proxy + SSL Termination)
ââ Gitea (Port 3000)
ââ MySQL (Metadata + User Storage)
Why Gitea over the alternatives:
- Written in Go â binary is ~80MB, RAM usage sits around 50MB at idle
- Self-contained â no JVM, no Node runtime, no dependency hell
- UI is clean and the API is well-documented
- Active maintenance without corporate ownership risk
Why this stack:
- Nginx handles SSL termination and proxies to Gitea on 3000 â Gitea never exposed directly
- MySQL for data persistence â SQLite works for personal use but doesn’t survive load or concurrent writes cleanly
- Systemd manages the Gitea process â proper restart-on-failure, dependency ordering, journal logging
EC2 Instance Setup#
AMI: Ubuntu 22.04 LTS
Instance Type: t2.micro (1 vCPU, 1GB RAM)
Storage: 8GB gp2
Security Group:
SSH (22): Your IP only
HTTP (80): 0.0.0.0/0 â required for Let's Encrypt HTTP challenge
HTTPS (443): 0.0.0.0/0
TCP (3000): Your IP only â initial setup only, close after Nginx is configured
Port 3000 should never be publicly exposed. It’s open briefly for initial Gitea setup, then closed. All traffic goes through Nginx on 443.
ssh -i "your-key.pem" ubuntu@your-ec2-ip
sudo apt update && sudo apt upgrade -y
Installing Dependencies#
sudo apt install -y git nginx mysql-server
# Dedicated system user for Gitea â contains blast radius if anything goes wrong
sudo adduser --system --shell /bin/bash \
--gecos 'Git Version Control' \
--group --disabled-password \
--home /home/git git
MySQL Setup#
sudo mysql_secure_installation
Answers that matter: set a root password, remove anonymous users, disallow remote root login, remove test database.
sudo mysql -u root -p
CREATE DATABASE gitea CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER 'gitea'@'localhost' IDENTIFIED BY 'your-strong-password';
GRANT ALL PRIVILEGES ON gitea.* TO 'gitea'@'localhost';
FLUSH PRIVILEGES;
EXIT;
utf8mb4 matters â standard utf8 in MySQL is a 3-byte subset that breaks on emoji and certain Unicode characters. Set it correctly now.
Installing Gitea#
wget -O gitea https://dl.gitea.com/gitea/1.20.0/gitea-1.20.0-linux-amd64
chmod +x gitea
sudo mv gitea /usr/local/bin/
sudo mkdir -p /var/lib/gitea/{custom,data,log}
sudo chown -R git:git /var/lib/gitea
sudo chmod -R 750 /var/lib/gitea
sudo mkdir /etc/gitea
sudo chown root:git /etc/gitea
sudo chmod 770 /etc/gitea
Permission breakdown: 750 â owner full access, group read/execute, others nothing. 770 on /etc/gitea â Gitea needs to write its config there during the setup wizard, then you lock it down after.
Systemd Service#
# /etc/systemd/system/gitea.service
[Unit]
Description=Gitea (Git with a cup of tea)
After=network.target mysql.service
Requires=mysql.service
[Service]
User=git
Group=git
WorkingDirectory=/var/lib/gitea/
ExecStart=/usr/local/bin/gitea web --config /etc/gitea/app.ini
Restart=always
Environment=USER=git HOME=/home/git GITEA_WORK_DIR=/var/lib/gitea
ProtectSystem=full
PrivateDevices=yes
PrivateTmp=yes
[Install]
WantedBy=multi-user.target
Requires=mysql.service in addition to After= â After controls ordering but doesn’t enforce the dependency. Without Requires, Gitea can attempt to start even if MySQL fails.
sudo systemctl daemon-reload
sudo systemctl enable gitea
sudo systemctl start gitea
sudo systemctl status gitea
Nginx Configuration and the 502 Debugging Session#
Initial Config#
sudo nano /etc/nginx/sites-available/gitea
server {
listen 80;
server_name your-domain.com;
client_max_body_size 50M;
location / {
proxy_pass http://localhost:3000;
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;
# WebSocket support â required for real-time repo updates
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
}
sudo ln -s /etc/nginx/sites-available/gitea /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx
The 502s#
Got three separate 502s in sequence. Worth documenting because they’re each different root causes.
502 #1 â Gitea wasn’t running
sudo systemctl status gitea
# Crashed. Check why before restarting.
sudo journalctl -u gitea -n 50
Found a database connection error â MySQL was up but Gitea couldn’t authenticate. Wrong password in the initial config. Fixed the credentials, restarted.
502 #2 â Port 3000 not listening
sudo netstat -tlnp | grep 3000
# Nothing.
Gitea had started but was binding to a different interface. Checked /etc/gitea/app.ini â the HTTP_ADDR was set to 127.0.0.1 which is correct for Nginx proxying, but the initial wizard hadn’t run yet so the config was incomplete. Ran through the web setup wizard first, then Nginx started seeing it.
502 #3 â UFW blocking the proxy connection
sudo ufw status
# Active, port 3000 not in the rules.
sudo ufw allow 3000/tcp
After Nginx is properly configured and traffic is flowing through 443, you close 3000 again:
sudo ufw delete allow 3000/tcp
Don’t leave it open. Gitea on 3000 is HTTP with no SSL â anything you do on that port is in plaintext.
SSL with Let’s Encrypt#
sudo apt install certbot python3-certbot-nginx -y
sudo certbot --nginx -d your-domain.com
Certbot modifies the Nginx config in place â adds the 443 server block, redirects HTTP to HTTPS, sets up the certificate paths. Test that auto-renewal works before you forget about it:
sudo certbot renew --dry-run
Certificates expire every 90 days. Certbot installs a systemd timer that handles renewal automatically. Verify the timer is active:
systemctl list-timers | grep certbot
Initial Gitea Configuration#
Visit https://your-domain.com â you’ll hit the setup wizard.
Database:
- Type: MySQL
- Host:
127.0.0.1:3306 - Username:
gitea - Database:
gitea
General:
- Repository Root:
/var/lib/gitea/data/gitea-repositories - Git LFS Root:
/var/lib/gitea/data/lfs - Run As:
git
After the wizard completes, lock down the config directory:
sudo chmod 750 /etc/gitea
sudo chmod 640 /etc/gitea/app.ini
The wizard needed write access. Production doesn’t.
Firewall#
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow ssh
sudo ufw allow http
sudo ufw allow https
sudo ufw enable
Port 3000 stays closed. All traffic through Nginx.
Automated Backups#
Backups aren’t backups until you’ve tested a restore. This script runs weekly and keeps 30 days:
#!/bin/bash
# /usr/local/bin/gitea-backup.sh
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
BACKUP_DIR=/var/backups/gitea
mkdir -p $BACKUP_DIR
# Database dump
mysqldump -u gitea -p'your-password' gitea > \
$BACKUP_DIR/gitea_db_$TIMESTAMP.sql
# Gitea data and config
tar -czf $BACKUP_DIR/gitea_data_$TIMESTAMP.tar.gz \
/var/lib/gitea \
/etc/gitea
# Prune anything older than 30 days
find $BACKUP_DIR -name "*.sql" -mtime +30 -delete
find $BACKUP_DIR -name "*.tar.gz" -mtime +30 -delete
echo "Backup completed: $TIMESTAMP"
sudo chmod +x /usr/local/bin/gitea-backup.sh
# Weekly at 2 AM Sunday
sudo crontab -e
# 0 2 * * 0 /usr/local/bin/gitea-backup.sh >> /var/log/gitea-backup.log 2>&1
Point these backups at S3 in production. Local backups on the same instance don’t survive instance failure.
Troubleshooting Reference#
413 on large repo push:
# Increase in Nginx config
client_max_body_size 500M;
SSH clone failing with permission denied:
sudo chown -R git:git /home/git/.ssh
sudo chmod 700 /home/git/.ssh
sudo chmod 600 /home/git/.ssh/authorized_keys
Check /etc/gitea/app.ini:
[server]
SSH_DOMAIN = your-domain.com
SSH_PORT = 22
Gitea not starting after reboot:
sudo systemctl status gitea
sudo journalctl -u gitea -n 50
Usually MySQL hasn’t finished starting before Gitea attempts to connect. The Requires=mysql.service in the unit file handles this â if it’s still happening, add a ExecStartPre=/bin/sleep 5 as a blunt fix while you investigate.
Three Months in Production#
| Metric | Result |
|---|---|
| RAM usage | ~60MB steady state |
| CPU | <5% baseline, spikes on git operations |
| Uptime | 99.8% |
| Repositories hosted | 30+ |
| Monthly bandwidth | ~1GB for personal use |
Cost:
- t2.micro on free tier: $0 / ~$8.50/month after
- 8GB EBS: $0 / ~$0.80/month after
- Transfer: negligible for personal use
The performance difference versus GitHub is noticeable in my region â sub-second response times on operations that would take 2â3 seconds on GitHub. Probably a latency thing, but it matters when you’re using it every day.
What I’d Do Differently#
Use Terraform from the start. The EC2 instance, security group, and EBS volume are all click-ops right now. Reproducing this environment means clicking through the console again. That’s not acceptable for infrastructure you intend to maintain.
S3 for backups from day one. Local backups on the instance mean an EBS failure takes your backups with it.
Test restore before you need it. I found out during a test that my backup script wasn’t capturing the LFS directory correctly. Caught it in a test, not a real recovery.
Source#
Full config files â systemd unit, Nginx config, backup script â on GitHub .
Tags#
#AWS #Infrastructure #SelfHosted #Git #Linux #Nginx #EC2
About the Author#
Elijah Udom (elijahu) is an Infrastructure & Cloud Engineer based in Lagos, Nigeria. AWS, Kubernetes, eBPF security, AI/ML infrastructure. Building in the open.

Navigation#
â Previous: FastAPI Project | Next: Orchestration Guide â