20 June 2026
Self-hosting Perforce and Horde on one cheap cloud box
How a three-person studio runs Perforce Helix Core and Epic's Horde on a single cloud server with one on-prem build agent: the architecture, how each piece is configured, and the decisions that keep it small.
Most of the posts in this series are about a specific problem we solved while running our build system. This one is the foundation they all sit on: how the stack is put together, and how each piece is configured. If you’re weighing up self-hosting Perforce and Horde for a small Unreal team, this is the starting point.
I wrote a longer, Horde-first walkthrough on my personal site a while back. This is the studio version, and it gives the Perforce side equal weight, because on a small team the two are really one system.
Why self-host
We’re three people. Hosted CI billed per build-minute gets expensive quickly when a single Unreal editor compile runs into tens of minutes, and managed Perforce hosting isn’t cheap either. Self-hosting trades money for a bounded amount of up-front setup time, and after that it costs us very little and we own all of it.
Horde is also just the right tool for the job. It’s built by Epic specifically for Unreal, so it understands streams, precompiled binaries and BuildGraph natively in a way no general-purpose CI does. It’s aimed at larger teams than ours, which means a few of its defaults assume more scale and staffing than we have. Most of this series is about adapting it to a studio our size.
What you’ll need
A few prerequisites make or break this, so they’re worth stating before the how.
Epic and GitHub access, linked. Both the engine source and Horde itself come from Epic through GitHub. Link your Epic account to GitHub and join the EpicGames organisation, and you can then clone Unreal Engine and pull Horde’s container image from Epic’s registry on ghcr.io. That access is what the container-registry login in the setup uses; without it, nothing bootstraps.
Perforce’s free tier. Helix Core is free for up to 5 users and 20 workspaces, which is exactly what makes self-hosting realistic for a small studio. Beyond that you pay per seat, and standard users count against the limit, but for a team our size it’s plenty of headroom.
Enough memory on the box. Mongo, Horde, Redis and p4d all share the one instance, and memory is the constraint that bites first, not CPU. Give it more than the bare minimum: a couple of gigabytes runs out of headroom under a real build, and the first symptom is the OOM killer taking down p4d. We run a mid-tier instance with a swapfile as a backstop, and even so the whole server side comes in well under a hundred euros a month, which is the entire argument for doing it this way.
The architecture
The whole server side is one cloud instance: a mid-tier box on Hetzner running Ubuntu, with a separate block volume mounted for all persistent data so nothing important lives on the instance disk. On that box:
- Perforce Helix Core (
p4d), serving over SSL, with its depot on the mounted volume. - Horde, run through Docker Compose alongside the two services it needs: MongoDB for its database and Redis for coordination.
- TLS terminated on 443 for both the dashboard and the agent connection.
Off the box entirely sits one on-prem Windows build agent. That desktop does all the compiling and cooking. A 24-core machine you already own is dramatically cheaper than renting equivalent cloud compute by the hour, and Unreal builds want every core you can give them. So the cloud box coordinates work and stores artifacts, and the desktop does the heavy lifting. The box, its block volume and the object storage behind it are provisioned with Terraform, so the infrastructure underneath is reproducible even where the service config on top still gets applied by hand.
What Terraform provisions
The server side is a single main.tf. It doesn’t manage the software on the box, that’s cloud-init and the config-as-code covered further down, but it owns everything underneath: the machine, its storage, the firewall, and the buckets we back up to.
The depot lives on a block volume rather than the instance disk, and it’s marked so Terraform can never tear it down:
resource "hcloud_volume" "depot" {
name = "${var.studio_name}-depot"
size = var.volume_size_gb
location = var.server_location
format = "ext4"
lifecycle {
prevent_destroy = true # never destroy the depot volume via terraform
}
}
The server itself is a stock Ubuntu image. Everything that turns it into a Perforce and Horde host happens in cloud-init, which Terraform renders with the values it needs. The secrets come from variables, so nothing sensitive lives in the repo:
resource "hcloud_server" "studio" {
name = "${var.studio_name}-server"
server_type = var.server_type
image = "ubuntu-24.04"
location = var.server_location
ssh_keys = [for k in hcloud_ssh_key.team : k.id]
firewall_ids = [hcloud_firewall.studio.id]
user_data = templatefile("${path.module}/cloud-init/server.yaml", {
# depot volume, domain, and service accounts.
# passwords and API keys come from tfvars, never committed.
...
})
depends_on = [hcloud_volume.depot]
}
That cloud-init/server.yaml is where Perforce actually gets onto the machine. On first boot it adds Perforce’s own apt repository and installs helix-p4d, then runs a setup script and brings Horde up with Docker Compose:
runcmd:
# Install Perforce Helix Core from Perforce's apt repository
- |
wget -qO - https://package.perforce.com/perforce.pubkey | \
gpg --dearmor -o /usr/share/keyrings/perforce.gpg
echo "deb [signed-by=/usr/share/keyrings/perforce.gpg] \
https://package.perforce.com/apt/ubuntu noble release" \
> /etc/apt/sources.list.d/perforce.list
apt-get update -qq
apt-get install -y helix-p4d
# Configure P4ROOT on the volume, write the p4dctl service, generate the
# SSL certificate, and start it
- bash /tmp/p4d-setup.sh
# Authenticate to Epic's container registry and start Horde
- |
echo "${github_pat}" | docker login ghcr.io -u "${github_username}" --password-stdin
cd /opt/horde && docker compose pull && docker compose up -d
So a terraform apply on a fresh box gives you an installed, SSL-enabled Perforce server and a running Horde with no manual steps to reach that point. The pieces the rest of this post configures, the Perforce protections, the Horde config-as-code, are what you layer on top once it’s up. Everything below is the detail inside that p4d-setup.sh and the Compose stack.
The firewall is deliberately narrow: SSH, Perforce over SSL, and HTTP/HTTPS for the dashboard and its certificate challenge. Nothing else:
resource "hcloud_firewall" "studio" {
name = "${var.studio_name}-firewall"
rule {
direction = "in"
protocol = "tcp"
port = "22" # SSH
source_ips = ["0.0.0.0/0", "::/0"]
}
rule {
direction = "in"
protocol = "tcp"
port = "1666" # Perforce over SSL
source_ips = ["0.0.0.0/0", "::/0"]
}
rule {
direction = "in"
protocol = "tcp"
port = "80" # ACME challenge + HTTPS redirect
source_ips = ["0.0.0.0/0", "::/0"]
}
rule {
direction = "in"
protocol = "tcp"
port = "443" # Horde dashboard + agents
source_ips = ["0.0.0.0/0", "::/0"]
}
# Plus an ICMP rule for ping. Horde's raw ports, 13340 (dashboard) and
# 13342 (agent gRPC), are deliberately NOT exposed; everything public
# arrives on 443.
}
The two object-storage buckets are the offsite half of the backup story:
resource "aws_s3_bucket" "p4_backups" {
provider = aws.hetzner_s3
bucket = "${var.studio_name}-p4-backups"
}
A cron job on the box takes a daily Perforce checkpoint and syncs it plus the versioned files up to that bucket, and separately snapshots the depot volume (keeping the last seven). The volume snapshot matters specifically because Hetzner’s native server backups don’t include attached volumes, so a whole-server backup would quietly miss the depot, which is the one thing you can’t lose.
Setting up Perforce
The package is installed by cloud-init, as above; this is what the setup script then configures. Point P4ROOT at the mounted block volume so the repository lives on durable storage independent of the instance, run p4d as a dedicated perforce user, and manage it through p4dctl, Perforce’s own service supervisor. One requirement worth noting when you write the p4dctl service config: give it an explicit PATH in its environment block, otherwise p4dctl start refuses with a “missing required parameter PATH”. The server then starts with sudo -u perforce p4dctl start <name>.
Turn on SSL from the beginning. Set P4SSLDIR, generate the server’s key and certificate, and serve on ssl:1666. It’s much easier to start encrypted than to migrate clients over later, and Horde will want to connect over SSL anyway.
Bootstrap the first user with p4 user -i. On a brand-new server there’s no authenticated super user yet, so the force flag (-f), which is super-only, can’t be used. Create the initial account by piping a user spec into p4 user -i, then set the protections table to make it super, and administer normally from there.
Two things are specific to running Unreal on a Linux server, and both are decisions to make deliberately rather than discover later:
- A Linux
p4dis case-sensitive. Windows Perforce clients aren’t, so pick your casing up front and hold every client and stream reference to it. Our engine stream is//UE5/main, lowercase, and every workspace, import and depot path names it that way. Consistency here is the whole game. - Configure the typemap and P4IGNORE for Unreal. Set
p4 typemapso binary assets like.uassetand.umapare stored asbinary+l(exclusive-locked, since they can’t be merged), and settle on a singleP4IGNOREfilename that every workstation sets. Ours is.p4ignore.txt; the exact name matters less than everyone using the same one.
Stream layout
The way the streams are organised does a lot of quiet work, so it’s worth laying out. Three groups of streams: the engine, the shared code, and the games.
//UE5/main Unreal Engine, carrying our own engine modifications
//UE5/upgrade staging branch for each new Epic release
//shared/main plugins shared across titles
//shared/game-a one branch per title
//shared/game-b
//game-a/main a title: imports the engine + its shared code
//game-b/main
The engine gets its own mainline, and upgrades are staged. //UE5/main holds Unreal Engine with our modifications on top. New Epic releases don’t go straight into it, because that would collide our changes with theirs in the one stream everyone works against. Instead each release lands in a child branch, //UE5/upgrade, and is then merged into //UE5/main, where we resolve the conflicts against our own engine changes once, deliberately, rather than in the middle of everyone’s day. The disruptive part of an engine upgrade stays isolated until it’s reconciled.
Each game is its own depot and mainline stream, and imports the engine. A game workspace needs the engine and the game together, so the game stream imports the engine rather than duplicating it. The stream spec shares the game’s own folder and imports the engine from //UE5/main:
share game-a/...
import+ ... //UE5/main/...
The import+ (rather than a plain import) matters: it mounts the engine into the game workspace and lets engine edits made from that workspace be submitted back to //UE5/main. So a per-game engine tweak flows back to the shared engine rather than being stranded in one project.
Shared code lives in its own depot, branched per title. Plugins we want across projects live in //shared, with a stream per game (//shared/game-a) that each title brings in. Branching the shared code per project, rather than everyone syncing one live copy, means a game can sit on its own state of a plugin and we integrate changes between titles when it’s convenient, instead of every change to a shared plugin immediately landing in every game. It’s the same reason the engine has an upgrade branch: keep the shared thing shared, but let each consumer control when it takes changes.
Setting up Horde
Horde runs from a small Docker Compose stack: the horde-server container, MongoDB, and Redis, with all state persisted onto the same block volume as the depot. Bring those up and the server is running; the rest is configuration.
Config-as-code lives in Horde’s data root. Horde reads its configuration from a set of JSON files in its data directory, the one Compose mounts as /app/Data. Put globals.json there, alongside the project and stream files it pulls in. Worth stating plainly because it isn’t obvious from the environment variables: the files load from the data root itself, not a config/ subdirectory beneath it.
The globals.json schema (version 2) wants three things: "version": 2, an include that pulls in Horde’s bundled defaults, and your own perforceClusters and projects defined under plugins.build rather than at the top level. The most reliable examples aren’t in the documentation, they’re in the engine tree you’ve already synced, under Engine/Source/Programs/Horde/HordeServer/Defaults/. Copy the structure from there and adapt it.
Authentication is set in server.json, also in the data root, which Horde creates with defaults on first run. Edit it to set the auth method to Horde’s built-in account system (AuthMethod set to Horde) unless you’re integrating an external identity provider. The admin sets a password on first launch and you manage accounts from Server, then Accounts in the dashboard.
Connecting Horde to Perforce
Define your Perforce server as a cluster in globals.json, then give Horde what it needs to trust the connection. Because Perforce is serving over SSL, Horde has to accept the server’s certificate fingerprint before it can even run a p4 info. Generate a P4 trust file (p4 trust -y against your ssl:host:1666, with P4TRUST pointed at a file in the data root) and mount that file into the container via the P4TRUST environment variable. With that in place the cluster reports healthy in the dashboard; without it, it shows as unhealthy with a failed info query, which looks like a credentials problem but isn’t.
One log note so it doesn’t distract you: running Horde single-instance, you’ll see occasional Redis connection warnings tied to scheduled events. Single-instance mode coordinates in memory, Redis is reachable, and those particular lines are benign.
TLS and the agent connection
The dashboard and the build agents both come in over 443, so that port needs TLS. There are two reasonable ways to do it.
The lower-effort option is a reverse proxy like Caddy in front of Horde. It handles Let’s Encrypt certificates automatically and does the dashboard-versus-agent routing described just below, so certificates are one less thing to think about. One DNS note if you go this way: with your domain on Cloudflare, set the dashboard record to DNS-only (grey cloud) rather than proxied, or the ACME challenge fails with what looks like a certificate error.
We’ve since let Horde’s own Kestrel terminate TLS directly on 443, pointing it at a PFX certificate in the data root. That removes the proxy entirely, at the cost of managing certificate renewal ourselves. Both are fine; start with the proxy if you want certificates handled for you, move to Kestrel if you’d rather run one fewer service.
How the agents reach it on 443
What makes one public port serve both the dashboard and the agents is that they speak different protocols. The browser talks HTTP/1.1 to the dashboard and REST API; the agents talk gRPC, which runs over HTTP/2. Horde exposes those internally as two endpoints, 13340 for the HTTP side and 13342 for the gRPC side, and neither faces the internet.
With Caddy in front, it terminates TLS on 443 and routes by content type: a request whose Content-Type is application/grpc is forwarded to the gRPC endpoint (as cleartext h2c on the local Docker network), and everything else goes to the HTTP endpoint. With Kestrel terminating TLS itself, the split happens through protocol negotiation instead: a TLS client on 443 negotiates HTTP/2 via ALPN if it wants it, so a gRPC agent and a browser both connect to the same port and Kestrel serves each the right way.
Either way the agent only ever knows the server as https://your-domain on 443. Port 13342 lives entirely inside the box, which is why it’s absent from the firewall.
The build agent
The agent is a Windows desktop we own. Install the Horde agent from the dashboard’s Tools download, point it at the server URL, and it connects and appears in the dashboard as a pending agent. Approve it, assign it to a pool, and it’s ready to take work. After that it’s hands-off: the server delivers and auto-updates the agent software itself, so there are no versions to manage by hand on the machine.
From there it’s pool and workspace configuration, which is where the next posts in this series pick up: how the agent’s workspaces are laid out, and how BuildGraph work is scheduled onto it. That’s the part that actually earns its keep, so it gets its own posts rather than a paragraph here.
Where this goes
That’s the foundation: a case-sensitive Perforce server over SSL, Horde reading config from its data root and trusting Perforce, TLS on 443, and one on-prem agent doing the real work. Everything else in this series is a specific problem we hit running it, an upload timing out, a job syncing the wrong workspace, symbols that wouldn’t download. If you’re setting this up yourself and hit something I haven’t covered, I’d be glad to compare notes.