> ## Documentation Index
> Fetch the complete documentation index at: https://developer.upsun.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Write a Git server with per-branch permissions in five minutes

> Upsun's most basic API is a Git server. Build one in Python with Dulwich and Paramiko, and add per-branch read permissions in a single method.

export const PostMeta = ({data = {}}) => {
  const {author, date} = data;
  const authors = Array.isArray(author) ? author : author ? [author] : [];
  const toSlug = value => String(value).toLowerCase().trim().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '');
  const resolveAuthor = slug => {
    const entry = AUTHOR_MAP[slug] || ({});
    const name = entry.name || slug;
    const github = entry.github || null;
    const url = `/posts/authors/${toSlug(slug)}`;
    const avatarUrl = github ? `https://github.com/${github}.png?size=64` : null;
    return {
      name,
      url,
      avatarUrl
    };
  };
  const formattedDate = date ? new Date(date).toLocaleDateString('en-US', {
    year: 'numeric',
    month: 'long',
    day: 'numeric'
  }) : null;
  if (authors.length === 0 && !formattedDate) return null;
  const AUTHOR_MAP = {
    "aaron-collier": {
      "name": "Aaron Collier"
    },
    "aaron-dudenhofer": {
      "name": "Aaron Dudenhofer"
    },
    "aaron-porter": {
      "name": "Aaron Porter"
    },
    "adriaan-odendaal": {
      "name": "Adriaan Odendaal"
    },
    "ajmal": {
      "name": "Ajmal Siddiqui"
    },
    "akalipetis": {
      "name": "Antonis Kalipetis"
    },
    "alexander-varwijk": {
      "name": "Alexander Varwijk"
    },
    "alicia-bevilacqua": {
      "name": "Alicia Bevilacqua"
    },
    "amelie-deguerry": {
      "name": "Amelie Deguerry"
    },
    "anacidre": {
      "name": "Ana Cidre",
      "linkedin": "https://www.linkedin.com/in/ana-cidre"
    },
    "andoni": {
      "name": "Andoni Auzmendi"
    },
    "andrei-taranu": {
      "name": "Andrei (Alex) Taranu",
      "linkedin": "https://www.linkedin.com/in/andrei-alex-taranu/"
    },
    "andrew-baxter": {
      "name": "Andrew Baxter"
    },
    "andrew-melck": {
      "name": "Andrew Melck"
    },
    "antoine-crochet-damais": {
      "name": "Antoine Crochet Damais"
    },
    "augustin-delaporte": {
      "name": "Augustin Delaporte",
      "linkedin": "https://www.linkedin.com/in/augustindelaporte/"
    },
    "branislav-bujisic": {
      "name": "Branislav Bujisic"
    },
    "carl-smith": {
      "name": "Carl Smith"
    },
    "caroline-leroy": {
      "name": "Caroline Leroy"
    },
    "cati-mayer": {
      "name": "Cati Mayer"
    },
    "catplat": {
      "name": "C Trinkwon"
    },
    "ceelolulu": {
      "name": "Celeste van der Watt"
    },
    "chadwcarlson": {
      "name": "Chad Carlson",
      "github": "chadwcarlson",
      "linkedin": "https://www.linkedin.com/in/chadwcarlson"
    },
    "chris-ward": {
      "name": "Chris Ward"
    },
    "chris-yates": {
      "name": "Chris Yates"
    },
    "christian-sieber": {
      "name": "Christian Sieber"
    },
    "christopher-lockheardt": {
      "name": "Christopher Lockheardt"
    },
    "christopher-skene": {
      "name": "Christopher Skene"
    },
    "chuck-morgan": {
      "name": "Chuck Morgan"
    },
    "corey-dockendorf": {
      "name": "Corey Dockendorf"
    },
    "crell": {
      "name": "Crell"
    },
    "damz": {
      "name": "Damz"
    },
    "dan-morrison": {
      "name": "Dan Morrison"
    },
    "davidbonachera": {
      "name": "David Bonachera",
      "github": "davidbonachera",
      "linkedin": "https://www.linkedin.com/in/davidbonachera"
    },
    "dereliahmet1": {
      "name": "Ahmet Faruk Dereli"
    },
    "devicezero": {
      "name": "Jonas Kröger",
      "github": "devicezero",
      "linkedin": "https://www.linkedin.com/in/jonaskroeger/"
    },
    "doug-goldberg": {
      "name": "Doug Goldberg"
    },
    "duncan-naves": {
      "name": "Duncan Naves",
      "github": "duncannaves",
      "linkedin": "https://www.linkedin.com/in/duncan-naves-a94423aa"
    },
    "erika-bustamante": {
      "name": "Erika Bustamante"
    },
    "fabpot": {
      "name": "Fabien Potencier"
    },
    "flovntp": {
      "name": "Florent Huck",
      "github": "flovntp",
      "linkedin": "https://www.linkedin.com/in/florenthuck"
    },
    "fred-plais": {
      "name": "Fred Plais"
    },
    "gauthier-garnier": {
      "name": "Gauthier Garnier"
    },
    "gilzow": {
      "name": "Paul Gilzow"
    },
    "gmoigneu": {
      "name": "Guillaume Moigneu",
      "github": "gmoigneu",
      "linkedin": "https://www.linkedin.com/in/guillaumemoigneu/"
    },
    "gregqualls": {
      "name": "Greg Qualls"
    },
    "guguss": {
      "name": "Augustin Delaporte"
    },
    "haylee-millar": {
      "name": "Haylee Millar"
    },
    "ivana-kotur": {
      "name": "Ivana Kotur"
    },
    "jackrabbithanna": {
      "name": "Mark Hanna",
      "github": "jackrabbithanna"
    },
    "jared-wright": {
      "name": "Jared Wright",
      "github": "jww-sh",
      "linkedin": "https://www.linkedin.com/in/jaredwaynewright"
    },
    "jessica-orozco": {
      "name": "Jessica Orozco"
    },
    "joey-stanford": {
      "name": "Joey Stanford"
    },
    "john-grubb": {
      "name": "John Grubb"
    },
    "jonas-kruger": {
      "name": "Jonas Kruger"
    },
    "kathryn-frazer": {
      "name": "Kathryn Frazer"
    },
    "kemiojo": {
      "name": "Kemi Elizabeth Ojogbede"
    },
    "kieronsambrook-smith": {
      "name": "Kieronsambrook Smith"
    },
    "laurent-arnoud": {
      "name": "Laurent Arnoud",
      "linkedin": "https://www.linkedin.com/in/laurent-arnoud-861b44121/"
    },
    "letoya-boyne": {
      "name": "Letoya Boyne"
    },
    "lolautruche": {
      "name": "Jérôme Vieilledent"
    },
    "lyly-lepinay": {
      "name": "Lyly Lepinay"
    },
    "manauwar-alam": {
      "name": "Manauwar Alam"
    },
    "marc-antoine-porri": {
      "name": "Marc Antoine Porri"
    },
    "maria-antinkaapo": {
      "name": "Maria Antinkaapo"
    },
    "maria-de-anton": {
      "name": "Maria De Anton"
    },
    "mark-dorison": {
      "name": "Mark Dorison"
    },
    "markus-hausammann": {
      "name": "Markus Hausammann"
    },
    "mary-thomas": {
      "name": "Mary Thomas"
    },
    "mathias-bolt-lesniak": {
      "name": "Mathias Bolt Lesniak"
    },
    "mathieu-strauch": {
      "name": "Mathieu Strauch"
    },
    "matthias-van-woensel": {
      "name": "Matthias Van Woensel",
      "linkedin": "https://www.linkedin.com/in/matthias-van-woensel-267a069"
    },
    "maz-mohammadi": {
      "name": "Maz Mohammadi"
    },
    "michael-sharp": {
      "name": "Michael Sharp"
    },
    "mupsi": {
      "name": "Marine Gandy"
    },
    "natalie-harper": {
      "name": "Natalie Harper"
    },
    "ngommenginger": {
      "name": "Nicolas Gommenginger",
      "linkedin": "https://www.linkedin.com/in/nicolas-gommenginger"
    },
    "nicholas-bennison": {
      "name": "Nicholas Bennison"
    },
    "nicholas-vahalik": {
      "name": "Nicholas Vahalik"
    },
    "nick-hardiman": {
      "name": "Nick Hardiman"
    },
    "nickanderegg": {
      "name": "Nickanderegg"
    },
    "nicolas-grekas": {
      "name": "Nicolas Grekas",
      "github": "nicolas-grekas",
      "linkedin": "https://www.linkedin.com/in/nicolasgrekas/"
    },
    "niti-malwade": {
      "name": "Niti Malwade"
    },
    "opensocialteam": {
      "name": "Opensocialteam"
    },
    "ori-pekelman": {
      "name": "Ori Pekelman"
    },
    "otavio-santana": {
      "name": "Otavio Santana"
    },
    "palwandi": {
      "name": "Pawan Alwandi",
      "github": "pawpy",
      "linkedin": "https://www.linkedin.com/in/pawanalwandi"
    },
    "patrick-boest": {
      "name": "Patrick Boest"
    },
    "patrick-dawkins": {
      "name": "Patrick Dawkins",
      "github": "pjcdawkins",
      "linkedin": "https://www.linkedin.com/in/patrickdawkins"
    },
    "patrick-klima": {
      "name": "Patrick Klima"
    },
    "pjcdawkins": {
      "name": "Pjcdawkins"
    },
    "prineet-kaurbhurji": {
      "name": "Prineet Kaurbhurji"
    },
    "quentin-sinig": {
      "name": "Quentin Sinig"
    },
    "ralt": {
      "name": "Florian Margaine",
      "github": "ralt",
      "linkedin": "https://www.linkedin.com/in/florian-margaine-43971136"
    },
    "ramanathanramakrishnamurthy": {
      "name": "Ramanathanramakrishnamurthy"
    },
    "remi-lejeune": {
      "name": "Rémi Lejeune"
    },
    "ribel": {
      "name": "Taras Kruts"
    },
    "robert-douglass": {
      "name": "Robert Douglass"
    },
    "rudy-weber": {
      "name": "Rudy Weber"
    },
    "ryan-hicks": {
      "name": "Ryan Hicks"
    },
    "sabri-helal": {
      "name": "Sabri Helal"
    },
    "savannah-bergeron": {
      "name": "Savannah Bergeron"
    },
    "shannon-vettes": {
      "name": "Shannon Vettes"
    },
    "shawn-ogasawara": {
      "name": "Shawn Ogasawara",
      "linkedin": "https://www.linkedin.com/in/shawn-ogasawara-83a9a0/"
    },
    "shawna-spoor": {
      "name": "Shawna Spoor"
    },
    "shedrack-akintayo": {
      "name": "Shedrack Akintayo"
    },
    "simon-ruggier": {
      "name": "Simon Ruggier"
    },
    "sophie-van-der-kindere": {
      "name": "Sophie Van Der Kindere"
    },
    "stefanos-thampis": {
      "name": "Stefanos Thampis"
    },
    "stephen-weinberg": {
      "name": "Stephen Weinberg"
    },
    "sukhman-virk": {
      "name": "Sukhman Virk"
    },
    "sumaira-nazir": {
      "name": "Sumaira Nazir"
    },
    "sumer": {
      "name": "Sümer Cip"
    },
    "syed-raza": {
      "name": "Syed Raza"
    },
    "tamara-bacchia": {
      "name": "Tamara Bacchia"
    },
    "tara-arnold": {
      "name": "Tara Arnold"
    },
    "theosakamg": {
      "name": "Mickael Gaillard",
      "github": "theosakamg"
    },
    "thomasdiluccio": {
      "name": "Thomas di Luccio"
    },
    "tim-anderson": {
      "name": "Tim Anderson"
    },
    "tom-helmer-hansen": {
      "name": "Tom Helmer Hansen"
    },
    "tylermills": {
      "name": "Tyler Mills"
    },
    "upsun": {
      "name": "Upsun"
    },
    "veronika-tolkachova": {
      "name": "Veronika Tolkachova",
      "linkedin": "https://www.linkedin.com/in/veronika-tolkachova-169167a2"
    },
    "vince-parker": {
      "name": "Vince Parker"
    },
    "vinnie-russo": {
      "name": "Vincenzo Russo"
    },
    "vrobert78": {
      "name": "Vincent Robert",
      "github": "vrobert78",
      "linkedin": "https://www.linkedin.com/in/vincent-robert-498a883"
    },
    "yuriy-babenko": {
      "name": "Yuriy Babenko"
    },
    "yuriy-gerasimov": {
      "name": "Yuriy Gerasimov"
    }
  };
  return <div className="post-meta">
      {(authors.length > 0 || formattedDate) && <div className="post-meta-info">
          {authors.length > 0 && <div className="post-meta-authors">
              {authors.map(slug => {
    const {name, url, avatarUrl} = resolveAuthor(slug);
    const inner = <>
                    {avatarUrl && <img src={avatarUrl} alt={name} className="post-meta-avatar" />}
                    <span className="post-meta-author-name">{name}</span>
                  </>;
    return url ? <a key={slug} href={url} className="post-meta-author">
                    {inner}
                  </a> : <span key={slug} className="post-meta-author">{inner}</span>;
  })}
            </div>}
          {authors.length > 0 && formattedDate && <span className="post-meta-separator" aria-hidden="true">·</span>}
          {formattedDate && <span className="post-meta-date">{formattedDate}</span>}
        </div>}
    </div>;
};

<PostMeta data={{ author: ["ralt"], date: "2026-07-07T09:00:00.000Z" }} />

Every time you deploy on Upsun, you use a Git server. You run `git push`, and something on the other end receives your code and turns it into a running application. Strip away the dashboards and the CLI, and that push is the most basic API we offer.

A Git server sounds like a big thing to operate. It isn't. You can write one that serves real clones and pushes in about as long as it takes to read this post. And once you have, one feature that sounds trivial turns out to be the interesting part: showing different branches to different users.

That last part deserves a second look. When you `git clone` a repository from GitHub, you get every branch. You don't pick a subset. The client asks for the repository and the server hands over its full set of refs. How would a server ever hide a branch from someone? The answer lives in a corner of Git most people never open.

## A Git server is an SSH server that runs two commands

When you clone over SSH, Git opens an SSH connection and runs a single command on the far side. The URL you typed is really an instruction to execute a program on the server:

```bash theme={null}
$ git clone ssh://git@example.com/myrepo.git

# under the hood, Git runs this over SSH:
$ ssh git@example.com "git-upload-pack '/myrepo.git'"
```

Two commands matter. `git-upload-pack` serves fetches and clones. `git-receive-pack` serves pushes. The names read from the server's point of view, which is why they feel inverted the first time you meet them. When you clone, the server uploads objects to you, so it runs upload-pack. When you push, the server receives objects from you, so it runs receive-pack.

SSH is only the transport. On top of it, the client and server speak a protocol Git defines. The server advertises its refs, the branches and tags it holds and the commit each one points at. The client replies with the object IDs it wants. The server works out which objects the client is missing and streams them back as a packfile, a compressed bundle of Git objects.

One step in that exchange is where branch permissions live. The server advertises its refs first, and the client can only ask for what it has been shown. Control the advertisement, and you control what a user can see and fetch. That is the whole trick.

## The pieces

Two Python libraries do the heavy lifting. [Dulwich](https://www.dulwich.io/) is a pure-Python implementation of Git that already speaks the protocol on the server side. [Paramiko](https://www.paramiko.org/) is a pure-Python SSH implementation that can act as a server. Your job is to connect the two and slip your own authorization in between.

```bash theme={null}
$ pip install dulwich paramiko
```

Start with authentication, and keep it to a bare minimum: the username. A real server maps an SSH public key to a user account. Here you take whatever username the client connects as and trust it, so the code stays about permissions rather than credentials.

<Warning>
  This server authenticates nobody. It trusts the username the client claims and accepts a connection with no key and no password. That is fine for reading the code and running it on your laptop, and completely unfit for anything reachable by other people. Wire in public-key authentication before this touches a network you don't control.
</Warning>

The SSH side is a Paramiko `ServerInterface`. It records the username, accepts the session, and captures the command Git asks to run:

```python theme={null}
import shlex
import socket
import threading

import paramiko
from dulwich.repo import Repo
from dulwich.server import (
    FileSystemBackend,
    ReceivePackHandler,
    UploadPackHandler,
    serve_command,
)

host_key = paramiko.RSAKey.generate(2048)


class GitServer(paramiko.ServerInterface):
    def __init__(self):
        self.username = None
        self.command = None
        self.exec_event = threading.Event()

    def get_allowed_auths(self, username):
        return "none"

    def check_auth_none(self, username):
        self.username = username
        return paramiko.AUTH_SUCCESSFUL

    def check_channel_request(self, kind, chanid):
        if kind == "session":
            return paramiko.OPEN_SUCCEEDED
        return paramiko.OPEN_FAILED_ADMINISTRATIVELY_PROHIBITED

    def check_channel_exec_request(self, channel, command):
        self.command = command.decode()
        self.exec_event.set()
        return True
```

When Git connects, it opens a channel and asks to execute `git-upload-pack` or `git-receive-pack`. You parse that command, pick the matching Dulwich handler, and hand the channel's input and output to Dulwich's `serve_command`, which runs the whole protocol conversation:

```python theme={null}
HANDLERS = {
    "git-upload-pack": UploadPackHandler,
    "git-receive-pack": ReceivePackHandler,
}

REPO_ROOT = "/srv/git/"


class ChannelWriter:
    def __init__(self, channel):
        self.channel = channel

    def write(self, data):
        self.channel.sendall(data)

    def flush(self):
        pass


def handle(client):
    transport = paramiko.Transport(client)
    transport.add_server_key(host_key)
    server = GitServer()
    transport.start_server(server=server)
    channel = transport.accept(20)
    if channel is None:
        return
    server.exec_event.wait(20)

    argv = shlex.split(server.command)
    handler_cls = HANDLERS.get(argv[0])
    if handler_cls is None:
        channel.close()
        return

    argv[1] = argv[1].lstrip("/")
    backend = FileSystemBackend(REPO_ROOT)
    serve_command(
        handler_cls,
        argv,
        backend=backend,
        inf=channel.makefile("rb"),
        outf=ChannelWriter(channel),
    )
    channel.send_exit_status(0)
    channel.close()
    transport.close()


def main():
    sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    sock.bind(("0.0.0.0", 2222))
    sock.listen()
    while True:
        client, _ = sock.accept()
        threading.Thread(target=handle, args=(client,), daemon=True).start()


if __name__ == "__main__":
    main()
```

Point `REPO_ROOT` at a directory of bare repositories and run it. That is a working Git server. You can clone from it, push to it, and it behaves like any other. Every user sees every branch, because `FileSystemBackend` advertises whatever refs the repository holds. That is exactly the behavior you now want to change.

## The feature: per-branch permissions

To hide a branch from a user, change what counts as "the refs" for that user. Dulwich reads a repository through a `Repo` object, and the ref advertisement flows through its `get_refs` method. Subclass `Repo`, override that one method, and filter the result by the connected user:

```python theme={null}
PERMISSIONS = {
    "alice": {"refs/heads/main", "refs/heads/dev", "refs/heads/secret"},
    "bob": {"refs/heads/main"},
}


def is_allowed(username, ref):
    return ref.decode() in PERMISSIONS.get(username, set())


class PermissionedRepo(Repo):
    username = None

    def get_refs(self):
        refs = super().get_refs()
        visible = {
            ref: sha
            for ref, sha in refs.items()
            if ref != b"HEAD" and is_allowed(self.username, ref)
        }
        head = refs.get(b"HEAD")
        if head in visible.values():
            visible[b"HEAD"] = head
        return visible
```

A small backend hands Dulwich this repo class instead of the default one, carrying the username through:

```python theme={null}
class PermissionedBackend(FileSystemBackend):
    def __init__(self, root, username):
        super().__init__(root)
        self.username = username

    def open_repository(self, path):
        repo = PermissionedRepo(self.root + path)
        repo.username = self.username
        return repo
```

Swap the `backend` line in `handle` to build a `PermissionedBackend(REPO_ROOT, server.username)`, and the filtering is in force.

The `HEAD` handling earns its two extra lines. `HEAD` tells the client which branch is the default, and a clone needs it. You drop it during the filter, then add it back only when it points at a branch the user is allowed to see. A user with no access to the default branch gets no `HEAD`, which is the right outcome.

## Trying it out

The server hands out bare repositories, the kind without a working tree. You can turn any repository you already have into one with `git clone --bare`, which copies its full set of branches:

```bash theme={null}
$ git clone --bare /path/to/some/repo /srv/git/myrepo.git
Cloning into bare repository '/srv/git/myrepo.git'...
done.

$ git -C /srv/git/myrepo.git branch
  dev
* main
  secret

$ python gitserver.py &
```

On the client side, skip host-key prompts so the demo stays quiet, then ask each user what they can see:

```bash theme={null}
$ export GIT_SSH_COMMAND='ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null'

$ git ls-remote ssh://alice@localhost:2222/myrepo.git
6a86220cc92fe113ead0a10056cf1da5a3a1fb4e	HEAD
26ce3be07260bac0cd3f976bf4c10cf7851743a0	refs/heads/dev
6a86220cc92fe113ead0a10056cf1da5a3a1fb4e	refs/heads/main
629d16e9a78f0c46a6695e11b4dc2ed50de3aa4d	refs/heads/secret

$ git ls-remote ssh://bob@localhost:2222/myrepo.git
6a86220cc92fe113ead0a10056cf1da5a3a1fb4e	HEAD
6a86220cc92fe113ead0a10056cf1da5a3a1fb4e	refs/heads/main

$ git ls-remote ssh://carol@localhost:2222/myrepo.git
```

Alice is allowed all three branches, so she sees all three. Bob is allowed only `main`, so `dev` and `secret` never reach him. Carol is in nobody's permission list, so the repository looks empty to her. A clone as Bob pulls exactly one branch:

```bash theme={null}
$ git clone ssh://bob@localhost:2222/myrepo.git
$ git -C myrepo branch -r
  origin/HEAD -> origin/main
  origin/main
```

This is not only about what gets advertised. If Bob learns the commit ID of the `secret` branch some other way and asks for it directly, the server refuses the request. Dulwich checks every requested object ID against the refs it advertised to that user, so a branch Bob can't see is a branch Bob can't fetch.

## Where this stops being a toy

The core idea scales further than the code does. A production Git server built on this shape still needs a few things this one skips.

* Real authentication. Map SSH public keys to user accounts, and reject unknown keys.
* Write permissions. The same idea applies to `git-receive-pack`. Check the user against the refs they are trying to update before accepting a push, and reject the ones they aren't allowed to change.
* Reachability rules. This server advertises no capability that lets clients request arbitrary commits by ID, which keeps hidden history hidden. A larger server has to stay deliberate about that.
* Auditing, quotas, and the hooks that run your actual deploy.

The shape holds underneath all of it. A Git server is an SSH server that runs `git-upload-pack` and `git-receive-pack`, plus whatever policy you insert at the ref advertisement.

This isn't a toy stack, either. Upsun runs Dulwich in production. The Git server that receives your `git push` and turns it into a deployment is built on the same library you used here, wired to real accounts, real infrastructure, and the policy checks this version leaves out. When you push code and only see the environments you're allowed to see, that's the ref advertisement doing its job at scale. The version you built is the version that's running.
