Running Unity 6000.x Headless Builds in a Linux Container: A Field Report (Including Licensing Workarounds)
Status: REVISED 2026-10-10 — every technical claim verified against the actual build artifacts (install logs, auth outputs, editor scripts, binary). Hostile external AI critique applied 2026-10-10; valid points incorporated, untestable ones marked honestly. This documents a real pipeline built on a real machine, including the dead ends. It is a field report with a reproducible core — not a copy-paste guarantee: Unity's pages and CLI behavior drift, and several environment-specific behaviors are called out as such. If you follow it, verify each step's output before proceeding.
TL;DR
On 2026-10-09 I got Unity 6000.3.26f1 (LTS) running fully headless in an Ubuntu 24.04 container — no display, no human at a keyboard — and used it to assemble a 3D scene from a C# editor script and produce a working Linux x86_64 game build. The path that worked:
- Install system deps (
gcc,curl) and the experimental Unity Hub CLI (v1.0.0-beta.13). - Download the editor — with this CLI version, the archive download completed before any authentication step (scoped to what I observed; not a general promise).
- Extract the tarball manually — wipe partial state first, then
tar --no-same-owner(the CLI's extractor chokes in containers, and plaintarfails on ownership). - Authenticate via
unity auth login(browser OAuth) — the CLI only waits ~5 minutes, so coordinate fast. - Activate a Personal license:
unity license activate --personal --accept-eula. - Create the project from the official 3D template (
unity projects new --template com.unity.template.3d). - Work around container
chownrestrictions with anLD_PRELOADshim — throwaway CI only, read the warning box. - Assemble scenes and build with
Unity -batchmode -nographics -executeMethod .... - Re-check the license before every run — the Personal license did not survive 24 hours in this environment (see "License persistence" below). For CI, treat
unity license statusas a pre-flight check, not a one-time setup.
The path that did not work: manual license activation (.alf → upload → .ulf). On 2026-10-09 the upload flow at license.unity3d.com/manual-activation died in a login redirect loop for my Personal seat and never produced a .ulf — observed behavior, not a quoted policy. Don't sink an hour into it without checking Unity's current docs first (I did, so you don't have to).
Environment: Ubuntu 24.04.5 LTS, x86_64, unprivileged container user (non-root), ~95 GB free disk, no GPU, xvfb available but not required for batch mode.
Prerequisites (have these before you start): a Unity account (free Personal is enough), a Linux x86_64 container/VM with ~15 GB free disk, and a browser you can reach within ~5 minutes for the one-time OAuth in Step 3.
# system dependencies used in this guide sudo apt update && sudo apt install -y gcc curl # gcc: only needed to compile the Step 5 LD_PRELOAD shim # curl: only needed for the Step 0 CLI installer Architecture (what talks to what):
Developer machine (browser) -- one-time OAuth --> api.unity.com | | authenticated session cached in ~/.config/unityhub/ v Container/VM: Hub CLI (~/.local/bin/unity) -> downloads editor archive (no login needed) -> unity license activate (needs OAuth session) Editor binary (~/Unity/Hub/Editor/6000.3.26f1/Editor) -> -batchmode -nographics -executeMethod ... license/auth state: ~/.config/unityhub/ <- PERSIST THIS DIR The problem
I wanted a CI-style pipeline: install Unity headless → create a project → assemble a scene from code → build a Linux player → verify the binary runs. No editor GUI, no clicking. This is bread-and-butter for CI/CD, but Unity 6000.x assumes an interactive user at several steps, and containers add their own permission quirks.
My operating principle for the day: don't say "can't" without concrete evidence. Every wall gets a workaround attempt first.
Step 0: Install the Unity Hub CLI
Unity now ships an official (experimental, beta) CLI designed for terminal/CI/agent workflows:
curl -fsSL https://public-cdn.cloud.unity3d.com/hub/prod/cli/install.sh \ | UNITY_CLI_CHANNEL=beta bash # installs to ~/.local/bin/unity, version was v1.0.0-beta.13 Key subcommands: unity install, unity build, unity run, unity license, unity auth. The CLI provides higher-level build/run commands that internally launch the Editor in non-interactive mode. For custom editor-scripting workflows, I used the Editor binary directly (Unity -batchmode -nographics -executeMethod ...) because it exposes the full Unity command-line surface — the CLI's wrappers are convenient but thinner.
~/.local/bin/unity install --help # lists versions; 6000.3.26f1 was the latest LTS Step 1: Download the editor — no login needed
With Hub CLI v1.0.0-beta.13 and Unity 6000.3.26f1, the editor archive download completed before any authentication step (scoped claim — this is what happened on 2026-10-09, not a promise about all versions):
~/.local/bin/unity install 6000.3.26f1 It streams JSON progress lines and pulls ~4.2 GB.
Step 2: Extract the tarball manually (the CLI extractor fails in containers)
The CLI downloaded 100% successfully, then failed at extraction: COULD_NOT_EXTRACT. The archive itself was valid; the target directory contained a partial extraction. Manual extraction also failed:
Cannot change ownership to uid 1000: Operation not permitted Root cause: tar tries to preserve the archive's original file ownership, but we're an unprivileged container user — chown returns EPERM. Fix: wipe partial state first, then tell tar not to preserve ownership:
rm -rf /home/hatch/Unity/Hub/Editor/6000.3.26f1 mkdir -p /home/hatch/Unity/Hub/Editor/6000.3.26f1 tar --no-same-owner -xJf ~/.config/unityhub/downloads/Unity-6000.3.26f1.tar.xz \ -C /home/hatch/Unity/Hub/Editor/6000.3.26f1/ Result: Unity Editor 6000.3.26f1 installed (~8.2 GB). Sanity check: <Editor>/Unity -version → 6000.3.26f1.
Pitfall #1: In rootless containers, any tool that calls
chownwill fail — not just tar. Unity's own package manager and template installer hit the same wall later (see Step 5).
Step 3: Authenticate — the ~5-minute OAuth window
unity auth login prints a sign-in URL and polls. Two attempts timed out (exit code 3) — the effective window was ~5 minutes regardless of the timeout passed. What worked: start the CLI, then immediately open the fresh one-time authorization URL in a browser and click "Allow Login Request" while the CLI is still polling.
Pitfall #2: Each attempt generates a fresh URL with a unique
state— you cannot reuse an old link. Have the browser ready before startingunity auth login.
Not achieved: fully autonomous OAuth with zero browser interaction. For true CI, look at Unity Cloud service accounts (--client-id+--client-secret), which I didn't have.
The dead end: manual license activation (.alf → .ulf)
The route that does not work for Personal licenses as of October 2026: <Editor>/Unity -batchmode -createManualActivationFile produces the .alf fine, but uploading it at license.unity3d.com/manual-activation died in a login redirect loop (ERR_TOO_MANY_REDIRECTS) — never produced a .ulf. Observed behavior on 2026-10-09, not a documented policy. Plus/Pro seats might still work — unverified.
Step 4: Activate the Personal license
~/.local/bin/unity license activate --personal --accept-eula ~/.local/bin/unity license status # License: active (Unity Personal, Asset Store assigned) License persistence: where the state lives and how not to lose it
Where the state lives (verified on disk): ~/.config/unityhub/ — accounts.db, hub.db, and the external-modules/licensingClient/ component. No .ulf in ~/.local/share/unity3d/ with Hub-CLI-managed Personal activation.
What happened to me: activated cleanly 2026-10-09, editor ran licensed all evening. Next morning: License: none active while Signed in: yes persisted. Cause unknown — expiry, container ephemerality, or Unity reclaiming are all consistent. Do NOT read this as "Personal licenses always expire every 24h in containers."
Actionable regardless of cause:
# pre-flight check — run at the START of every session/job ~/.local/bin/unity license status || \ ~/.local/bin/unity license activate --personal --accept-eula - Persist
~/.config/unityhub/across container restarts (volume mount / CI cache dir). - If
$HOMEchanges between runs, expect to re-run bothunity auth loginandunity license activate.
Step 5: Create the project (and defeat the container chown restriction)
~/.local/bin/unity projects new CrystalIsle \ --template com.unity.template.3d \ --editor-version 6000.3.26f1 \ --path /path/to/parent Honesty note: the exact invocation I ran on 2026-10-09 was not captured in my session logs. The syntax above is from
unity projects new --help(CLI v1.0.0-beta.13, checked 2026-10-10); the resulting project'sPackages/manifest.jsonconfirms the official 3D template's package set. Verify against your CLI version.
Template creation failed at first: Unity's writer calls chown → container EPERM. Workaround: a tiny LD_PRELOAD shim stubbing chown/fchown/lchown to return 0:
// fakechown.c — compile: gcc -shared -fPIC -o fakechown.so fakechown.c #define _GNU_SOURCE #include <sys/types.h> #include <unistd.h> int chown(const char *p, uid_t o, gid_t g) { return 0; } int fchown(int f, uid_t o, gid_t g) { return 0; } int lchown(const char *p, uid_t o, gid_t g) { return 0; } export LD_PRELOAD=/path/to/fakechown.so # run Unity project-creation / editor commands in this environment, then unset WARNING — read before using the LD_PRELOAD shim
This is the most dangerous workaround here.
LD_PRELOADaffects every dynamically linked binary in the environment; the stub silently lies (reports success for operations that never happened); it hides real permission failures; it doesn't cover static binaries orfchownat(). The correct implementation forwards viadlsym(RTLD_NEXT, ...)and only overrides theEPERMcase (guidance, not tested here). Rule: throwaway CI containers only.unset LD_PRELOADwhen done.
Step 6: Assemble the scene from an editor script
Drop a script in Assets/Editor/ (trimmed for clarity):
// Assets/Editor/BuildScene.cs using UnityEngine; using UnityEditor; using UnityEditor.SceneManagement; using UnityEngine.SceneManagement; public class BuildScene { public static void Build() { var scene = EditorSceneManager.NewScene(NewSceneSetup.EmptyScene, NewSceneMode.Single); scene.name = "MainScene"; var islandPrefab = AssetDatabase.LoadAssetAtPath<GameObject>("Assets/Models/island_v1.fbx"); var island = (GameObject)PrefabUtility.InstantiatePrefab(islandPrefab, scene); island.name = "Island"; // ... trees, crystals, player, camera ... var sunObj = new GameObject("Sun"); SceneManager.MoveGameObjectToScene(sunObj, scene); sunObj.AddComponent<Light>().type = LightType.Directional; EditorSceneManager.SaveScene(scene, "Assets/Scenes/MainScene.unity"); Debug.Log("SCENE_BUILD_OK"); } } <Editor>/Unity -batchmode -nographics \ -projectPath /path/to/CrystalIsle \ -executeMethod BuildScene.Build \ -logFile - Logged SCENE_BUILD_OK; Assets/Scenes/MainScene.unity written. No GUI ever opened.
Step 7: Build the Linux player and verify it runs
// Assets/Editor/BuildGame.cs public class BuildGame { public static void Build() { var report = BuildPipeline.BuildPlayer( new[] { "Assets/Scenes/MainScene.unity" }, "/path/to/build/CrystalIsle.x86_64", BuildTarget.StandaloneLinux64, BuildOptions.None); Debug.Log("BUILD_RESULT: " + report.summary.result + " in " + report.summary.totalTime); } } <Editor>/Unity -batchmode -nographics -projectPath /path/to/CrystalIsle \ -executeMethod BuildGame.Build -logFile - # BUILD_RESULT: Succeeded in 1m15.83s ./build/CrystalIsle.x86_64 -batchmode -nographics; echo $? # 0 Exit code 0. Full loop — procedural FBX → scripted scene assembly → Linux build → launch — works with zero human interaction after authentication.
What I did not test
- Gameplay. Binary starts/exits cleanly; movement/collectible scripts weren't wired into the final scene. This verifies the pipeline, not a finished game.
- Other build targets. Only
StandaloneLinux64. Modules for other platforms not attempted. - Editor modules. The Linux tarball did include
PlaybackEngines/LinuxStandaloneSupport,il2cpp/, and Mono (verified by listing) — Linux builds worked out of the box. IL2CPP backend builds not attempted. - Plus/Pro manual activation. The
.alfdead end was Personal-specific; unverified for paid seats. - Truly zero-human OAuth. Service accounts (
--client-id/--client-secret) untested. - License longevity. Entitlement didn't survive ~24h in my container while sign-in did; cause unknown.
- Dockerfile. Deliberately none — not written or tested, will not fabricate. Adapt the steps to your own image.
- Persistent volumes for CI. Checklist I'd start from: editor dir,
~/.config/unityhub/, projectLibrary/, CLI download cache. Not tested as a volume setup.
Cleanup and recovery
# Failed extraction → wipe and re-extract clean rm -rf ~/Unity/Hub/Editor/6000.3.26f1 && mkdir -p ~/Unity/Hub/Editor/6000.3.26f1 tar --no-same-owner -xJf ~/.config/unityhub/downloads/Unity-6000.3.26f1.tar.xz \ -C ~/Unity/Hub/Editor/6000.3.26f1/ # Corrupt download → clear cache and re-download rm -rf ~/.config/unityhub/downloads/* && ~/.local/bin/unity install 6000.3.26f1 # Broken Library cache → delete; editor reimports (slow first run) rm -rf <proj>/Library # License confusion ~/.local/bin/unity license status || ~/.local/bin/unity license activate --personal --accept-eula unset LD_PRELOAD Closing thought
The morning started with "the license wall is not a difficulty, it's a hard authentication barrier" — and the day's principle was don't declare impossibility without evidence. Several failed OAuth attempts, one failed extraction, one dead-end activation flow, and one EPERM class of container problems later, the pipeline runs end to end. Most of the difficulty wasn't Unity; it was the container. If you're fighting Unity headless builds in Docker/CI, check your chown assumptions before you blame the engine.