HashiCorp Nomad and Vault with .NET: ASP.NET Core in a Secure Workload


Bicycle

When we published our HashiCorp Nomad and Vault: Dynamic Secrets post, the demo ran exclusively as a Python Flask application. Since then, the repository has grown into a multi-language showcase. This post covers the .NET (ASP.NET Core MVC) implementation — the same Vault patterns, the same Nomad job structure, a completely different runtime.

The motivation is practical: many teams run mixed stacks. Showing that the Nomad + Vault secrets model works the same way regardless of whether you write Python, Java, or C# removes a common reason not to adopt it.

Application Overview

The .NET app is built with ASP.NET Core MVC targeting .NET 8. It is functionally identical to the Python version:

  • CRUD operations on a customers table in MySQL
  • Vault Transit encryption for birth_date, address, and salary
  • Vault dynamic database credentials (short-lived MySQL users)
  • INI-based configuration rendered by Nomad's template engine
  • /health endpoint for Nomad service checks and Consul integration
app/dotnet/
├── Program.cs                         # Application entry point
├── NomadVaultMySqlDotnet.csproj       # Project file (net8.0)
├── config/config.ini                  # Local dev config
├── Configuration/
│   └── AppRuntimeConfig.cs            # INI → C# config binding
├── Services/
│   ├── VaultService.cs                # VaultSharp SDK client
│   └── CustomerRepository.cs         # JDBC-equivalent: Dapper + Vault
├── Controllers/
│   └── AppController.cs              # MVC routes
└── Models/
    └── Customer.cs                    # Data model

Configuration Binding

The application reads a config.ini file at startup. AppRuntimeConfig uses the Microsoft.Extensions.Configuration INI provider — same sections as the Python version:

1var configPath = Path.Combine(builder.Environment.ContentRootPath, "config", "config.ini");
2builder.Configuration.AddIniFile(configPath, optional: true, reloadOnChange: true);
3
4var runtimeConfig = AppRuntimeConfig.FromConfiguration(builder.Configuration);

The reloadOnChange: true parameter means the app picks up Nomad template re-renders (triggered by Vault credential rotation) without restarting.

The Vault Service — VaultSharp SDK

VaultService.cs now uses VaultSharp — a widely used community .NET client for Vault. This eliminates manual HTTP plumbing and provides a robust, type-safe API for Vault integration.

Initialisation

 1public async Task InitializeAsync(AppRuntimeConfig config, CancellationToken cancellationToken)
 2{
 3    _settings = config.Vault;
 4    if (!_settings.Enabled) { IsEnabled = false; return; }
 5
 6    _token = _settings.InjectToken
 7        ? (Environment.GetEnvironmentVariable("VAULT_TOKEN") ?? string.Empty).Trim()
 8        : _settings.Token.Trim();
 9
10    if (string.IsNullOrWhiteSpace(_settings.Address) || string.IsNullOrWhiteSpace(_token))
11    {
12        _logger.LogWarning("Vault address or token is missing; skipping initialization.");
13        IsEnabled = false;
14        return;
15    }
16
17    try
18    {
19        var authMethod = new TokenAuthMethodInfo(_token);
20        var vaultClientSettings = new VaultClientSettings(_settings.Address, authMethod)
21        {
22            Namespace = string.IsNullOrWhiteSpace(_settings.Namespace) ? null : _settings.Namespace,
23            MyHttpClientProviderFunc = handler => new HttpClient(new HttpClientHandler
24            {
25                // PoC only: trust self-signed/internal certs for demo environments
26                ServerCertificateCustomValidationCallback = HttpClientHandler.DangerousAcceptAnyServerCertificateValidator
27            })
28        };
29        _vaultClient = new VaultClient(vaultClientSettings);
30
31        var healthResponse = await SendVaultRequestAsync(HttpMethod.Get, "/v1/sys/health", null, cancellationToken);
32        IsEnabled = healthResponse.IsSuccessStatusCode;
33    }
34    catch (Exception ex)
35    {
36        _logger.LogError(ex, "VaultSharp initialization failed");
37        IsEnabled = false;
38    }
39}

The token is sourced from the VAULT_TOKEN environment variable when InjectToken = True. Nomad injects this variable automatically when the task has a vault stanza — the application never handles token lifecycle.

Dynamic Database Credentials with VaultSharp

 1public async Task<(string User, string Password)?> ReadDatabaseCredentialsAsync(
 2    string path, CancellationToken cancellationToken)
 3{
 4    if (!IsEnabled || string.IsNullOrWhiteSpace(path) || _vaultClient is null)
 5        return null;
 6
 7    // Vault dynamic DB creds path format: <mount>/creds/<role>
 8    var parts = path.Split('/', StringSplitOptions.RemoveEmptyEntries);
 9    var credsIndex = Array.IndexOf(parts, "creds");
10    if (credsIndex > 0 && credsIndex < parts.Length - 1)
11    {
12        try
13        {
14            var mountPoint = string.Join('/', parts.Take(credsIndex));
15            var roleName = parts[credsIndex + 1];
16            var secret = await _vaultClient.V1.Secrets.Database.GetCredentialsAsync(roleName, mountPoint);
17            return (secret.Data.Username ?? string.Empty, secret.Data.Password ?? string.Empty);
18        }
19        catch (Exception ex)
20        {
21            _logger.LogError(ex, "VaultSharp DB credentials failed; falling back to generic API.");
22        }
23    }
24
25    // Fallback to raw HTTP if VaultSharp is unavailable
26    var response = await SendVaultRequestAsync(
27        HttpMethod.Get, $"/v1/{path}", null, cancellationToken);
28
29    if (!response.IsSuccessStatusCode) return null;
30
31    using var doc = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync(cancellationToken), cancellationToken: cancellationToken);
32    if (doc.RootElement.TryGetProperty("data", out var data) &&
33        data.TryGetProperty("username", out var user) &&
34        data.TryGetProperty("password", out var pwd))
35    {
36        return (user.GetString() ?? string.Empty, pwd.GetString() ?? string.Empty);
37    }
38
39    return null;
40}

Transit Encryption with VaultSharp

 1public async Task<string> EncryptAsync(string value, CancellationToken cancellationToken)
 2{
 3    if (!IsEnabled || _vaultClient is null)
 4        return value;
 5
 6    var plaintext = Convert.ToBase64String(Encoding.UTF8.GetBytes(value));
 7    var request = new EncryptRequestOptions { Base64EncodedPlainText = plaintext };
 8    var response = await _vaultClient.V1.Secrets.Transit.EncryptAsync(
 9        _settings.KeyName, request, mountPoint: _settings.KeyPath);
10    return response.Data.CipherText ?? value;
11}
12
13public async Task<string> DecryptAsync(string value, CancellationToken cancellationToken)
14{
15    if (!IsEnabled || _vaultClient is null || !value.StartsWith("vault:v", StringComparison.Ordinal))
16        return value;
17
18    var request = new DecryptRequestOptions { CipherText = value };
19    var response = await _vaultClient.V1.Secrets.Transit.DecryptAsync(
20        _settings.KeyName, request, mountPoint: _settings.KeyPath);
21
22    var base64PlainText = response.Data.Base64EncodedPlainText;
23    if (string.IsNullOrWhiteSpace(base64PlainText)) return value;
24
25    var decoded = Convert.FromBase64String(base64PlainText);
26    return Encoding.UTF8.GetString(decoded);
27}

VaultSharp handles Base64 encoding/decoding, HTTP serialization, and response parsing automatically. The API is type-safe and matches Vault's request/response structure directly.

The Nomad Job Files

The nomad/dotnet/ directory mirrors the Python progression exactly:

FileDriverVaultDescription
app_hardcoded.hcldockerCredentials in template
app_static.hcldockerKVCredentials from KV store
app_dynamic.hcldockerDynamic DBShort-lived MySQL users
app_transit.hcldockerDynamic + TransitField-level encryption
app_transit_connect.hcldockerDynamic + Transit+ Consul Connect mTLS

Transit Job (app_transit.hcl)

The only differences from the Python transit job are the container image and the config file mount path:

 1task "dynamic-app" {
 2  driver = "docker"
 3
 4  config {
 5    image = "quay.io/infralovers/nomad-vault-mysql-dotnet"
 6    volumes = [
 7      "local/config.ini:/app/config/config.ini"   # .NET path, not /usr/src/app
 8    ]
 9    ports = ["web"]
10  }
11
12  template {
13    destination = "local/config.ini"
14    data        = <<EOF
15[DEFAULT]
16LogLevel = DEBUG
17Port = 8080
18
19[DATABASE]
20{{ range service "mysql-server" }}
21Address = {{ .Address }}
22Port    = {{ .Port }}
23{{ end }}
24{{ with secret "dynamic-app/kv/database" }}
25Database = {{ .Data.data.database }}
26{{ end }}
27{{ with secret "dynamic-app/db/creds/app" }}
28User     = {{ .Data.username }}
29Password = {{ .Data.password }}
30{{ end }}
31
32[VAULT]
33Enabled     = True
34InjectToken = True
35Namespace   =
36Address     = {{ env "VAULT_ADDR" }}
37KeyPath     = dynamic-app/transit
38KeyName     = app
39EOF
40  }
41
42  resources {
43    cpu    = 256
44    memory = 256
45  }
46}

The vault stanza, Consul template expressions, policy name (nomad-dynamic-app), and secret paths are identical to the Python job. Nothing in the Vault or Nomad layer changes — only the application runtime does.

Container Image: CI/CD

The .NET image is built and published to Quay on every push to main:

1# .github/workflows/ci.yml (dotnet target)
2- name: Build and push .NET image
3  uses: docker/build-push-action@v5
4  with:
5    context: app/dotnet
6    push: true
7    tags: quay.io/infralovers/nomad-vault-mysql-dotnet:latest

The Nomad job references quay.io/infralovers/nomad-vault-mysql-dotnet.

Running Locally

The docker-compose.dotnet.yml in the repository root lets you run the .NET app together with MySQL locally without a Nomad cluster:

1docker compose -f docker-compose.dotnet.yml up -d --build

The application is available at http://localhost:8080. Configure Vault access by setting VAULT_ADDR and VAULT_TOKEN in your environment or editing app/dotnet/config/config.ini.

Comparing Python and .NET Implementations

AspectPython (Flask).NET (ASP.NET Core)
Vault SDKhvac (Transit only)VaultSharp (Transit + DB creds), raw HTTP fallback
Config formatconfig.iniconfig.ini
Vault token sourceVAULT_TOKEN envVAULT_TOKEN env
INI reloadApp restartreloadOnChange: true
Container imagenomad-vault-mysql-pythonnomad-vault-mysql-dotnet
Nomad driverdockerdocker
Config mount path/usr/src/app/config//app/config/

The Python implementation uses the hvac library for Transit operations. The .NET version uses VaultSharp for Transit and dynamic database credentials, and falls back to direct Vault HTTP calls when needed to keep behavior resilient and transparent.

Summary

The .NET implementation demonstrates that the Nomad + Vault secrets model is language-agnostic:

  • The Nomad job structure (vault stanza, template engine, service registration) is unchanged.
  • The Vault configuration (policies, secret paths, engines) is unchanged.
  • Only the application image and the config file mount path differ.

This matters for multi-language organisations: adopting Vault does not require a single-language mandate. Any application that can make an HTTP call and read a file can participate in the same secrets lifecycle.

The full source code is at github.com/infralovers/nomad-vault-mysql.

Go Back explore our courses

We are here for you

You are interested in our courses or you simply have a question that needs answering? You can contact us at anytime! We will do our best to answer all your questions.

Contact us