On-prem ADO → Apache/.NET Kestrel deploy runbook
On-prem ADO → Apache/.NET Kestrel deploy runbook
September 11, 2026
Reusable pattern from SlingBuiltAuth (General project). Use this for the next site pipelines.
Architecture (SlingBuiltAuth reference)
- TLS edge:
sfl-web-001— Apache + Let's Encrypt; ProxyPass to app host - App host:
sfl-web-004— Apache HTTP vhost → Kestrel127.0.0.1:PORT - App: systemd + optional env wrapper; User= service account (often apache)
- CI: Default pool Windows agent (SFL-ADO-001); build + SSH deploy in a single job
- Secrets: ADO Library variable group; mark secrets as secret
Public DNS → edge. LAN DNS must also hit the TLS edge (001), not the app host alone, or HTTPS 404s / wrong certs.
ADO setup checklist
- Repo +
azure-pipelines.ymlon master - Environment (e.g.
slingbuilt-prod) + approval if needed - Variable group linked to the pipeline (e.g.
slingbuilt-prod) - SSH service connection (e.g.
slingbuilt-linux → grok@app-host) - Agent pool: Default (no Microsoft-hosted
ubuntu-lateston this ADO) - Secure files optional — prefer host-side cert copy if agent Node TLS fails against internal CA
Pipeline pattern that works here
- One job: restore → build → publish → SSH deploy (avoid Publish/Download Build Artifacts when agent Node cannot verify the internal CA)
- Write
/etc/<app>.envon the agent (PowerShell), then upload — avoids bashset -uexpanding$inside passwords - Install env as
root:<service-group>mode 640 (e.g.root:apache) — 600 root:root crash-loops if the service user cannot read it - Post-restart: wait/retry health check
- Health check against Kestrel with forwarded headers, example:
curl -H="X-Forwarded-Proto: https" -H="Host: public.example.com" http://127.0.0.1:PORT/.well-known/... systemd
- Prefer dedicated
User=/Group= WorkingDirectory+ExecStart=/usr/bin/dotnet …/App.dllOR a small loader script- Hyphenated env keys (e.g.
Oidc__Clients__organized-inventory__Secret) are rejected by systemdEnvironmentFile=— load the file in a wrapper (Python/shell) then exec dotnet ProtectSystem=strictneedsReadWritePathsfor keys/certs/app data;PrivateTmp=trueis fine if you do not require /tmp state
Apache
Edge (TLS)
- ACME only:
ProxyPass "/.well-known/acme-challenge/" "!" - Do NOT blanket-exclude
/.well-known/— that breaks OpenID discovery and JWKS - Set
X-Forwarded-Proto,X-Forwarded-Port,X-Forwarded-Host ProxyPass / http://app-host/(orapp-host:port)
App host (HTTP)
ProxyPreserveHost OnProxyPass / http://127.0.0.1:PORT/- Backup forwarded headers if edge already sets them
Secrets / SQL
- Keep DB + client secrets as secret variables; rotate after any screenshot/log exposure
- If several apps share one SQL login (e.g.
web_internet), rotating the password requires updating every appsettings/env that uses it in the same change window - Connection strings for .NET on Linux often need
Encrypt=False;TrustServerCertificate=True(or proper CA trust) against internal SQL
Agent Node TLS (Secure Files / artifacts)
- Symptom:
UNABLE_TO_VERIFY_LEAF_SIGNATURE/ unable to verify the first certificate NODE_EXTRA_CA_CERTSalone may not be enough — prefer a full chain PEM- Workaround used for SlingBuiltAuth: skip Secure File download; copy PFX from deploy user home during SSH
First-run verification
systemctl is-active <service>- Loopback discovery/health with forwarded Host/Proto
- Public
https://<host>/...through the edge - LAN DNS points at TLS edge
SlingBuiltAuth concrete paths
- App root:
/var/www/html/slingbuilt/{app,keys,certs,migrations} - Env:
/etc/slingbuilt-auth.env - Unit:
slingbuilt-auth → /usr/local/bin/slingbuilt-auth-start - Port:
5080 - Public:
https://slingbuilt.com - Canonical repo detail:
DEPLOY.md
Trouble we hit
- env 640/apache
- hyphenated EnvironmentFile
- ACME well-known exclude
- forwarded headers for OpenIddict
- LAN DNS to 001
- shared SQL login rotation blast radius
- single-job pipeline for Node TLS