Configuration

Self-Hosting Git on OpenBSD with gotd and gotwebd

This guide walks you through setting up your own Git hosting server on OpenBSD from scratch, starting from a fresh VPS with a domain name. By the end you will have:

  • gotd -- the Game of Trees daemon, serving Git repositories over SSH
  • gotwebd -- a read-only web frontend to browse your repositories in a browser
  • TLS certificates via acme-client (Let's Encrypt)
  • Two reverse proxy options: plain httpd or relayd (for multi-site setups)

gotd is written by OpenBSD developers and built around OpenBSD security primitives -- pledge(2), unveil(2), and a multi-process architecture. It is not a GitHub replacement. There is no web push, no pull requests, no admin panel. It is a clean, minimal, secure Git server that speaks the standard Git SSH protocol, meaning any git or got client works with it without modification.

gotwebd is strictly read-only. It only shows repository contents in a browser. All write access happens over SSH through gotd.


Assumptions

This guide uses the following sample values. Replace them with your real information everywhere:

192.0.2.1
Your server's IPv4 address
2001
db8::1:Your server's IPv6 address
example.com
Your main domain
git.example.com
Subdomain for the web interface
alice
Your Unix username on the server

Part 1 -- Setting Up gotd

Step 1 -- Install the package

gotd is not in the OpenBSD base system. Install it from packages:

doas pkg_add gotd

This installs four programs:

  • gotd -- the repository server daemon
  • gotctl -- admin control utility
  • gotsh -- restricted login shell for SSH users
  • gitwrapper -- lets standard git clients work alongside got

Step 2 -- Create the repository directory

All bare repositories live under /var/git. Create it and give ownership to the _gotd system user:

doas mkdir /var/git
doas chown -R _gotd:_gotd /var/git
doas chmod 750 /var/git

Why 750 and not 755? The mode 750 means only _gotd and members of the _gotd group can read the directory. This matters because gotd.conf controls access permissions -- if other local users could read /var/git directly they could bypass the access rules you configure. Setting 750 enforces that all access goes through gotd.

Step 3 -- Initialize a repository

Create a bare repository using got init. Always run this as _gotd so the files are owned correctly:

doas -u _gotd got init /var/git/myproject.git

A bare repository has no working tree -- it only contains the git object store and metadata. The .git suffix is conventional for bare repos and gotd expects it.

Note: got init creates repositories with a default branch of main. If you push from a client configured with master as the default branch name you will see a branch mismatch error. See the Troubleshooting section for the fix.

Step 4 -- Configure /etc/gotd.conf

# /etc/gotd.conf

# The system user gotd runs as
user _gotd

# Unix socket gotd listens on
listen on "/var/run/gotd.sock"

repository "myproject" {
    path "/var/git/myproject.git"
    permit rw alice      # your unix username -- read and write
    permit ro anonymous  # anyone can clone, no password needed
}

Access rules are evaluated top to bottom and the last matching rule wins. If no rule matches, access is denied. The permit rw line grants push and pull over SSH. The permit ro anonymous line allows passwordless read-only cloning -- remove it if you want a private server.

Add more repositories by repeating the repository block:

repository "anotherproject" {
    path "/var/git/anotherproject.git"
    permit rw alice
}

Test the configuration before starting:

doas gotd -n

Step 5 -- Configure gotsh for SSH access

gotd does not handle SSH itself -- OpenBSD's sshd does. gotsh is a restricted shell that intercepts the SSH session and hands it off to gotd via the Unix socket. You need to set gotsh as the login shell or forced command for any user who will access repositories over SSH.

Option A -- Forced command in authorized_keys (recommended)

This keeps your normal shell for interactive login while restricting Git operations to gotsh when connecting with a specific key. Edit ~/.ssh/authorized_keys on the server:

command="/usr/local/bin/gotsh" ssh-ed25519 AAAA... yourkey

Option B -- Set gotsh as the login shell

If you want the user to have no interactive shell access at all:

doas usermod -s /usr/local/bin/gotsh alice

Warning: If you do this for your own account you will lose interactive SSH access. Only use this for dedicated git-only accounts.

Step 6 -- Anonymous read-only access (optional)

To allow anyone to clone without a password, create an anonymous user:

doas useradd -m -s /usr/local/bin/gotsh -d /var/git anonymous

Add these lines to /etc/ssh/sshd_config:

Match User anonymous
    PasswordAuthentication yes
    PermitEmptyPasswords yes
    DisableForwarding yes
    PermitTunnel no
    PermitTTY no

Reload sshd:

doas rcctl reload sshd

Step 7 -- Enable and start gotd

doas rcctl enable gotd
doas rcctl start gotd
doas rcctl check gotd

Step 8 -- Push from your local machine

On your local machine, initialize a repository and push:

git init myproject
cd myproject
git add .
git commit -m "first commit"
git remote add origin ssh://alice@example.com/myproject
git push -u origin main

Tip: The repository name in the SSH URL matches the repository block name in gotd.conf, not the directory path. So repository "myproject" { path "/var/git/myproject.git" } is accessed as ssh://alice@example.com/myproject. Appending .git also works.

Step 9 -- Set repository description and owner

These fields appear in the gotwebd web interface.

The description is a plain text file inside the bare repo:

doas -u _gotd sh -c 'echo "My project description" > /var/git/myproject.git/description'

The owner and description can also be set via the git config file inside the repo. Since got has no config subcommand and git may not be installed on your server, edit the file directly:

doas -u _gotd vi /var/git/myproject.git/config

Add at the bottom:

[gitweb]
    owner = Alice

Part 2 -- TLS Certificates with acme-client

Before setting up the web interface you need a TLS certificate for git.example.com. OpenBSD includes acme-client which automates Let's Encrypt certificate issuance and renewal.

Step 1 -- Configure /etc/acme-client.conf

authority letsencrypt {
    api url "https://acme-v02.api.letsencrypt.org/directory"
    account key "/etc/ssl/private/letsencrypt.key"
}

domain git.example.com {
    domain key "/etc/ssl/private/git.example.com.key"
    domain fullchain certificate "/etc/ssl/git.example.com.fullchain.pem"
    sign with letsencrypt
}

Step 2 -- Configure httpd for the ACME challenge

acme-client proves you control the domain by serving a challenge file over HTTP on port 80. Your httpd.conf needs a location block for this. Add a server block for the git subdomain:

server "git.example.com" {
    listen on * port 80
    location "/.well-known/acme-challenge/*" {
        root "/acme"
        request strip 2
    }
    location "/*" {
        block return 301 "https://$SERVER_NAME$REQUEST_URI"
    }
}

Create the acme directory inside httpd's chroot if it does not exist:

doas mkdir -p /var/www/acme

Reload httpd then issue the certificate:

doas rcctl reload httpd
doas acme-client git.example.com

Step 3 -- Automate renewal

Certificates expire after 90 days. Add a cron job as root:

doas crontab -e

Add this line:

0 0 * * * acme-client git.example.com && rcctl reload httpd

If you use relayd for TLS termination (Part 4B), use rcctl reload relayd instead of rcctl reload httpd.


Part 3 -- Setting Up gotwebd

Step 1 -- Install gotwebd

doas pkg_add gotwebd

This installs the gotwebd binary at /usr/local/sbin/gotwebd and places static assets at /var/www/htdocs/gotwebd.

Step 2 -- Fix permissions

gotwebd runs as _gotwebd and needs read access to /var/git which is owned by _gotd at mode 750. Add _gotwebd to the _gotd group:

doas usermod -G _gotd _gotwebd

Verify the group was added:

id _gotwebd

You should see _gotd in the groups output.

Create the tmp directory gotwebd needs inside httpd's chroot:

doas mkdir -p /var/www/got/tmp
doas chown www:daemon /var/www/got/tmp

Create the run directory for the FastCGI socket inside the chroot:

doas mkdir -p /var/www/run
doas chown _gotwebd:_gotwebd /var/www/run

Why /var/www/run? httpd runs in a chroot at /var/www. When httpd.conf specifies a FastCGI socket path like /run/gotwebd.sock, httpd looks for it at /var/www/run/gotwebd.sock on disk. gotwebd must create its socket at that same path.

Step 3 -- Configure /etc/gotwebd.conf

# /etc/gotwebd.conf

# Socket path is inside httpd's chroot (/var/www)
listen on socket "/var/www/run/gotwebd.sock"

server "localhost" {
    repos_path "/var/git"
    site_name  "My Git"
    site_owner "Alice"
    site_link  "git.example.com"
}

Important: The listen on socket line is required. Without it gotwebd uses a default socket path that does not match what httpd expects, the socket never gets created, and httpd returns 500 Internal Server Error.

Step 4 -- Enable and start gotwebd

doas rcctl enable gotwebd
doas rcctl start gotwebd

Verify the socket was created:

ls -la /var/www/run/gotwebd.sock

If the socket file does not appear, gotwebd crashed on startup. Check the log:

doas tail -20 /var/log/daemon

Part 4A -- Reverse Proxy: httpd with TLS (simple setup)

Use this if you are not running relayd and want httpd to handle TLS directly.

Add or update /etc/httpd.conf:

types { include "/usr/share/misc/mime.types" }

# HTTP -- redirect to HTTPS, serve ACME challenges
server "git.example.com" {
    listen on * port 80
    location "/.well-known/acme-challenge/*" {
        root "/acme"
        request strip 2
    }
    location "/*" {
        block return 301 "https://$SERVER_NAME$REQUEST_URI"
    }
}

# HTTPS -- serve gotwebd via FastCGI
server "git.example.com" {
    listen on * tls port 443
    tls {
        certificate "/etc/ssl/git.example.com.fullchain.pem"
        key         "/etc/ssl/private/git.example.com.key"
    }
    root "/htdocs/gotwebd"

    # Serve static assets directly
    location "/htdocs/gotwebd/*" {
        pass
    }

    # Everything else goes to gotwebd FastCGI
    location "/*" {
        fastcgi socket "/run/gotwebd.sock"
    }
}

Check config and reload:

doas httpd -n
doas rcctl reload httpd

Part 4B -- Reverse Proxy: relayd (multi-site setup)

Use this if you are already running relayd to proxy other services (a main website, IRC bouncer, etc.). relayd terminates TLS and routes by Host header. httpd only listens on loopback on a separate port and never sees TLS directly.

Traffic flow:

Internet --> relayd :443 (TLS) --> httpd :8080 --> gotwebd FastCGI --> /var/git
                               --> httpd :80   --> main site
                               --> ZNC   :1338 --> IRC bouncer

relayd.conf

Add the new lines marked with # ADD to your existing /etc/relayd.conf:

ip4="192.0.2.1"
ip6="2001:db8::1"

table <www> { 127.0.0.1 }
table <znc> { 127.0.0.1 }
table <git> { 127.0.0.1 }            # ADD

log connection

http protocol https {
    match request header append "X-Forwarded-For" value "$REMOTE_ADDR"
    match request header append "X-Forwarded-By" \
        value "$SERVER_ADDR:$SERVER_PORT"
    match request header set "Connection" value "close"
    match request header set "X-Forwarded-Proto" value "https"
    tcp { sack, backlog 128 }
    tls { keypair example.com }
    tls { keypair bnc.example.com }
    tls { keypair git.example.com }   # ADD
    match request header "Host" value "example.com" forward to <www>
    match request header "Host" value "bnc.example.com" forward to <znc>
    match request header "Host" value "git.example.com" forward to <git>  # ADD
}

relay wwwtls {
    listen on $ip4 port 443 tls
    protocol https
    forward to <www> port 80
    forward to <znc> port 1338
    forward to <git> port 8080        # ADD
}

relay www6tls {
    listen on $ip6 port 443 tls
    protocol https
    forward to <www> port 80
    forward to <znc> port 1338
    forward to <git> port 8080        # ADD
}

httpd.conf (relayd setup)

httpd listens on loopback port 8080 for the git site. Add this server block:

server "git.example.com" {
    listen on 127.0.0.1 port 8080
    root "/htdocs/gotwebd"

    location "/.well-known/acme-challenge/*" {
        root "/acme"
        request strip 2
    }

    location "/htdocs/gotwebd/*" {
        pass
    }

    location "/*" {
        fastcgi socket "/run/gotwebd.sock"
    }
}

Reload both daemons:

doas httpd -n && doas rcctl reload httpd
doas relayd -n && doas rcctl reload relayd

Part 5 -- Troubleshooting

500 Internal Server Error

This almost always means httpd cannot reach the gotwebd FastCGI socket. Check in order:

# 1. Is gotwebd running?
doas rcctl check gotwebd

# 2. Does the socket exist?
ls -la /var/www/run/gotwebd.sock

# 3. Check the daemon log
doas tail -30 /var/log/daemon

If the socket does not exist, gotwebd is crashing. The most common cause is a missing listen on socket line in /etc/gotwebd.conf. Add it and restart:

listen on socket "/var/www/run/gotwebd.sock"
doas rcctl restart gotwebd

reference refs/heads/main not found

gotwebd shows this when the repository's HEAD points to a branch that does not exist. It happens when you push to master but the repository was initialized expecting main.

Fix -- point HEAD at your actual branch:

doas -u _gotd sh -c \
  'echo "ref: refs/heads/master" > /var/git/myproject.git/HEAD'

Restart gotwebd and refresh the page.

To avoid this permanently, set your local default branch to main before creating repos:

git config --global init.defaultBranch main

doas: git: command not found

git is not installed on OpenBSD by default. For tasks like setting gitweb.owner that would normally use git config, edit the config file directly:

doas -u _gotd vi /var/git/myproject.git/config

# Add at the bottom:
[gitweb]
    owner = Alice

Unnamed repository in gotwebd

The description file still has the default placeholder text. Overwrite it:

doas -u _gotd sh -c 'echo "My project" > /var/git/myproject.git/description'

Permission denied when pushing

Check that your Unix username matches the permit rw line in /etc/gotd.conf exactly, then reload gotd:

doas rcctl reload gotd

Also verify gotsh is set as the forced command or login shell for your user. Test by SSHing to the server -- if you get a normal shell prompt instead of a gotsh error message, gotsh is not configured.

gotwebd cannot read repositories

Verify _gotwebd is in the _gotd group:

id _gotwebd

If _gotd is missing:

doas usermod -G _gotd _gotwebd
doas rcctl restart gotwebd

Part 6 -- Quick Reference

Key files

/etc/gotd.conf
gotd configuration -- repositories and access rules
/etc/gotwebd.conf
gotwebd configuration -- socket path, repos path, site info
/var/git/
Root directory for all bare repositories
/var/git/repo.git/description
Plain text description shown in gotwebd
/var/git/repo.git/config
Git config file -- add [gitweb] owner here
/var/www/run/gotwebd.sock
FastCGI socket (inside httpd chroot)
/var/www/got/tmp
Temp directory for gotwebd (inside httpd chroot)
/etc/acme-client.conf
Let's Encrypt certificate configuration
/var/log/daemon
gotd and gotwebd log output

Useful commands

doas rcctl restart gotwebd
Restart gotwebd after config changes
doas rcctl reload gotd
Reload gotd.conf without dropping connections
doas gotd -n
Validate gotd.conf syntax
doas gotctl info
Show running gotd info (run as root)
doas relayd -n
Validate relayd.conf syntax
doas httpd -n
Validate httpd.conf syntax
doas acme-client -v git.example.com
Issue or renew certificate verbosely
doas tail -f /var/log/daemon
Watch live daemon logs

This guide was written based on a real OpenBSD setup using got-0.123, gotd-0.123, gotwebd-0.123 on OpenBSD 7.8.

All contributors agree to submit code, audio, video, text, and any other content under the following license:

Copyright (C) 2026 by VoidKrypt <voidkrypt@nastycode.com>

Permission is granted to use, copy, modify, and/or distribute this work for any purpose with or without fee. This work is offered as-is, with absolutely no warranty whatsoever. The author is not responsible for any damages that result from using this work.