Skip to content

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

  1. Create an org at dev.azure.com (free tier, up to 5 users)
  2. Create a project (e.g. CustomsTUF)
  3. In Project Settings → Agent Pools, create a pool named CustomsTUF-Server
  4. In the pool, click New agent and follow the Windows install instructions on the server
  5. Create a new pipeline from this repository — it will pick up azure-pipelines.yml
  6. 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

git tag v1.2.3
git push origin v1.2.3

GitHub Actions builds, creates a GitHub Release, then triggers the ADO pipeline. The ADO agent on your server runs deploy/Deploy-IIS.ps1 which:

  1. Downloads customstuf-v1.2.3.zip from the GitHub Release
  2. Extracts to C:\sites\customstuf\v1.2.3\
  3. Switches the IIS site physical path to the new folder
  4. 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):
    # Run on DC — safe to re-run, does nothing if key already exists
    Add-KdsRootKey -EffectiveImmediately
    

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):

icacls "C:\ProgramData\CustomsTUF\data" /grant "YOURDOMAIN\customstuf-app$:(OI)(CI)M"

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"