Gitea is a self-hosted Git server written in Go. Let's look at how it uses Go patterns for routing, middleware, and package registries.

Gitea is written in Go — here's what you can learn from its codebase


Gitea is a self-hosted git server — think GitHub, but you run it yourself. It does code review, CI/CD, package registries (npm, Maven, Docker Registry V2), the lot. The frontend is Vue and TypeScript, but the backend is pure Go.

I spent some time reading the source and there’s a lot of good stuff in there. Not perfect, but genuinely worth looking at if you’re building anything non-trivial in Go.

Structuring a large Go application

Gitea is big. Thousands of files, hundreds of packages. The top-level layout:

models/       - Database models and queries
modules/      - Shared libraries (git, markup, setting, etc.)
routers/      - HTTP route handlers (API + web)
services/     - Business logic layer
cmd/          - CLI entry points

The key thing here is that services sits between routers and models. Route handlers don’t touch the database. They call into services, which deal with models. This isn’t revolutionary, but a lot of Go projects skip it and end up with handlers doing everything.

A service function in Gitea’s style looks like this:

package services

import (
	"context"
	"fmt"

	"code.gitea.io/gitea/models/repo"
	"code.gitea.io/gitea/models/user"
)

// CreateRepository handles the business logic for repo creation.
// The router calls this — it never touches models directly.
func CreateRepository(ctx context.Context, owner *user.User, opts repo.CreateRepoOptions) (*repo.Repository, error) {
	if opts.Name == "" {
		return nil, fmt.Errorf("repository name cannot be empty")
	}

	// Validation, permission checks, etc. happen here
	r, err := repo.CreateRepository(ctx, owner, opts)
	if err != nil {
		return nil, fmt.Errorf("creating repository: %w", err)
	}

	// Post-creation hooks: initialize git repo, create default branch, etc.
	if err := initRepository(ctx, r); err != nil {
		return nil, fmt.Errorf("initializing repository: %w", err)
	}

	return r, nil
}

If you’ve been stuffing logic directly into HTTP handlers, this is worth looking at. Testing gets a lot easier when your business logic isn’t tangled up with request parsing. We touched on related structural ideas in our post on functional options.

Context everywhere (and I mean everywhere)

Gitea passes context.Context through almost everything. That part’s standard. What’s more interesting is that they define their own context type with request-scoped data attached — authenticated user, current repo, locale:

package context

import (
	"context"
	"net/http"
)

// Context represents a request context for Gitea's web handlers.
type Context struct {
	context.Context
	Req  *http.Request
	Resp http.ResponseWriter
	User *User
	Repo *Repository
	Org  *Organization
	Data map[string]interface{} // template data
}

// GetContext extracts the Gitea context from an http.Request.
func GetContext(req *http.Request) *Context {
	ctx, _ := req.Context().Value(contextKey).(*Context)
	return ctx
}

Every handler gets a *context.Context (their version, not the stdlib one). Middleware populates the fields before the handler runs, so each handler doesn’t have to go look up the current user or repo itself.

The middleware setup:

package routers

import (
	"net/http"

	giteaCtx "code.gitea.io/gitea/modules/context"
)

// Contexter middleware creates the Gitea context and injects it.
func Contexter() func(http.Handler) http.Handler {
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
			ctx := &giteaCtx.Context{
				Req:  req,
				Resp: w,
				Data: make(map[string]interface{}),
			}

			// Attach to request context
			req = req.WithContext(context.WithValue(req.Context(), contextKey, ctx))

			next.ServeHTTP(w, req)
		})
	}
}

Clean. If you want to see how context works in production code, this pairs well with our post on context in Go.

Wrapping the git binary

Gitea doesn’t reimplement git. It shells out to the git binary, but wraps the calls properly. The modules/git package gives you typed Go functions for git operations:

package git

import (
	"bytes"
	"context"
	"fmt"
	"os/exec"
	"strings"
)

// Command represents a git command to be executed.
type Command struct {
	args []string
	envs []string
}

// NewCommand creates a new git command.
func NewCommand(args ...string) *Command {
	return &Command{args: args}
}

// AddArguments appends arguments to the command.
func (c *Command) AddArguments(args ...string) *Command {
	c.args = append(c.args, args...)
	return c
}

// RunInDir executes the git command in the given directory.
func (c *Command) RunInDir(ctx context.Context, dir string) (string, error) {
	cmd := exec.CommandContext(ctx, "git", c.args...)
	cmd.Dir = dir
	cmd.Env = append(cmd.Env, c.envs...)

	var stdout, stderr bytes.Buffer
	cmd.Stdout = &stdout
	cmd.Stderr = &stderr

	if err := cmd.Run(); err != nil {
		return "", fmt.Errorf("git %s: %s: %w",
			strings.Join(c.args, " "),
			stderr.String(),
			err,
		)
	}

	return stdout.String(), nil
}

Which gets used like:

func GetBranchNames(ctx context.Context, repoPath string) ([]string, error) {
	stdout, err := NewCommand("branch", "--list").RunInDir(ctx, repoPath)
	if err != nil {
		return nil, err
	}
	// Parse branch names from stdout
	lines := strings.Split(strings.TrimSpace(stdout), "\n")
	branches := make([]string, 0, len(lines))
	for _, line := range lines {
		branches = append(branches, strings.TrimPrefix(strings.TrimSpace(line), "* "))
	}
	return branches, nil
}

Three things I like about this: exec.CommandContext means the git process gets killed if the request is cancelled or times out (important for LFS operations). The builder pattern makes command construction readable. And stderr ends up in the error message, which makes debugging substantially less painful than squinting at “exit status 1”.

If you shell out to anything else — ffmpeg, terraform, whatever — this same pattern works.

The package registry

This is probably Gitea’s most ambitious feature. It supports npm, Maven, Docker Registry V2, PyPI, and more. Each speaks a different protocol, but they share the same storage layer. Go interfaces handle the abstraction:

package packages

import (
	"context"
	"io"
)

// PackageType represents the type of package (npm, maven, docker, etc.)
type PackageType string

const (
	TypeNpm    PackageType = "npm"
	TypeMaven  PackageType = "maven"
	TypeDocker PackageType = "docker"
)

// PackageFileStore defines how package files are stored and retrieved.
type PackageFileStore interface {
	Save(ctx context.Context, path string, r io.Reader, size int64) error
	Open(ctx context.Context, path string) (io.ReadCloser, error)
	Delete(ctx context.Context, path string) error
}

// RegistryHandler defines the interface each registry type must implement.
type RegistryHandler interface {
	Upload(ctx context.Context, owner string, data io.Reader) (*PackageVersion, error)
	Download(ctx context.Context, owner, name, version string) (io.ReadCloser, error)
	ListVersions(ctx context.Context, owner, name string) ([]*PackageVersion, error)
}

The Docker registry has to deal with chunked uploads and content-addressable storage. The npm registry parses package.json metadata. Very different protocol details — but they both funnel blobs through the same PackageFileStore. Each registry implementation lives under routers/api/packages/ and gets wired into the router:

// In the router setup
packagesAPI.Group("/npm", func() {
	// npm-specific routes
})
packagesAPI.Group("/maven", func() {
	// maven-specific routes
})
packagesAPI.Group("/docker", func() {
	// Docker Registry V2 routes
})

Gitea Actions and gRPC

Gitea Actions is compatible with GitHub Actions workflows. The runner is a separate Go project — act_runner — that polls Gitea for jobs and runs them.

The runner talks to Gitea over gRPC. It registers, gets workflow jobs, executes them (usually in Docker containers), and streams logs back. If you’ve ever been curious how CI/CD systems actually work at the transport layer, act_runner is a much smaller codebase than Gitea itself and easier to read through.

What I took away from reading this

The routers → services → models layering is the pattern I’d copy first. It’s not fancy, but it scales, and it makes testing significantly easier.

The git wrapper is a nice template for any subprocess work. Typed functions, context support, stderr in the error. That’s the whole recipe.

The package registry shows how interfaces let you keep protocol-specific complexity isolated. Each registry implementation can be as weird as it needs to be without bleeding into the shared storage layer.

If you’re building a devops tool, a self-hosted service, or anything that deals with real operational complexity, the source is worth a read. It’s a codebase that has had to solve actual problems — not toy examples.

For more on building well-structured Go applications, the post on regex handling in Go covers another practical pattern you’ll see in real codebases.