17 July 2026
Uploading to Steam from Horde BuildGraph
A cook-to-Steam pipeline in BuildGraph: keeping steamcmd credentials out of the job, VDF templates that default to not going live, generating them robustly, and the path details that make one script work across projects.
This one follows on from my post on running Horde CI on a single build machine. Once a build cooks cleanly, the next job is getting it onto Steam, from BuildGraph, on that same on-prem agent, and without stuffing any credentials into the job itself.
The whole thing is one BuildGraph script with two nodes: build and stage a client, then upload it. Here it is in full, it lives in each project’s Build/Steam/ folder in Perforce, and everything project-specific comes in as an -set: option so one copy drives any title:
<?xml version='1.0' ?>
<!--
Build, cook, stage a Win64 client and upload it to Steam.
Auth model: a dedicated Steam build account whose session is CACHED on the agent
(run `steamcmd +login <user>` once interactively to satisfy Steam Guard). After
that `+login <user>` works non-interactively, so no password/2FA is in the job.
-->
<BuildGraph xmlns="http://www.epicgames.com/BuildGraph"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.epicgames.com/BuildGraph ../../../Engine/Build/Graph/Schema.xsd">
<Option Name="ProjectFile" DefaultValue="Game/Game.uproject" Description="Path to the .uproject, relative to the branch root"/>
<Option Name="GameTarget" DefaultValue="Game" Description="Game target name (from *.Target.cs)"/>
<Option Name="Configuration" Restrict="Development|Test|Shipping" DefaultValue="Development" Description="Client build configuration"/>
<Option Name="SteamAppId" DefaultValue="" Description="Steam App ID"/>
<Option Name="SteamDepotId" DefaultValue="" Description="Steam Depot ID for the Windows client"/>
<Option Name="SteamBranch" DefaultValue="" Description="Steam beta branch to SetLive (blank = upload only, no SetLive)"/>
<Option Name="SteamUser" DefaultValue="" Description="Steam build account username (session cached on the agent)"/>
<Option Name="SteamCmdExe" DefaultValue="C:\steamcmd\steamcmd.exe" Description="Path to steamcmd.exe on the agent"/>
<!-- Derived paths inside the agent workspace. All keyed off GameTarget so the
same script is correct in every stream it's dropped into. -->
<Property Name="StageRoot" Value="$(RootDir)/LocalBuilds/Steam/$(GameTarget)"/>
<Property Name="ContentRoot" Value="$(StageRoot)/Windows"/>
<Property Name="ScriptDir" Value="$(StageRoot)/SteamScripts"/>
<Property Name="OutputDir" Value="$(StageRoot)/SteamOutput"/>
<Property Name="AppVdf" Value="$(ScriptDir)/app_build.vdf"/>
<Property Name="DepotVdf" Value="$(ScriptDir)/depot_build.vdf"/>
<Property Name="TemplateDir" Value="$(RootDir)/$(GameTarget)/Build/Steam"/>
<Agent Name="Steam Upload Agent" Type="Win64">
<!-- 1) Compile + cook + stage + pak a Win64 client into ContentRoot -->
<Node Name="Build And Stage Client">
<Log Message="Staging $(GameTarget) ($(Configuration)) to $(ContentRoot)"/>
<Command Name="BuildCookRun"
Arguments="-project="$(ProjectFile)" -target=$(GameTarget) -platform=Win64
-clientconfig=$(Configuration) -build -cook -stage -pak -archive
-archivedirectory="$(StageRoot)" -nop4 -nodebuginfo -utf8output"/>
</Node>
<!-- 2) Resolve the VDF templates, then hand off to steamcmd -->
<Node Name="Upload To Steam" Requires="Build And Stage Client">
<Error Message="SteamAppId, SteamDepotId and SteamUser are required"
If="'$(SteamAppId)' == '' or '$(SteamDepotId)' == '' or '$(SteamUser)' == ''"/>
<!-- Fill the VDF templates via a script file - named params, so no inline
quoting has to survive the spawn (see below). -->
<Spawn Exe="powershell.exe"
Arguments="-NoProfile -ExecutionPolicy Bypass -File "$(TemplateDir)/GenerateSteamVdf.ps1"
-ScriptDir "$(ScriptDir)" -OutputDir "$(OutputDir)"
-ContentRoot "$(ContentRoot)" -TemplateDir "$(TemplateDir)"
-AppId $(SteamAppId) -DepotId $(SteamDepotId) -Branch "$(SteamBranch)""/>
<!-- Upload using the cached Steam session on this agent -->
<Spawn Exe="$(SteamCmdExe)"
Arguments="+login $(SteamUser) +run_app_build "$(AppVdf)" +quit"/>
</Node>
</Agent>
<Aggregate Name="Build and Upload to Steam" Requires="Upload To Steam"/>
</BuildGraph>
The Horde template that drives it
The graph’s -set: options come from the Horde template. This is also where the operator-facing knobs live, the parameters block turns into the inputs you fill in when you trigger a build:
{
"id": "build-upload-steam",
"name": "Build + Upload to Steam (Win64)",
"description": "Compiles, cooks and stages a Win64 client, then uploads it to Steam via steamcmd.",
"initialAgentType": "Win64",
"arguments": [
"-Script=Game/Build/Steam/UploadToSteam.xml",
"-Target=Build and Upload to Steam",
"-set:ProjectFile=$(ProjectPath)",
"-set:GameTarget=$(GameTarget)",
"-set:SteamAppId=YOUR_APP_ID",
"-set:SteamDepotId=YOUR_DEPOT_ID",
"-set:SteamUser=YOUR_BUILD_ACCOUNT"
],
"parameters": [
{
"type": "List",
"label": "Configuration",
"items": [
{ "text": "Development", "argumentIfEnabled": "-set:Configuration=Development", "default": true },
{ "text": "Test", "argumentIfEnabled": "-set:Configuration=Test" },
{ "text": "Shipping", "argumentIfEnabled": "-set:Configuration=Shipping" }
]
},
{
"type": "Text",
"label": "Steam branch (SetLive)",
"argument": "-set:SteamBranch=",
"default": "",
"hint": "e.g. internal (leave blank to upload without going live)"
}
]
}
The App ID, Depot ID and build-account name are fixed per project so they sit in arguments. The two parameters are per-run choices: a Configuration dropdown and a free-text Steam branch box. Leave the branch empty and the upload doesn’t go live, which matters, so it’s the default.
Logging in without secrets in the job
Steam Guard makes non-interactive login awkward, which is fair enough. The approach that works is logging in once, by hand, on the agent:
steamcmd +login <builduser>
You enter the password and the Steam Guard code that one time. steamcmd caches the session in its own install directory (not under a user profile), so after that +login <builduser> goes through without prompting. The job never sees a password or a 2FA code, and there’s nothing Steam-related sitting in Perforce or the Horde config.
One thing to get right: whoever the Horde agent runs as needs to be able to read that cached session. Because steamcmd keeps it in its install directory rather than per-user, a service account running the agent can usually reach it, but if a login prompt turns up mid-job, a user mismatch there is the first place I’d look.
The VDF templates
Steam drives an upload from two files. app_build.vdf says what to upload, which depots, and whether to go live; depot_build.vdf is the file mapping. I keep both checked in as templates with placeholder tokens and fill them in at build time with the run’s paths and IDs.
// app_build.template.vdf
"appbuild" {
"appid" "__APPID__"
"desc" "Build (Horde)"
"buildoutput" "__OUTPUT__"
"contentroot" "__CONTENTROOT__"
"setlive" "__BRANCH__" // blank = upload but don't go live
"depots" { "__DEPOTID__" "depot_build.vdf" }
}
// depot_build.template.vdf
"DepotBuildConfig" {
"DepotID" "__DEPOTID__"
"FileMapping" { "LocalPath" "*" "DepotPath" "." "recursive" "1" }
"FileExclusion" "*.pdb"
}
Leaving setlive blank by default is the safety rail. An upload with a blank branch lands on Steam but doesn’t publish to anyone until you pass a branch on purpose, so first runs can go up dark and you can check the depot contents in the partner backend before a single player sees them.
That check is worth doing at least once, because of contentroot. BuildCookRun -archive drops the staged client into a platform subfolder and I point contentroot at $(StageRoot)/Windows. If your target archives to WindowsClient instead, contentroot points at an empty directory and you’ll happily upload a zero-byte depot, invisible until you look at what actually landed on Steam, and a one-line fix in the template once you know.
Generating the VDFs
The token substitution needs to run somewhere, and the robust way to do it from BuildGraph is a script file called with named parameters, not an inline -Command. Anything inline has to survive the XML attribute, BuildGraph’s argument assembly, and Windows CreateProcess in turn, and quoted backslashes (which you need for Windows paths) don’t come through that intact. Named parameters passed to -File don’t get re-parsed as a command, so there’s nothing to mangle.
<Spawn Exe="powershell.exe"
Arguments="-NoProfile -ExecutionPolicy Bypass
-File "$(TemplateDir)/GenerateSteamVdf.ps1"
-ScriptDir "$(ScriptDir)" -OutputDir "$(OutputDir)"
-ContentRoot "$(ContentRoot)" -TemplateDir "$(TemplateDir)"
-AppId $(SteamAppId) -DepotId $(SteamDepotId)
-Branch "$(SteamBranch)""/>
param(
[Parameter(Mandatory=$true)] [string]$ScriptDir,
[Parameter(Mandatory=$true)] [string]$OutputDir,
[Parameter(Mandatory=$true)] [string]$ContentRoot,
[Parameter(Mandatory=$true)] [string]$TemplateDir,
[Parameter(Mandatory=$true)] [string]$AppId,
[Parameter(Mandatory=$true)] [string]$DepotId,
[string]$Branch = ''
)
$ErrorActionPreference = 'Stop'
New-Item -ItemType Directory -Force -Path $ScriptDir, $OutputDir | Out-Null
# Steam wants native Windows separators in the VDF paths.
$contentRootWin = $ContentRoot -replace '/', '\'
$outputWin = $OutputDir -replace '/', '\'
$appVdf = Join-Path $ScriptDir 'app_build.vdf'
$depotVdf = Join-Path $ScriptDir 'depot_build.vdf'
# Literal String.Replace, NOT -replace: a backslash here isn't a regex escape.
$app = Get-Content -Raw -Path (Join-Path $TemplateDir 'app_build.template.vdf')
$app = $app.Replace('__APPID__', $AppId)
$app = $app.Replace('__DEPOTID__', $DepotId)
$app = $app.Replace('__OUTPUT__', $outputWin)
$app = $app.Replace('__CONTENTROOT__', $contentRootWin)
$app = $app.Replace('__BRANCH__', $Branch)
Set-Content -NoNewline -Path $appVdf -Value $app
$depot = Get-Content -Raw -Path (Join-Path $TemplateDir 'depot_build.template.vdf')
$depot = $depot.Replace('__DEPOTID__', $DepotId)
Set-Content -NoNewline -Path $depotVdf -Value $depot
Write-Host "Wrote $appVdf and $depotVdf"
Two details in there that are easy to trip on. Use String.Replace() rather than the -replace operator for the token substitution, otherwise a \ in the replacement value is read as a regex escape and quietly corrupts the output. And do convert the paths to native \ separators: the archive path comes back from BuildGraph with mixed slashes (D:\HordeAgent\...\Sync/LocalBuilds/...), and Steam wants backslashes throughout the VDF.
One reason all the paths in the graph key off $(GameTarget) rather than a literal project folder: I drop the same script into each project’s Build/Steam/ folder, and a hardcoded path would resolve to the wrong place on every stream but one.
That’s the whole pipeline, and honestly it’s not much code once the paths and the VDF generation are sorted. Let me know if you’re setting up something similar and get stuck, happy to compare notes.