Deployment
Overview
Tag-triggered CI/CD:
flowchart TD
dev(["git tag v1.2.3<br/>git push --tags"])
dev --> gha
subgraph gha["GitHub Actions"]
build["dotnet publish<br/>win-x64"] --> zip["zip artifact"]
zip --> release["GitHub Release"]
release --> curl["POST to ADO<br/>REST 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<br/>C:\\sites\\customstuf\\v1.2.3\\"]
extract --> switch["Switch IIS<br/>physicalPath"]
switch --> prune["Prune old versions<br/>(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
Application data that is written by the app (uploads/temp files) must not live inside the versioned deploy folder or it will be wiped on every deploy.
Databases are expected to run on SQL Server (DefaultConnection and TarbelConnection). Tarbel content is still managed by the app via TARIC ZIP import in /Admin/Tarbel.
Recommended layout:
C:\sites\customstuf\
v1.0.0\ ← old (kept for rollback)
v1.1.0\ ← current (IIS physicalPath points here)
C:\ProgramData\CustomsTUF\data\
uploads\
Configure paths via appsettings.Production.json or IIS app pool environment variables:
{
"ConnectionStrings": {
"DefaultConnection": "Server=sql-host,1433;Database=customstuf;User Id=app;Password=***;TrustServerCertificate=true",
"TarbelConnection": "Server=sql-host,1433;Database=tarbel;User Id=app;Password=***;TrustServerCertificate=true"
},
"Storage": {
"UploadFolder": "C:\\ProgramData\\CustomsTUF\\data\\uploads"
}
}
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:
- 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"
dv — Docker / NAS deployment
This section applies to the dv (development / self-hosted) environment only. It uses GitHub Container Registry and Docker — no IIS, no ADO, no Windows required.
How it works
Every tag matching docker-* triggers .github/workflows/docker.yml, which builds a
multi-stage .NET 10 Docker image and pushes it to ghcr.io:
git tag docker-0.6.0
git push origin docker-0.6.0
-> GitHub Actions builds image
-> pushes ghcr.io/rousseauxy/customstuf:latest
-> pushes ghcr.io/rousseauxy/customstuf:<version>
-> pushes ghcr.io/rousseauxy/customstuf:<major>.<minor>
The NAS either pulls manually or uses Watchtower to pick up the new image automatically.
Cost: GitHub Actions free tier provides 2,000 minutes/month.
A full .NET build takes ~2-4 minutes; layer caching (type=gha) reduces repeat builds to ~30-60 s.
Normal commit frequency stays well within the free allowance.
Persistent volumes
| Container path | What goes there |
|---|---|
/app/uploads |
Uploaded PDFs / XLSXes and temp files |
Mount /app/uploads so files survive image updates.
One-time NAS setup
1. Authenticate to ghcr.io (required for private repos)
Create a GitHub PAT with the read:packages scope, then on the NAS:
2. Create persistent directories
3. Create a .env file (never commit this to git)
# /volume1/docker/customstuf/.env
MSSQL_SA_PASSWORD=your-sql-password
AZURE_TENANT_ID=your-tenant-id
AZURE_CLIENT_ID=your-client-id
AZURE_CLIENT_SECRET=your-client-secret
4. Create the compose file
# /volume1/docker/customstuf/docker-compose.dv.yml
services:
customstuf:
image: ghcr.io/rousseauxy/customstuf:latest
restart: unless-stopped
ports:
- "8080:8080"
volumes:
- /volume1/docker/customstuf/uploads:/app/uploads
environment:
ASPNETCORE_ENVIRONMENT: Production
Auth__Provider: EntraId
ConnectionStrings__DefaultConnection: Server=192.168.25.5,1433;Database=customstuf;User Id=sa;Password=${MSSQL_SA_PASSWORD};TrustServerCertificate=true
ConnectionStrings__TarbelConnection: Server=192.168.25.5,1433;Database=tarbel;User Id=sa;Password=${MSSQL_SA_PASSWORD};TrustServerCertificate=true
AI__Provider: AzureOpenAI
AI__AzureOpenAI__Endpoint: https://your-resource.openai.azure.com/
AI__AzureOpenAI__DeploymentName: gpt-4o
AI__AzureOpenAI__UseKeylessAuth: "true"
DocumentIntelligence__Endpoint: https://your-resource.cognitiveservices.azure.com/
DocumentIntelligence__UseKeylessAuth: "true"
Descartes__Smf__ValidateXsd: "true"
Descartes__Smf__XsdFolder: /app/schemas/smf
Azure__TenantId: ${AZURE_TENANT_ID}
Azure__ClientId: ${AZURE_CLIENT_ID}
Azure__ClientSecret: ${AZURE_CLIENT_SECRET}
/app/schemas/smf is bundled in the image during publish and contains SMF.xsd and included schema files.
5. Start
Updating manually
Auto-updating with Watchtower (optional)
Watchtower polls ghcr.io every 5 minutes and
restarts the container when a new latest is published:
docker run -d \
--name watchtower \
--restart unless-stopped \
-v /var/run/docker.sock:/var/run/docker.sock \
-e WATCHTOWER_CLEANUP=true \
containrrr/watchtower \
--interval 300 \
customstuf
For private registries, also pass:
Configuration reference
All settings follow ASP.NET Core's Section__Key env-var convention.
| Variable | Default in image | Description |
|---|---|---|
ASPNETCORE_ENVIRONMENT |
Production |
Environment name (Development loads appsettings.Development.json) |
Descartes__Smf__ValidateXsd |
true |
Enables SMF XSD validation in the wrapper service |
Descartes__Smf__XsdFolder |
/app/schemas/smf |
Folder containing SMF.xsd and included XSDs |
ASPNETCORE_URLS |
http://+:8080 |
Listening address inside the container |
ConnectionStrings__DefaultConnection |
(empty) | Required SQL Server connection string for main app DB |
ConnectionStrings__TarbelConnection |
(empty) | Required SQL Server connection string for Tarbel DB |
Storage__UploadFolder |
/app/uploads |
Upload and temp file root |
AI__Provider |
AzureOpenAI |
AzureOpenAI or OpenAI |
AI__AzureOpenAI__Endpoint |
(empty) | Azure OpenAI resource endpoint URL |
AI__AzureOpenAI__DeploymentName |
(empty) | Deployment name (e.g. gpt-4o) |
AI__AzureOpenAI__UseKeylessAuth |
true |
true = use Azure__* credentials; false = use ApiKey |
AI__AzureOpenAI__ApiKey |
(empty) | Required when UseKeylessAuth is false |
AI__OpenAI__BaseUrl |
(empty) | Optional OpenAI-compatible endpoint when using OpenAI provider |
AI__OpenAI__ApiKey |
(empty) | OpenAI API key when using OpenAI provider |
AI__OpenAI__Model |
gpt-4o-mini |
Model name for OpenAI provider |
DocumentIntelligence__Endpoint |
(empty) | Azure Document Intelligence resource endpoint URL |
DocumentIntelligence__UseKeylessAuth |
true |
true = use Azure__* credentials; false = use ApiKey |
DocumentIntelligence__ApiKey |
(empty) | Required when UseKeylessAuth is false |
DocumentIntelligence__ModelId |
prebuilt-layout |
Document model for OCR analysis |
Azure__TenantId |
(empty) | App registration tenant ID (shared by OpenAI + DI keyless auth) |
Azure__ClientId |
(empty) | App registration client ID |
Azure__ClientSecret |
(empty) | App registration client secret |