Deployment
Overview
Tag-triggered CI/CD:
flowchart LR
dev(["git tag v1.2.3\ngit push --tags"])
dev --> gha
subgraph gha["GitHub Actions"]
build["dotnet publish\nwin-x64"] --> zip["zip artifact"]
zip --> release["GitHub Release"]
release --> curl["POST to ADO\nREST API"]
end
curl --> ado
subgraph ado["Azure DevOps"]
pipeline["azure-pipelines.yml"]
end
ado --> agent
subgraph agent["Self-hosted agent (IIS server)"]
ps["Deploy-IIS.ps1"] --> extract["Extract to\nC:\\sites\\customstuf\\v1.2.3\\"]
extract --> switch["Switch IIS\nphysicalPath"]
switch --> prune["Prune old versions\n(keep 3)"]
end
Build runs entirely on GitHub (free). Only the deploy step runs on your server via the self-hosted ADO agent.
Prerequisites
On the IIS server
- Windows Server with IIS
- ASP.NET Core Hosting Bundle (.NET 10) installed
- IIS site created manually (once) — the deploy script switches the physical path, it does not create the site
- Data folder outside the site root, e.g.
C:\ProgramData\CustomsTUF\data\ - Azure DevOps self-hosted agent installed and running (see below)
Data folder isolation
SQLite databases, uploads, and generated XML must not live inside the versioned deploy folder or they will be wiped on every deploy.
tarbel.db is managed by the application itself — admins upload TARIC extraction ZIPs from the minfin portal and the TarbelImportWorker populates it. It is not a static file shipped with the build.
Recommended layout:
C:\sites\customstuf\
v1.0.0\ ← old (kept for rollback)
v1.1.0\ ← current (IIS physicalPath points here)
C:\ProgramData\CustomsTUF\data\
customstuf.db
tarbel.db
uploads\
generated_xml\
Configure paths via appsettings.Production.json or IIS app pool environment variables:
{
"ConnectionStrings": {
"DefaultConnection": "Data Source=C:\\ProgramData\\CustomsTUF\\data\\customstuf.db",
"TarbelConnection": "Data Source=C:\\ProgramData\\CustomsTUF\\data\\tarbel.db"
},
"Storage": {
"UploadFolder": "C:\\ProgramData\\CustomsTUF\\data\\uploads",
"OutputFolder": "\\\\fileserver\\customstuf\\generated_xml"
}
}
OutputFolder can be a UNC path when XML output needs to land on a network share (e.g. for pickup by a customs submission system). The app pool identity must have write access to that share — see gMSA setup below.
One-time setup
1. Azure DevOps
- Create an org at dev.azure.com (free tier, up to 5 users)
- Create a project (e.g.
CustomsTUF) - In Project Settings → Agent Pools, create a pool named
CustomsTUF-Server - In the pool, click New agent and follow the Windows install instructions on the server
- Create a new pipeline from this repository — it will pick up
azure-pipelines.yml - Note the numeric pipeline ID from the URL:
/_apis/pipelines/{ID}
2. ADO pipeline variables
In the ADO pipeline → Edit → Variables, add (mark as secret):
| Variable | Example | Secret |
|---|---|---|
GITHUB_TOKEN |
GitHub PAT with repo scope |
✅ |
IIS_SITE_NAME |
CustomsTUF |
|
IIS_SITE_ROOT |
C:\sites\customstuf |
GITHUB_TOKEN is needed to download the release asset from GitHub (required for private repos; can be omitted if repo is public).
3. GitHub secrets
In the GitHub repo → Settings → Secrets and variables → Actions:
| Secret | Description |
|---|---|
ADO_PAT |
Azure DevOps personal access token — scope: Build (Read & execute) |
ADO_ORG |
Your ADO organization name |
ADO_PROJECT |
Your ADO project name |
ADO_PIPELINE_ID |
Numeric pipeline ID from step 1 |
Deploy
GitHub Actions builds, creates a GitHub Release, then triggers the ADO pipeline. The ADO agent on your server runs deploy/Deploy-IIS.ps1 which:
- Downloads
customstuf-v1.2.3.zipfrom the GitHub Release - Extracts to
C:\sites\customstuf\v1.2.3\ - Switches the IIS site physical path to the new folder
- Prunes old versioned folders — keeps the 3 most recent
Rollback
Instant — switch the IIS physical path back to the previous version:
Import-Module WebAdministration
Set-ItemProperty "IIS:\Sites\CustomsTUF" -Name physicalPath -Value "C:\sites\customstuf\v1.1.0"
Or re-trigger the ADO pipeline manually with an older version tag.
gMSA app pool identity
The app pool must run as a Group Managed Service Account (gMSA) to:
- Write XML output to a network share (OutputFolder UNC path)
- Read the private key of the Entra ID certificate (Auth:EntraId:CertificateThumbprint) from the Windows certificate store
Prerequisites
- Active Directory domain with a domain controller running Windows Server 2012+
- KDS Root Key created on the DC (one-time, per domain):
Create the gMSA
# On the DC
New-ADServiceAccount `
-Name "customstuf-app" `
-DNSHostName "customstuf-app.yourdomain.local" `
-PrincipalsAllowedToRetrieveManagedPassword "YourIISServer$" # machine account of IIS server
Install on the IIS server
# On the IIS server (run once)
Install-ADServiceAccount -Identity "customstuf-app"
Test-ADServiceAccount -Identity "customstuf-app" # should return True
Set the app pool identity
In IIS Manager → Application Pools → CustomsTUF → Advanced Settings → Identity:
- Select Custom account
- Username: YOURDOMAIN\customstuf-app$ (note the trailing $)
- Password: (leave blank — gMSA has no password)
Or via PowerShell:
Import-Module WebAdministration
$pool = Get-Item "IIS:\AppPools\CustomsTUF"
$pool.processModel.userName = "YOURDOMAIN\customstuf-app$"
$pool.processModel.password = ""
$pool.processModel.identityType = 3 # SpecificUser
$pool | Set-Item
Grant required permissions
Data folder (local):
Network share (on the file server):
# Grant write access to the output share
Grant-SmbShareAccess -Name "customstuf" -AccountName "YOURDOMAIN\customstuf-app$" -AccessRight Full -Force
icacls "\\fileserver\customstuf" /grant "YOURDOMAIN\customstuf-app$:(OI)(CI)M"
Certificate private key:
The Entra ID certificate must be imported into the Local Machine store (not Current User) so the app pool identity can access it:
# Import cert to Local Machine\My
Import-PfxCertificate -FilePath "entra-cert.pfx" -CertStoreLocation Cert:\LocalMachine\My
# Find the cert and grant private key read access to the gMSA
$cert = Get-ChildItem Cert:\LocalMachine\My | Where-Object { $_.Thumbprint -eq "YOUR_THUMBPRINT" }
$rsaKey = [System.Security.Cryptography.X509Certificates.RSACertificateExtensions]::GetRSAPrivateKey($cert)
$keyPath = Join-Path $env:ProgramData "Microsoft\Crypto\RSA\MachineKeys" $rsaKey.Key.UniqueName
icacls $keyPath /grant "YOURDOMAIN\customstuf-app$:R"