Soft Serve on Ubuntu 24.04 on Azure User Guide
Overview
This guide covers the deployment and configuration of Soft Serve on Ubuntu 24.04 on Azure using cloudimg Azure Marketplace images. Soft Serve is a self hostable Git server from Charm. You host your own repositories on your own VM, and you administer the whole thing the same way you already use Git: over SSH.
What makes Soft Serve different from every other Git server is that there is no web application to run and no browser to open. Running ssh against the server drops you into a full screen terminal interface where you can browse repositories, read files, page through commit history and copy a clone command. Everything else — creating repositories, managing users and keys, setting per repository access — is an SSH command.
Because it is a single statically linked Go binary with an embedded SQLite store, this appliance needs no container runtime, no language runtime and no database server. It idles in tens of megabytes and starts in under a second.
The cloudimg image ships the free and open source, MIT licensed Soft Serve as the official upstream release archive, pinned by content hash and tied to an exact upstream commit. No administrator key, no Git host key and no database is ever baked into the image. On first boot the appliance adopts the SSH key you created the VM with as an administrator, generates a per VM operator key and a per VM HTTP credential, and proves that an unknown key is refused before it lets the server accept a connection. Backed by 24/7 cloudimg support.
Soft Serve is an independent open source project. This image is produced by cloudimg and is not affiliated with, endorsed by, or sponsored by Charmbracelet, Inc. It ships the free and open source MIT licensed software, unmodified.

What is included:
- Soft Serve v0.11.6 — the official upstream
Linux_x86_64release archive, pinned by sha256 content hash, not by a moving tag - Verified provenance: the annotated tag
v0.11.6dereferences to commit80490de86ee94fa584aa64541290272aeba20d7e, the binary self reportssoft version v0.11.6 (80490de), and theLICENSEinside the archive is byte identical to theLICENSEat that commit - Git over SSH on port
23231— clone, push, browse and administer - Git over HTTP on port
80, behind a fail closed nginx gate holding a credential generated for your VM alone - Embedded SQLite store — no database server of any kind on the instance
- Git LFS support, enabled over both SSH and HTTP
anon-accessset tono-accessand the anonymousgit://daemon disabled, so nothing is readable without a key you control- A shipped dependency licence inventory at
/usr/share/soft-serve/dependency-licences.txtcovering all 88 Go modules compiled into the binary
Prerequisites
- An Azure subscription with permission to create VMs
- An SSH key pair — create the VM with SSH public key authentication, not a password. The public key you launch with becomes your Soft Serve administrator key. (If you must use password authentication, see If you launched without an SSH key.)
- A
Standard_B2sVM or larger. Soft Serve is one 30 MB static binary plus SQLite, so 2 vCPU and 4 GiB is genuinely sufficient for a team's repositories - Inbound TCP
23231open to the networks your developers connect from — this is the Git and interface port - Inbound TCP
80open only if you want Git over HTTP as well - Inbound TCP
22open to your administrators for VM administration
Ports this appliance actually serves
Only three ports are reachable from off the VM. This is enforced by an audit that reads the kernel's own socket table on every boot path, not by assumption.
| Port | Reachable | What it is |
|---|---|---|
22 |
yes | VM administration over SSH (Azure provisioned) |
80 |
yes | Git over HTTP, behind the nginx credential gate |
23231 |
yes | Soft Serve SSH — Git and the terminal interface |
23232 |
no, loopback only | Soft Serve's HTTP endpoint; only nginx reaches it |
23233 |
no, loopback only | Soft Serve's metrics endpoint |
9418 |
disabled | The anonymous git:// daemon is turned off |
The git:// daemon is disabled deliberately. It is anonymous, unauthenticated and unencrypted, and everything it can do is available over SSH with key authentication.
Launching the VM
From the Azure Portal
- In the Azure Marketplace, search for Soft Serve on Ubuntu 24.04 LTS by cloudimg and select Create.
- Choose your subscription, resource group and region.
- Set the VM size to Standard_B2s or larger.
- Under Administrator account, choose SSH public key and supply your own public key. This key becomes your Soft Serve administrator key, so use one you control.
- Under Inbound port rules, allow SSH (22). You will add
23231(and optionally80) next. - Review and create.
From the Azure CLI
az vm create \
--resource-group <resource-group> \
--name <vm-name> \
--image cloudimg:soft-serve-ubuntu-24-04:default:latest \
--size Standard_B2s \
--admin-username azureuser \
--ssh-key-values <path-to-your-public-key> \
--public-ip-sku Standard
# Then open the Git port, and the HTTP port only if you want Git over HTTP too
az vm open-port --resource-group <resource-group> --name <vm-name> --port 23231 --priority 1010
az vm open-port --resource-group <resource-group> --name <vm-name> --port 80 --priority 1020
Restrict both rules to your own networks in the network security group. A Git server holding your source code should not be open to the whole internet.
What happens on first boot
The image ships with no database, no repositories, no Git host key and no administrator key, and the Soft Serve service is held shut until first boot has finished. On the first boot of your VM the appliance:
- Generates a per VM operator SSH key at
/root/.ssh/soft-serve-admin. - Reads the public key Azure provisioned for your admin account and adds it as an administrator alongside the operator key.
- Writes the configuration, starts Soft Serve, and lets it create its database — with those administrator keys already present.
- Sets
anon-accesstono-accessandallow-keylesstofalse, then reads both back to confirm. - Generates a stranger key pair and proves that key can neither list, clone, nor administer anything.
- Generates the per VM HTTP gate credential and proves an unauthenticated HTTP request is answered with exactly
401on a throwaway loopback listener before nginx is allowed to start. - Writes
/root/soft-serve-credentials.txt(mode0600, owned byroot) and creates its completion sentinel.
If any of those steps fails, the sentinel is never written, neither Soft Serve nor nginx is permitted to start, and the appliance stays closed. It does not come up half configured.
First boot takes around 20 seconds. Confirm it finished:
sudo test -f /var/lib/cloudimg/soft-serve-firstboot.done && echo "first boot complete"
sudo systemctl is-active soft-serve.service nginx.service
Retrieving your credentials
SSH to the VM as your admin user, then read the credentials file. It is readable by root only.
sudo cat /root/soft-serve-credentials.txt
You will see something like this. Every value is unique to your VM.
SOFT_SERVE_URL=ssh://20.51.x.x:23231
SOFT_SERVE_HTTP_URL=http://20.51.x.x/
SOFT_SERVE_SSH_PORT=23231
SOFT_SERVE_ADMIN_KEY=/root/.ssh/soft-serve-admin
SOFT_SERVE_HTTP_USER=softserve
SOFT_SERVE_HTTP_PASSWORD=<SOFT_SERVE_HTTP_PASSWORD>
SOFT_SERVE_ANON_ACCESS=no-access
SOFT_SERVE_ALLOW_KEYLESS=false
You do not normally need the operator key: your own SSH key is already an administrator. The operator key exists so the VM can administer itself and so you have a way back in if you lose your key.
Connecting: the SSH interface
From your workstation, with the key you created the VM with:
ssh -p 23231 <vm-ip>
That is the whole thing. You land in the interface shown at the top of this guide. Use the arrow keys to move, tab to switch between Repositories and About, enter to open a repository, / to filter, c to copy that repository's clone command, ? for the full key list, and q to quit.
The same port takes commands directly. Ask the server who it thinks you are:
sudo ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -n -p 23231 \
-i /root/.ssh/soft-serve-admin localhost info
And list the commands it accepts:
sudo ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -n -p 23231 \
-i /root/.ssh/soft-serve-admin localhost help
From your own workstation the equivalents are just ssh -p 23231 <vm-ip> info and ssh -p 23231 <vm-ip> help.
Your first repository
Create a repository, push to it, and clone it back. Run these on the VM to try it end to end; from your workstation, replace the sudo ssh …localhost prefix with ssh -p 23231 <vm-ip> and the clone URL with ssh://<vm-ip>:23231/<name>.
# Remove any earlier attempt first, so this example is repeatable
sudo ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -n -p 23231 \
-i /root/.ssh/soft-serve-admin localhost repo delete hello-world 2>/dev/null || true
sudo ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -n -p 23231 \
-i /root/.ssh/soft-serve-admin localhost repo create hello-world
sudo ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -n -p 23231 \
-i /root/.ssh/soft-serve-admin localhost repo list
Push a first commit into it:
sudo bash -c '
set -e
export GIT_SSH_COMMAND="ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -p 23231 -i /root/.ssh/soft-serve-admin"
export GIT_AUTHOR_NAME=cloudimg GIT_AUTHOR_EMAIL=team@example.com
export GIT_COMMITTER_NAME=cloudimg GIT_COMMITTER_EMAIL=team@example.com
rm -rf /tmp/hello-world && mkdir -p /tmp/hello-world && cd /tmp/hello-world
git init -q -b main
echo "# hello-world" > README.md
git add README.md
git commit -q -m "Initial commit"
git remote add origin ssh://localhost:23231/hello-world
git push -q origin main
echo "pushed"
'
And clone it back:
sudo bash -c '
set -e
export GIT_SSH_COMMAND="ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -p 23231 -i /root/.ssh/soft-serve-admin"
rm -rf /tmp/hello-clone
git clone -q ssh://localhost:23231/hello-world /tmp/hello-clone
cat /tmp/hello-clone/README.md
'
Give it a description so it reads well in the interface, then tidy up:
sudo ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -n -p 23231 \
-i /root/.ssh/soft-serve-admin localhost repo description hello-world "My first repository"
sudo ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -n -p 23231 \
-i /root/.ssh/soft-serve-admin localhost repo info hello-world

From your workstation the everyday form is much shorter, because your own key is already an administrator:
ssh -p 23231 <vm-ip> repo create my-projectgit clone ssh://<vm-ip>:23231/my-projectgit remote add origin ssh://<vm-ip>:23231/my-project
Git over HTTP
Port 80 serves the same repositories over HTTP, behind an nginx gate holding a credential generated for your VM alone. This is useful for CI systems and anything that cannot present an SSH key.
An unauthenticated request gets exactly 401 — including the health probes:
curl -sS -o /dev/null -w 'anonymous: HTTP %{http_code}\n' http://127.0.0.1/livez
With your VM's credential it is served:
sudo bash -c '
U=$(awk -F= "/^SOFT_SERVE_HTTP_USER=/{print \$2}" /root/soft-serve-credentials.txt)
P=$(awk -F= "/^SOFT_SERVE_HTTP_PASSWORD=/{print \$2}" /root/soft-serve-credentials.txt)
curl -sS -o /dev/null -w "authenticated: HTTP %{http_code}\n" -u "$U:$P" http://127.0.0.1/livez
'
From your workstation the same request is curl -u '<SOFT_SERVE_HTTP_USER>:<SOFT_SERVE_HTTP_PASSWORD>' http://<vm-ip>/livez.
Cloning works with the same one credential — nginx substitutes the appliance's own Soft Serve token upstream, so you never handle a second token:
sudo bash -c '
set -e
U=$(awk -F= "/^SOFT_SERVE_HTTP_USER=/{print \$2}" /root/soft-serve-credentials.txt)
P=$(awk -F= "/^SOFT_SERVE_HTTP_PASSWORD=/{print \$2}" /root/soft-serve-credentials.txt)
rm -rf /tmp/hello-http
git clone -q "http://$U:$P@127.0.0.1/hello-world" /tmp/hello-http
cat /tmp/hello-http/README.md
rm -rf /tmp/hello-http
'
From elsewhere the form is git clone http://<user>:<password>@<vm-ip>/hello-world.
Port 80 is plain HTTP, so the credential crosses the network in base64. Put TLS in front of it before you use it across an untrusted network — either an Azure Application Gateway or Front Door terminating TLS, or certbot on the VM itself. If you are not going to use Git over HTTP, simply leave port 80 closed in the network security group; SSH is unaffected.
Access control
Who is an administrator
Administrators are the keys in initial_admin_keys in /var/lib/soft-serve/data/config.yaml: the key you created the VM with, plus the per VM operator key. Nobody else is an administrator, and there is no path by which connecting first makes someone one — the server is not permitted to run at all until those keys are in place.
sudo grep -c '^ - "ssh-\|^ - "ecdsa-\|^ - "sk-' /var/lib/soft-serve/data/config.yaml
Anonymous access is off
sudo ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -n -p 23231 \
-i /root/.ssh/soft-serve-admin localhost settings anon-access
sudo ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -n -p 23231 \
-i /root/.ssh/soft-serve-admin localhost settings allow-keyless
Upstream Soft Serve defaults anon-access to read-only, which means anyone who can reach port 23231 — with any key at all, or none — can list and clone every repository. This appliance ships no-access instead, because a Git server holding your source code on a public cloud address should not be readable by strangers, and one that accepts anonymous pushes is a hosting abuse vector.
If you genuinely want a public, read only code browser, you can change it — and you should understand exactly what you are turning on:
ssh -p 23231 <vm-ip> settings anon-access read-only
That makes every non private repository readable by anyone who can reach the port. Mark anything that should stay closed as private first (ssh -p 23231 <vm-ip> repo private <name> true), and restrict the port in your network security group.

Adding people
Create a user with their public key, then add them to a repository:
ssh -p 23231 <vm-ip> user create alice --key "ssh-ed25519 AAAA... alice@example.com"
ssh -p 23231 <vm-ip> repo collab add my-project alice read-write
Access levels are no-access, read-only, read-write and admin-access. To make someone a server administrator, ssh -p 23231 <vm-ip> user create bob --admin --key "...".
If you launched without an SSH key
If the VM was created with password authentication, Azure provisioned no public key for first boot to adopt, so only the per VM operator key is an administrator. Add your own key from the VM:
sudo soft-serve-add-admin-key 'ssh-ed25519 AAAA... you@example.com'
It validates the key, refuses anything that is not one, and restarts Soft Serve.
Verifying the listening surface yourself
The appliance ships the same audit it uses internally. It reads the kernel's socket table and fails on any reachable listener outside 22, 80 and 23231:
sudo /usr/local/sbin/soft-serve-socket-audit.sh --expect-app
Day to day operation
Services
sudo systemctl status soft-serve.service --no-pager | head -12
sudo systemctl status nginx.service --no-pager | head -8
Logs
sudo journalctl -u soft-serve.service --no-pager -n 30
Backup
Everything Soft Serve owns lives in one directory: the repositories, the SQLite database, the LFS objects and the server's Git host key.
sudo bash -c 'systemctl stop soft-serve.service; \
tar czf /var/backups/soft-serve-$(date -u +%Y%m%d).tar.gz -C /var/lib/soft-serve data; \
systemctl start soft-serve.service; ls -la /var/backups/'
Copy that archive off the VM — to Azure Blob Storage, for example. Restoring is the reverse: stop the service, extract over /var/lib/soft-serve/data, chown -R softserve:softserve it, and start the service.
Note that the archive contains the Git host key and the administrator keys, so treat it as a secret.
Operating system updates
Unattended security upgrades are enabled, so the OS and nginx stay patched with no action from you.
cat /etc/apt/apt.conf.d/20auto-upgrades
Upgrading Soft Serve
The cleanest upgrade path is to launch a VM from a newer cloudimg image and restore your backup onto it, which keeps the pinned and licence audited artifact chain intact. To upgrade in place, download the release archive you want from the upstream releases page, verify its checksum against the release's checksums.txt, stop soft-serve.service, replace /usr/local/bin/soft, and start it again. Back up first.
Provenance and licences
The appliance ships its own evidence. The binary reports the commit it was built from, and the full dependency licence inventory is on disk:
/usr/local/bin/soft --version
head -20 /usr/share/soft-serve/dependency-licences.txt
awk -F'\t' '$1=="go"{print $4}' /usr/share/soft-serve/dependency-licences.txt | sort | uniq -c | sort -rn
All 88 Go modules compiled into the binary are permissively licensed — MIT, BSD, Apache-2.0 and MPL-2.0. Nothing AGPL, SSPL, BUSL, Elastic, Commons Clause, PolyForm, Prosperity or RSAL is present, and nothing GPL or LGPL is linked into the binary. (git itself, which the server invokes, is the distribution's own unmodified GPL-2.0 package.)

Troubleshooting
ssh -p 23231 <vm-ip> hangs or is refused. Check that 23231 is open in the network security group for your source address, and that the service is up: sudo systemctl is-active soft-serve.service.
The interface opens but shows no repositories. That is correct on a new server — create one with ssh -p 23231 <vm-ip> repo create <name>. If you expect repositories and see none, your key is probably not an administrator or a collaborator; check with ssh -p 23231 <vm-ip> info.
"you are not authorized to do this". Your key is not an administrator. Use the key you created the VM with, or add your key from the VM with sudo soft-serve-add-admin-key '<your public key>'.
soft-serve.service will not start. The appliance fails closed on purpose. Run the guard to see exactly what it objected to:
sudo /usr/local/sbin/soft-serve-preflight.sh --app || true
The most common cause is an empty initial_admin_keys — add a key with soft-serve-add-admin-key.
nginx will not start. Same idea, for the HTTP gate:
sudo /usr/local/sbin/soft-serve-preflight.sh --nginx || true
nginx is also held on two markers that first boot creates. If first boot did not complete, nginx will never be considered for start — check sudo journalctl -u soft-serve-firstboot.service --no-pager -n 50.
HTTP returns 401 with the right password. Confirm you are using the values from /root/soft-serve-credentials.txt on this VM. Every VM has its own.
Support
This image is published and supported by cloudimg. For assistance, contact support@cloudimg.co.uk.
For questions about Soft Serve itself, see the upstream project.