Obfuscate .NET in Azure DevOps Pipelines
A concrete Azure DevOps YAML pipeline that obfuscates your .NET app on Release only, licenses just the build agent, and keeps the symbol map as an artifact.
If your team ships .NET software out of Azure DevOps, the protection question is really a pipeline question. You don’t want every developer running an obfuscator on their laptop, and you don’t want to remember to protect a build by hand. You want the pipeline to produce one artifact — the one customers get — already hardened, with everything else staying clean and debuggable. This post is the concrete YAML for doing that in Azure Pipelines: obfuscate on Release only, license just the agent, and keep the symbol map.
I’ve written the GitHub Actions version of this workflow separately — the principles are identical, so this focuses on the Azure DevOps specifics rather than repeating the fundamentals.
TL;DR
- Obfuscate in the pipeline, not on dev machines. Developers build clean, debuggable binaries; the agent produces the one hardened artifact you ship.
- Release only. Gate the obfuscation step on build configuration or branch so validation builds stay fast.
- License the agent, not the team. For Microsoft-hosted (ephemeral) agents use an offline signed license file from a secret;
NEBULA_LICENSEis a file path, not a key. - Order matters: publish → obfuscate → sign. Signing first invalidates the signature and anti-tamper hashes.
- Keep the
.symbols.jsonmap as an artifact per release so you can read production stack traces.
Why the pipeline is the right place
.NET compiles to IL, and IL decompiles back to near-original C# in seconds — open any of your own DLLs in ILSpy or Glass.NET and you’ll see it. Obfuscation rewrites that IL so it resists reading and reconstruction. But where you run it decides whether the practice survives contact with a real team:
- On developer machines: everyone needs the tool and a license, local builds get hard to debug, and someone eventually ships an unprotected build by accident.
- In the pipeline (recommended): obfuscation runs once, on the official Release build, on an agent you control. Developers build normally, only the agent is licensed, and the published artifact is always protected.
This is the build-server-only model, and Azure DevOps stages map onto it cleanly.
The config
Nebula.NET is driven entirely by a JSON config that lists the assemblies to protect and what to do to them. Add a small nebula.config.json to your repo:
{
"inputs": ["publish/MyApp.dll"],
"outputDirectory": "protected",
"encryptStrings": true,
"controlFlow": true,
"antiTamper": true
}
List all of your own interdependent assemblies together in inputs so renaming stays consistent across them. Nebula writes the protected copies, a MyApp.symbols.json rename map, and a report into outputDirectory.
The pipeline
Here’s a two-stage azure-pipelines.yml. The Build stage runs on every push and produces a normal, unobfuscated build your tests run against. The Release stage runs only for tagged/main builds and is the one that obfuscates and publishes the protected artifact.
trigger:
branches:
include: ['main']
tags:
include: ['v*']
variables:
buildConfiguration: 'Release'
stages:
- stage: Build
jobs:
- job: build_and_test
pool:
vmImage: 'windows-latest'
steps:
- task: UseDotNet@2
inputs:
version: '8.0.x'
- script: dotnet build -c $(buildConfiguration)
displayName: 'Build'
- script: dotnet test -c $(buildConfiguration)
displayName: 'Test (unprotected)'
- stage: ReleaseProtected
# Only protect real releases — not every CI validation build.
condition: and(succeeded(), startsWith(variables['Build.SourceBranch'], 'refs/tags/v'))
jobs:
- job: publish_protected
pool:
vmImage: 'windows-latest'
steps:
- task: UseDotNet@2
inputs:
version: '8.0.x'
# 1. Publish the Release output as usual.
- script: dotnet publish src/MyApp/MyApp.csproj -c $(buildConfiguration) -o publish
displayName: 'Publish'
# 2. Make the Nebula.NET CLI available on the agent. The CLI ships inside the
# Nebula download (from delta1labs.com/download) — stage your copy and add it to PATH.
- powershell: |
Invoke-WebRequest -Uri "$(NEBULA_CLI_URL)" -OutFile nebula.zip
Expand-Archive nebula.zip -DestinationPath "$(Agent.TempDirectory)/nebula"
Write-Host "##vso[task.prependpath]$(Agent.TempDirectory)/nebula"
displayName: 'Install Nebula.NET CLI'
# 3. Write the offline license file from a secret variable.
# NEBULA_LICENSE is a PATH to the file, not a key string.
- powershell: |
Set-Content -Path "$(Agent.TempDirectory)/nebula-license.json" -Value "$(NEBULA_LICENSE_FILE)"
displayName: 'Write license'
# 4. Obfuscate — Nebula is config-driven (there is no "obfuscate" verb).
- script: nebula --config nebula.config.json
displayName: 'Obfuscate (Release only)'
env:
NEBULA_LICENSE: '$(Agent.TempDirectory)/nebula-license.json'
# 5. (If you sign) sign AFTER obfuscation — never before.
# - script: signtool sign ... protected/MyApp.dll
# 6. Publish the protected assemblies as the release artifact.
- task: PublishPipelineArtifact@1
inputs:
targetPath: 'protected'
artifact: 'MyApp-protected'
displayName: 'Publish protected artifact'
# 7. Publish the symbol map SEPARATELY — keep it, never ship it inside the product.
- task: PublishPipelineArtifact@1
inputs:
targetPath: 'protected/MyApp.symbols.json'
artifact: 'symbol-map'
displayName: 'Publish symbol map'
Store NEBULA_LICENSE_FILE and NEBULA_CLI_URL as secret pipeline variables (or in a variable group / Azure Key Vault), not in the YAML.
Licensing the agent
There are two mechanisms, and which you use depends on the agent:
- Microsoft-hosted agents are a fresh VM per run, so use an offline signed license file: store its contents in a secret variable, write it to disk in the job, and point
NEBULA_LICENSEat that path (as above). It needs no online activation, so you never burn a seat per run.NEBULA_LICENSEis a file path — aLIC-…key string there won’t resolve. - Self-hosted agents persist, so you can instead activate an online key once on the machine and let every build reuse that seat. Don’t do that on hosted agents, or each ephemeral run looks like a “new machine” and exhausts your seats.
Keeping the Release stage genuinely Release-only
The condition on the ReleaseProtected stage is doing the real work: only tagged builds get obfuscated. That matters for two reasons. First, your PR and CI validation builds stay fast and produce debuggable binaries your tests run against unprotected — which is where you want to be debugging. Second, you never accidentally protect (and slow down) an inner-loop build.
If you’d rather obfuscate during compile than as a separate step, Nebula’s MSBuild integration runs after compile when the NebulaObfuscate property is set. Gate it on a pipeline variable that only exists in the Release stage and your dotnet build command doesn’t change. Note that build integration is a licensed feature — the CLI step above also runs on the free edition (with caps), the MSBuild path requires a license on the agent.
Verify, and mind the order
Never trust a protection step you haven’t inspected. Download the artifact and open the DLL in ILSpy, dnSpy or Glass.NET: flattened methods should show a while(true){ switch } maze, strings should be gone, and names stripped except the public API and anything reflection needs. Then run your test suite against the protected artifact, not just the clean one — see how to obfuscate a .NET assembly for what to check.
Two ordering rules save the most grief:
- Obfuscate before signing. Obfuscation rewrites the assembly, so signing first invalidates both the signature and the anti-tamper integrity hashes. Order is publish → obfuscate → sign.
- Obfuscate before packing single-file/trimmed output. Protect the assemblies first, then pack, and test that combination specifically.
The honest limits
A pipeline makes protection consistent; it doesn’t make it absolute. Everything here is client-side hardening — it raises the cost of reverse-engineering and patching, it doesn’t make your code uncrackable. Reflection, serialization, DI and XAML resolve members by name at runtime, so a rename that hits one of those is the classic “worked in dev, broke in prod” failure; use a tool that detects and preserves them and exclude anything custom. And license enforcement that has to be trustworthy belongs on a server you control, not in the client no matter how well protected.
Reading production crashes
Because you renamed everything, a production stack trace comes back in gibberish — unless you kept the map. That’s why the pipeline publishes MyApp.symbols.json as its own artifact for every release. To turn a renamed trace back into original names:
nebula deobfuscate --map MyApp.symbols.json --input crash.txt
Treat the map like a PDB: archived per release, kept private, never shipped inside the product.
Get started
Grab the free edition and wire the CLI step into a throwaway pipeline first — it’ll run end to end, with caps on a couple of features. When you’re ready to ship across a whole assembly, Nebula.NET gives you control-flow flattening, string encryption, anti-tamper and agent-friendly licensing; pricing has the editions. Protect the artifact the pipeline ships, keep the map, and let developers keep their clean builds.
Try Nebula.NET
Harden your .NET code in minutes — start with the free edition.