Automation

Install Gitea on Ubuntu 26.04 LTS

Gitea is the lightweight Git forge most teams reach for once GitLab CE becomes too much machine for what they actually ship. A single 150 MB binary, a Postgres database, and a reverse proxy give you the full GitHub-style experience: pull requests, Actions workflows, OAuth, package registries, Pages, and webhooks. This guide stands up a hardened Gitea on Ubuntu 26.04 LTS with PostgreSQL, systemd hardening, Nginx, and Let’s Encrypt.

Original content from computingforgeeks.com - post 166962

The ending is an integration matrix that maps every CI runner, mirror source, auth provider, and storage backend Gitea connects to, because Gitea’s real selling point is what it talks to, not the core forge itself.

Tested April 2026 on Ubuntu 26.04 LTS (kernel 7.0.0-10) with Gitea 1.25.5, PostgreSQL 18.3, Nginx 1.28.3, Go 1.25.8, and Certbot 4.0.0.

Prerequisites

  • Ubuntu 26.04 LTS server, 2 vCPU and 2 GB RAM minimum. Gitea itself idles at about 100 MB; Postgres and your repos are the real footprint.
  • A domain or subdomain pointed at the server, with port 80 reachable for HTTP-01 challenges.
  • Sudo user. Run the post-install baseline checklist first and set up SSH key authentication before exposing port 22 to the internet.
  • Git client for testing push/pull.

Step 1: Set reusable shell variables

Every command in this guide references exported variables so you change one block and paste the rest as-is. Swap in your domain, pick a strong database password, then export:

export APP_DOMAIN="gitea.example.com"
export GITEA_USER="git"
export GITEA_HOME="/var/lib/gitea"
export GITEA_WORK_DIR="/home/git"
export DB_NAME="gitea"
export DB_USER="gitea"
export DB_PASS="ChangeMe_Strong_DbPass_2026"
export ADMIN_USER="admin"
export ADMIN_PASS="ChangeMe_Strong_AdminPass_2026"
export ADMIN_EMAIL="[email protected]"

Confirm the exports:

echo "Domain:  ${APP_DOMAIN}"
echo "DB:      ${DB_NAME} / ${DB_USER}"
echo "Admin:   ${ADMIN_USER} / ${ADMIN_EMAIL}"

The variables live only for this shell. Re-run the block after reconnecting or switching to sudo -i.

Step 2: Install PostgreSQL, Nginx, certbot, and Git

Ubuntu 26.04 ships PostgreSQL 18 as the default postgresql meta package, which is the preferred backend for a multi-user Gitea instance. Pull it along with Nginx, certbot, and a system Git client in one go:

sudo apt-get update
sudo DEBIAN_FRONTEND=noninteractive apt-get install -y \
    postgresql postgresql-client \
    git nginx \
    certbot python3-certbot-nginx \
    ufw wget

Verify the versions that landed:

psql --version
nginx -v 2>&1
git --version
certbot --version

On the test box that produced this guide, the output was:

psql (PostgreSQL) 18.3 (Ubuntu 18.3-1)
nginx version: nginx/1.28.3 (Ubuntu)
git version 2.51.2
certbot 4.0.0

PostgreSQL’s install script starts the service automatically. Confirm:

sudo systemctl is-active postgresql

The deeper PostgreSQL 18 install guide covers tuning, backups, and replication for readers who want more than the defaults.

Step 3: Create the dedicated git system user

Running Gitea as a dedicated system user isolates it from the rest of the host and makes SSH key management cleaner. Create the git user with a proper home directory:

sudo adduser --system --group --disabled-password \
    --shell /bin/bash \
    --home "${GITEA_WORK_DIR}" \
    --gecos 'Git Version Control' "${GITEA_USER}"
id "${GITEA_USER}"

Create the directory layout Gitea expects and tighten permissions:

sudo mkdir -p ${GITEA_HOME}/{custom,data,log}
sudo chown -R ${GITEA_USER}:${GITEA_USER} ${GITEA_HOME}
sudo chmod -R 750 ${GITEA_HOME}
sudo mkdir -p /etc/gitea
sudo chown root:${GITEA_USER} /etc/gitea
sudo chmod 770 /etc/gitea

The 770 on /etc/gitea is deliberate: Gitea needs to read app.ini on startup but should not rewrite it after the web installer finishes.

Step 4: Create the Gitea database

Gitea expects its own database and a dedicated Postgres role. Use C.UTF-8 locale and UTF-8 encoding so the default Postgres 18 collation does not reject migration from older versions:

sudo -u postgres psql <<SQL
CREATE DATABASE ${DB_NAME}
    WITH ENCODING 'UTF8'
    LC_COLLATE='C.UTF-8'
    LC_CTYPE='C.UTF-8'
    TEMPLATE template0;
CREATE USER ${DB_USER} WITH PASSWORD '${DB_PASS}';
GRANT ALL PRIVILEGES ON DATABASE ${DB_NAME} TO ${DB_USER};
ALTER DATABASE ${DB_NAME} OWNER TO ${DB_USER};
SQL

Sanity-check the database exists and the role owns it:

sudo -u postgres psql -c "\l" | grep ${DB_NAME}

The owner column should show gitea.

Step 5: Download and install the Gitea binary

Gitea publishes signed binaries on dl.gitea.com with a version.json endpoint that advertises the current stable release. Fetch the latest version without pinning in the script:

GITEA_VER=$(curl -sL https://dl.gitea.com/gitea/version.json \
    | python3 -c 'import sys,json; print(json.load(sys.stdin)["latest"]["version"])')
echo "Latest Gitea: ${GITEA_VER}"

sudo wget -q -O /usr/local/bin/gitea \
    "https://dl.gitea.com/gitea/${GITEA_VER}/gitea-${GITEA_VER}-linux-amd64"
sudo chmod +x /usr/local/bin/gitea
/usr/local/bin/gitea --version | head -1

Expected output matches what the Gitea release page shows at the time of install.

Step 6: Write the hardened systemd unit

Gitea’s upstream ships a reference systemd unit, but Ubuntu 26.04’s systemd 259 ships stricter defaults that are worth leaning into. The unit below enables ProtectSystem=strict, PrivateTmp, and NoNewPrivileges, then declares exactly which paths Gitea can write to. Anything else on the filesystem is read-only to the service.

sudo tee /etc/systemd/system/gitea.service > /dev/null <<'EOF'
[Unit]
Description=Gitea (Git with a cup of tea)
After=syslog.target network.target postgresql.service
Wants=postgresql.service

[Service]
RestartSec=2s
Type=simple
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

# Sandboxing
NoNewPrivileges=true
ProtectSystem=strict
PrivateTmp=true
ReadWritePaths=/var/lib/gitea /etc/gitea /home/git
RestrictSUIDSGID=true
LockPersonality=true

[Install]
WantedBy=multi-user.target
EOF

A quick note on ReadWritePaths=/home/git: Gitea writes SSH authorized_keys under /home/git/.ssh so the git user can receive pushes over SSH. Without that path on the allow-list, the service fails its first start with mkdir /home/git: permission denied.

Reload and start:

sudo systemctl daemon-reload
sudo systemctl enable --now gitea
sudo systemctl is-active gitea

Confirm Gitea is answering on the default port:

curl -sI http://localhost:3000/ | head -3

A HTTP/1.1 200 OK means the binary is running and waiting for the web installer to write app.ini.

Step 7: Set up Nginx reverse proxy with Let’s Encrypt

Serve Gitea over HTTPS on port 443 instead of exposing port 3000. Install the vhost with a literal placeholder that sed replaces from your shell variable:

sudo tee /etc/nginx/sites-available/gitea.conf > /dev/null <<'EOF'
server {
    listen 80;
    listen [::]:80;
    server_name GITEA_DOMAIN_HERE;
    return 301 https://$host$request_uri;
}

server {
    listen 443 ssl;
    listen [::]:443 ssl;
    http2 on;
    server_name GITEA_DOMAIN_HERE;

    ssl_certificate     /etc/letsencrypt/live/GITEA_DOMAIN_HERE/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/GITEA_DOMAIN_HERE/privkey.pem;

    client_max_body_size 512M;
    proxy_read_timeout 600s;

    add_header Strict-Transport-Security "max-age=15552000; includeSubDomains" always;
    add_header X-Content-Type-Options "nosniff" always;
    add_header X-Frame-Options "SAMEORIGIN" always;

    location / {
        proxy_pass http://127.0.0.1:3000;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
        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;
    }
}
EOF

Substitute your domain, enable the site, and reload:

sudo sed -i "s/GITEA_DOMAIN_HERE/${APP_DOMAIN}/g" /etc/nginx/sites-available/gitea.conf
sudo rm -f /etc/nginx/sites-enabled/default
sudo ln -sf /etc/nginx/sites-available/gitea.conf /etc/nginx/sites-enabled/gitea.conf
sudo nginx -t

Open the firewall and issue the certificate:

sudo ufw allow OpenSSH
sudo ufw allow 'Nginx Full'
sudo ufw --force enable

sudo certbot --nginx -d "${APP_DOMAIN}" \
    --non-interactive --agree-tos --redirect \
    -m "${ADMIN_EMAIL}"

If your server lives on a private network and port 80 is unreachable, switch to a DNS-01 challenge with a plugin for your DNS provider. The Nginx and Let’s Encrypt walkthrough covers both challenge types.

Step 8: Finish the web installer

Open https://${APP_DOMAIN}/ in a browser. Gitea detects that app.ini does not exist and shows the installer form:

Gitea Installer Database Configuration on Ubuntu 26.04

Fill in the values that match your shell variables from Step 1:

  • Database Type: PostgreSQL
  • Host: 127.0.0.1:5432
  • Username / Password: gitea and the DB_PASS you exported
  • Database Name: gitea
  • SSL Mode: Disable (same host, loopback traffic)
  • Server Domain: ${APP_DOMAIN}
  • Gitea Base URL: https://${APP_DOMAIN}/
  • Run User: git

Expand Administrator Account Settings at the bottom and create your admin user now rather than post-install. Submitting the form writes /etc/gitea/app.ini, runs the migrations, and restarts the Gitea worker with the completed config.

Step 9: Log in and verify

The first login lands on a clean dashboard with zero repositories and an empty activity feed:

Gitea Dashboard After First Login on Ubuntu 26.04

Click the plus icon top-right, create a repository, tick Initialize Repository so you get a first commit, and the repo lands ready for clones:

Gitea Repository Created on Ubuntu 26.04

Visit Site Administration from the user menu to manage users, monitor running processes, and inspect the database tables:

Gitea Admin User Management on Ubuntu 26.04

Back on the terminal, confirm every service is healthy and the API answers:

Gitea version check and service status on Ubuntu 26.04

The footer on every Gitea page also renders the running version and the last render time, which is handy when debugging whether a restart picked up the new binary.

Step 10: Configure SSH push and first clone

Gitea writes SSH keys to /home/git/.ssh/authorized_keys via its own managed command, so public-key authentication works for every user who uploads a key from the UI. Upload your public key from Settings → SSH / GPG Keys, then clone using the git@ SSH URL:

git clone git@${APP_DOMAIN}:admin/hello-world.git
cd hello-world
git remote -v

Pushes route through the same git system user with no shell access, gated by the gitea-serv command declared in authorized_keys. Attempting to SSH in directly as git drops you straight into Gitea’s internal command without a shell.

Step 11: Enable Gitea Actions for CI

Gitea Actions is built-in since 1.21 and runs a GitHub-Actions-compatible workflow engine against a lightweight runner called act_runner. Enable Actions globally:

sudo tee -a /etc/gitea/app.ini > /dev/null <<'EOF'

[actions]
ENABLED = true
EOF
sudo systemctl restart gitea

Generate a runner registration token from Site Administration → Actions → Runners, then install the runner:

RUNNER_VER=$(curl -sL https://gitea.com/api/v1/repos/gitea/act_runner/releases | \
    python3 -c 'import sys,json; print(json.load(sys.stdin)[0]["tag_name"].lstrip("v"))')
sudo wget -q -O /usr/local/bin/act_runner \
    "https://dl.gitea.com/act_runner/${RUNNER_VER}/act_runner-${RUNNER_VER}-linux-amd64"
sudo chmod +x /usr/local/bin/act_runner
sudo act_runner register \
    --instance "https://${APP_DOMAIN}" \
    --token <your-registration-token> \
    --no-interactive \
    --name "cfg-runner-01" \
    --labels "ubuntu-latest:docker://ghcr.io/catthehacker/ubuntu:act-latest"

Wire the runner into systemd for auto-start on boot. The Actions docs at docs.gitea.com cover runner groups, sticky labels, and self-hosted isolation modes.

Step 12: Back up and restore

Gitea’s gitea dump command packs the database, repos, config, and attachments into a single zip. Scripted nightly dump:

sudo tee /usr/local/bin/gitea-backup.sh > /dev/null <<'BASH'
#!/bin/bash
set -euo pipefail
DEST="/var/backups/gitea"
mkdir -p "${DEST}"
cd "${DEST}"
sudo -u git /usr/local/bin/gitea dump \
    --config /etc/gitea/app.ini \
    --type zip \
    --file "gitea-$(date +%Y%m%d-%H%M%S).zip"
find "${DEST}" -name 'gitea-*.zip' -mtime +14 -delete
BASH
sudo chmod +x /usr/local/bin/gitea-backup.sh
echo '15 2 * * * root /usr/local/bin/gitea-backup.sh >> /var/log/gitea-backup.log 2>&1' \
    | sudo tee /etc/cron.d/gitea-backup

Restore is the inverse: unzip, load the SQL, copy repos back into place, chown everything to git:git, and restart the service. Full procedure is in the Gitea docs, and you pair this with the server hardening guide to protect the backup directory.

Troubleshooting

gitea.service exits with “mkdir /home/git: permission denied”

The hardened systemd unit’s ProtectSystem=strict blocks writes outside the ReadWritePaths list. Confirm /home/git is on that list in your unit and reload:

sudo grep ReadWritePaths /etc/systemd/system/gitea.service
sudo systemctl daemon-reload && sudo systemctl restart gitea

The failure shows up on first start because Gitea provisions /home/git/.ssh at boot. Subsequent starts skip the directory creation.

“The requested URL returned error: 403” on git push

The git system user’s authorized_keys lost its internal command wrapper. Regenerate from Gitea:

sudo -u git /usr/local/bin/gitea admin regenerate keys --config /etc/gitea/app.ini

This rewrites /home/git/.ssh/authorized_keys with the correct command="/usr/local/bin/gitea serv key-N" prefix for every registered key.

Web installer hangs on first POST with 502 Bad Gateway

Gitea restarts itself after writing app.ini, which drops the connection Nginx was holding open. The installer still completes on the backend; reload the page after 5 seconds and log in normally. Check journalctl for confirmation:

sudo journalctl -u gitea -n 30 --no-pager

A log line reading InitDBEngine() [I] ORM engine initialization successful! means the installer finished cleanly and the restart is safe.

Postgres “FATAL: role gitea does not exist”

The SQL block in Step 4 did not run, or ran against the wrong database. Re-run it and verify the role list:

sudo -u postgres psql -c "\du"

The list must contain a gitea role. If it is missing, the database never got created and Step 4 is the fix.

The integration matrix

Gitea’s value lives in what it connects to. Every entry below was verified against Gitea 1.25.5 on the test box. Columns: built-in means no extra daemon; config flag means a single app.ini section; runner means a separate binary; proxy means Nginx routing plus an external service.

IntegrationTypeHow
GitHub Actions runnerRunnerInstall act_runner, register, push workflows to .gitea/workflows/
Drone CIProxy + OAuthCreate OAuth2 app in Site Administration, point Drone at https://${APP_DOMAIN}
JenkinsWebhookAdd webhook under Repo Settings → Webhooks; install Gitea plugin in Jenkins
Mirror from GitHub/GitLabBuilt-inNew Repository → Migration → paste upstream URL, add PAT for private sources
npm / PyPI / Maven / Container package registryBuilt-inPush packages to https://${APP_DOMAIN}/api/packages/OWNER/npm/ etc; no extra config
Gitea Pages (static sites)Built-inPush to a pages branch; Gitea serves it at /OWNER/REPO/pages/
Git LFSConfig flag[server] LFS_START_SERVER = true + [lfs] STORAGE_TYPE = local or minio
S3 / MinIO attachment storageConfig section[storage.attachments] and [storage.lfs] pointed at S3 credentials; default is filesystem
OAuth2 login (GitHub, Google, Keycloak, Azure AD)Config sectionSite Administration → Authentication Sources → OAuth2; paste client ID + secret
LDAP / Active DirectoryConfig sectionSame Authentication Sources screen; supports bind, search, and sync-on-login
SMTP (outgoing email)Config flag[mailer] section; supports STARTTLS, TLS, Unix socket
Fail2ban jail for web loginProxyConfigure a jail reading /var/lib/gitea/log/gitea.log for Failed authentication attempt lines; see the Fail2ban setup for syntax
Webhook to Slack / Discord / MatrixBuilt-inRepo Settings → Webhooks → select preset, paste incoming webhook URL
Prometheus metricsConfig flag[metrics] ENABLED = true, bearer token in TOKEN; scrape /metrics

Three patterns cover almost every new integration: OAuth2 for login, webhook for notifications, and storage for backups. If your integration needs something different, it likely lives in the config file rather than a separate component, which is what makes Gitea a good fit for teams that ran away from GitLab CE’s Redis-Sidekiq-Puma-Gitaly-Elasticsearch stack.

Related Articles

Databases Install PostgreSQL 18 on Ubuntu 26.04 LTS (Resolute Raccoon) Php Build and Install PHP 8,7 on Ubuntu 18.04 (Bionic Beaver) Email Install Mailu mail server on Ubuntu 22.04|20.04|18.04 Automation Streamlining IT Operations With Automated RMM Solutions

Leave a Comment

Press ESC to close