“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#

MetricResult
RAM usage~60MB steady state
CPU<5% baseline, spikes on git operations
Uptime99.8%
Repositories hosted30+
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.

Elijah Udom


← Previous: FastAPI Project | Next: Orchestration Guide →