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 daemongotctl-- admin control utilitygotsh-- restricted login shell for SSH usersgitwrapper-- lets standardgitclients work alongsidegot
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.
