A note to self on my Gitea setup.
Basic Setup
I chose to set up manually, using the release binary, following the basic setup instructions from here Gitea docs.
On Ubuntu/Debian
$ adduser \
--system \
--shell /bin/bash \
--gecos 'Gitea Version Control' \
--group \
--disabled-password \
--home /home/gitea \
gitea
$ mkdir -p /var/lib/gitea/{custom,data,log}
$ chown -R gitea:gitea /var/lib/gitea/
$ chmod -R 750 /var/lib/gitea/
$ mkdir /etc/gitea
$ chown root:gitea /etc/gitea
$ chmod 770 /etc/gitea
$ chmod 750 /etc/gitea
$ chmod 640 /etc/gitea/app.ini
Then, copy the gitea binary from the Gitea’s latest Github release to /usr/share/bin/gitea.
Linux service
Follow the basic instructions from Gitea docs, customizing using the user, and paths above.
Customize the /explore/repos
For personal hosting, a view similar to gitweb, or cgit, that shows categorized views of repos is helpful. With cgit / gitweb, specifying the category in the category file in . Since Gitea’s primary audience is teams, and organizations, it needs a bit of unorthodox coaxing for this customization.
- Set the following in app.ini (see app.example.ini):
[ui]
EXPLORE_PAGING_NUM=100
EXPLORE_PAGING_DEFAULT_SORT=alphabetical
Create an Organization (my preference) / User per “category” (see gitweb and cgitrc).
Create a file at
$GITEA_CUSTOM/templates/shared/repo/list.tmplto obtain a customized view that organizes repos by “category” (Organization), with this (mostly) Claude-Code generated template, with the following prompt:Customize list.tmpl to organize the repositories by organization (.Name)
list.tmpl:
{{/* Remember to set these in /etc/gitea/app.ini */}}
{{/* [ui] */}}
{{/* EXPLORE_PAGING_NUM = 100 */}}
{{/* EXPLORE_PAGING_DEFAULT_SORT = alphabetically */}}
{{$repos := .Repos}}
{{if $repos}}
{{/* Track the owner we last emitted a heading for. */}}
{{$lastOrg := ""}}
{{range $repo := $repos}}
{{$ownerName := $repo.Owner.Name}}
{{/* ── Emit a new group heading when the owner changes ── */}}
{{if ne $ownerName $lastOrg}}
{{/* Close the previous group's item-list (skip on first iteration) */}}
{{if ne $lastOrg ""}}
</div>{{/* .grouped-repos */}}
</div>{{/* .ui.segment */}}
{{end}}
<div class="ui segment org-group">
<h3 class="ui dividing header org-group-header">
{{svg "octicon-organization" 18 "tw-mr-2"}}
<a href="{{AppSubUrl}}/{{$ownerName}}">{{$ownerName}}</a>
</h3>
<div class="grouped-repos item list">
{{$lastOrg = $ownerName}}
{{end}}
{{/* ── Individual repository card ── */}}
<div class="item">
<div class="ui grid">
<div class="sixteen wide column">
{{if $repo.Avatar}}
<img class="ui avatar image" src="{{$repo.Avatar}}" alt=""/>
{{end}}
<a class="name has-emoji" href="{{$repo.Link}}">
<span class="text truncate">{{$repo.Name}}</span>
</a>
<br />
{{if $repo.IsPrivate}}
<span class="ui tiny basic label">{{ctx.Locale.Tr "repo.desc.private"}}</span>
{{end}}
{{if $repo.IsTemplate}}
<span class="ui tiny basic label">{{ctx.Locale.Tr "repo.desc.template"}}</span>
{{end}}
{{if $repo.IsArchived}}
<span class="ui tiny basic label">{{ctx.Locale.Tr "repo.desc.archived"}}</span>
{{end}}
{{if $repo.IsFork}}
<span class="text small">
{{svg "octicon-repo-forked" 14}}
{{ctx.Locale.Tr "repo.forked_from"}}
<a href="{{$repo.BaseRepo.Link}}">{{$repo.BaseRepo.FullName}}</a>
</span>
{{end}}
<div class="meta">
{{if $repo.PrimaryLanguage}}
<span class="text grey df ac tw-mr-3">
<i class="color-icon tw-mr-1" style="background-color: {{$repo.PrimaryLanguage.Color}}"></i>
{{$repo.PrimaryLanguage.Language}}
</span>
{{end}}
<span class="text grey tw-mr-3">
{{svg "octicon-star" 14 "tw-mr-1"}}
{{$repo.NumStars}}
</span>
<span class="text grey tw-mr-3">
{{svg "octicon-git-branch" 14 "tw-mr-1"}}
{{$repo.NumForks}}
</span>
{{if $repo.UpdatedUnix}}
<span class="text grey">
{{svg "octicon-clock" 14 "tw-mr-1"}}
<relative-time format="datetime" year="numeric" month="short" day="numeric"
datetime="{{$repo.UpdatedUnix.FormatDate}}"
data-tooltip-content="{{$repo.UpdatedUnix.FormatDate}}">
{{$repo.UpdatedUnix.FormatDate}}
</relative-time>
</span>
{{end}}
</div>
</div>
{{/* Star / watch actions column */}}
{{if and $.IsSigned (not $repo.IsEmpty)}}
<div class="two wide column text right">
<form method="post"
action="{{AppSubUrl}}{{$repo.Link}}/action/{{if $.IsStaringRepo}}un{{end}}star?redirect_to={{$.Link}}">
{{$.CsrfTokenHtml}}
<button class="ui compact mini basic button" type="submit"
data-tooltip-content="{{if $.IsStaringRepo}}{{ctx.Locale.Tr "repo.unstar"}}{{else}}{{ctx.Locale.Tr "repo.star"}}{{end}}">
{{if $.IsStaringRepo}}
{{svg "octicon-star-fill" 16}}
{{else}}
{{svg "octicon-star" 16}}
{{end}}
</button>
</form>
</div>
{{end}}
</div>
</div>
{{/* end repo card */}}
{{end}}{{/* end range $repos */}}
{{/* Close the final open group */}}
{{if ne $lastOrg ""}}
</div>{{/* .grouped-repos */}}
</div>{{/* .ui.segment */}}
{{end}}
{{else}}
{{/* No repositories found – mirror the standard empty-state */}}
<div class="ui secondary segment">
<p>{{ctx.Locale.Tr "search.no_results"}}</p>
</div>
{{end}}
<style>
/* Extra styling for the org-group headers */
.org-group {
margin-bottom: 1.5em !important;
padding-bottom: 0px;
}
.org-group-header {
font-size: 1.1rem;
margin-bottom: 0em !important;
}
.org-group-header a {
color: inherit;
}
.grouped-repos .item {
padding: 0.75em 0;
border-bottom: 1px solid var(--color-secondary);
}
.grouped-repos .item:last-child {
border-bottom: none;
padding-bottom: 0px;
}
.sixteen {
display: flex !important;
padding-bottom: 10px !important;
flex-wrap: wrap;
justify-content: space-between;
}
.name {
width: 35%; // Hack for mobile view: force metadata on separate line, by reserving large portion of line for repo name
}
</style>