The Complete Guide to Secret Rotation in .NET Microservices with HashiCorp Vault Agent: From VaultSharp to Zero-Downtime Sidecar

Mehdi Hadeli
@mehdihadeli
On this page
Table of contents
Introduction
Every microservice system runs into the same awkward problem sooner or later: your services need secrets, those secrets should rotate, and the rotation should not take production down.
That sounds reasonable until you try to wire it into a real .NET service. PostgreSQL passwords expire. RabbitMQ credentials rotate. Vault issues short-lived leases. Meanwhile your APIs still need to keep serving requests, finishing transactions, and publishing messages without restarting every few minutes.
I have gone through the usual options more than once: pulling credentials with VaultSharp inside application code, injecting them as environment variables, and letting a template runner restart the process when secrets change. All of those approaches solve one part of the problem and create another.
The pattern that has held up best is much simpler than it first sounds: run Vault Agent as a sidecar, let it render appsettings.json onto a shared volume, and let modern .NET do what it already does well with reloadOnChange.
That gives you a useful split of responsibilities. Vault Agent handles authentication, lease renewal, and template rendering. Your .NET service keeps reading configuration the normal ASP.NET Core way. No Vault SDK in business code. No process restart on every rotation. No secret management framework leaking across your application surface.
This article walks through the alternatives, why they break down, and how the sidecar pattern works in a concrete .NET 10 sample with two microservices, PostgreSQL, RabbitMQ, Docker Compose, and Vault.
Objectives
This article focuses on one problem: rotating secrets in .NET microservices without shipping Vault logic into application code and without taking the service down.
By the end, you should be able to:
- Explain why SDK-driven secret retrieval is usually the wrong default for runtime credentials.
- See why environment-variable rotation still forces a process restart.
- Understand how Vault Agent can render fresh credentials into a reloadable file.
- Apply that pattern to .NET configuration with
reloadOnChange: true. - Use the sample to wire dynamic PostgreSQL and RabbitMQ credentials into microservices.
Test Environment
The sample uses two .NET 10 services:
UsersApiOrdersApi
Runnable sample: vault-agent-dotnet-sample.
They rely on two kinds of dynamic credentials:
- PostgreSQL credentials for separate databases:
users_dbandorders_db - RabbitMQ credentials for message publishing and consumption
The relevant sample files are here:
deployments/infrastructure/vault/configure.shdeployments/docker-compose.infrastructure.ymldeployments/docker-compose.apps.dev.ymldeployments/docker-compose.apps.ymldeployments/agent-config/orders/vault-agent.hcldeployments/agent-config/orders/appsettings.ctmpldeployments/agent-config/users/vault-agent.hcldeployments/agent-config/users/appsettings.ctmplservices/OrdersApi/Program.csservices/UsersApi/Program.cs
The infrastructure side of the sample is started with this compose file:
name: vault-agent-dotnet-sample-infrastructure
services:
postgres:
image: postgres:17-alpine
environment:
- POSTGRES_USER=root
- POSTGRES_PASSWORD=rootpassword
- POSTGRES_DB=postgres
volumes:
- postgres_data:/var/lib/postgresql/data
- ./infrastructure/postgres/init.sql:/docker-entrypoint-initdb.d/init.sql
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U root']
interval: 5s
restart: unless-stopped
networks:
- vault-net
ports:
- '5432:5432'
rabbitmq:
image: rabbitmq:management-alpine
environment:
- RABBITMQ_DEFAULT_USER=admin
- RABBITMQ_DEFAULT_PASS=admin
healthcheck:
test: ['CMD', 'rabbitmq-diagnostics', 'check_port_connectivity']
interval: 5s
restart: unless-stopped
networks:
- vault-net
ports:
- '5672:5672'
- '15672:15672'
vault:
image: hashicorp/vault:latest
cap_add:
- IPC_LOCK
environment:
- VAULT_DEV_ROOT_TOKEN_ID=dev-only-token
- VAULT_ADDR=http://0.0.0.0:8200
entrypoint: vault server -dev -dev-root-token-id=dev-only-token -dev-listen-address=0.0.0.0:8200
restart: unless-stopped
networks:
- vault-net
ports:
- '8200:8200'
vault-init:
image: hashicorp/vault:latest
user: root
depends_on:
postgres:
condition: service_healthy
rabbitmq:
condition: service_healthy
vault:
condition: service_started
environment:
- VAULT_ADDR=http://vault:8200
- VAULT_TOKEN=dev-only-token
volumes:
- ./infrastructure/vault/configure.sh:/configure.sh
- vault_credentials:/vault/credentials
entrypoint: /bin/sh
command:
- -c
- |
/configure.sh
echo "Configuration complete. Keeping container alive for debugging..."
sleep 3600
networks:
- vault-net
volumes:
postgres_data:
vault_credentials:
name: vault_approle_creds
driver: local
driver_opts:
type: tmpfs
device: tmpfs
networks:
vault-net:
name: vault_network
Source: deployments/docker-compose.infrastructure.yml
The important part is not that there are two services. It is that each service gets its own Vault identity, its own policy scope, and its own rendered configuration file.
Approach 1: VaultSharp in Application Code
The first approach most .NET developers reach for is direct SDK access through VaultSharp.
It is easy to see why. You add a package, authenticate to Vault, request credentials, and build your connection string in Program.cs.
using VaultSharp;
using VaultSharp.V1.AuthMethods.AppRole;
var roleId = Environment.GetEnvironmentVariable("VAULT_ROLE_ID");
var secretId = Environment.GetEnvironmentVariable("VAULT_SECRET_ID");
var authMethod = new AppRoleAuthMethodInfo(roleId, secretId);
var vaultClient = new VaultClient(
new VaultClientSettings("http://vault:8200", authMethod)
);
var dbCreds = await vaultClient.V1.Secrets.Database.GetCredentialsAsync("orders-role");
var connectionString = $"Host=postgres;Database=orders_db;" +
$"Username={dbCreds.Data.Username};" +
$"Password={dbCreds.Data.Password}";
At first glance, this looks tidy. In practice, it creates three persistent problems.
Vault credentials now live in the application container
To fetch secrets from Vault, the app needs a Vault identity. In most setups that means Role ID and Secret ID for AppRole, or some equivalent credential with enough power to ask Vault for more credentials.
That is already a step too far for many services.
If the application container is compromised, the attacker does not just get the current database password. They get the ability to ask Vault for a fresh one.
That is a much larger blast radius than a short-lived database user.
You now own the rotation logic
VaultSharp gives you credentials at a moment in time. It does not solve what happens when those credentials expire in the middle of a long-running process.
So you end up writing the same infrastructure code in every service:
- track lease TTL
- request fresh credentials before expiry
- rebuild connection settings
- deal with transient Vault failures
- decide what to do with existing open connections
This is where secret management stops being a library call and turns into application plumbing.
It leaks infrastructure concerns into your tests
Once Vault is part of the application startup path, your unit and integration tests need to mock or host that dependency too. Even when you abstract it well, the application still knows too much about how secrets are fetched.
That might be acceptable if you are using Vault transit encryption or a Vault API that truly belongs in the domain flow. It is a poor default for runtime database and broker credentials.
Approach 2: Vault Agent with Environment Variables
The next idea is usually to keep Vault out of the app by moving authentication to Vault Agent and exposing the results as environment variables.
That does improve one thing: the application no longer needs VaultSharp or AppRole handling.
But it still runs into a hard platform limit: environment variables are fixed at process start.
That last step is the whole problem.
If the credentials rotate, the agent cannot mutate the environment of an already-running .NET process. The usual answer is to kill the process and start it again with updated values.
That means:
- in-flight requests can be dropped
- active database work can be interrupted
- broker connections are torn down abruptly
- connection pools are rebuilt from scratch
For a development helper or a low-value internal job, maybe that is good enough. For an API you expect to stay available, it is not.
Approach 3: Template Rendering with Process Restart
Another variation uses template rendering instead of environment variables, often through an exec model that restarts the application whenever the rendered configuration changes.
This is marginally better because a JSON file is more flexible than environment variables, but if the runner still owns the application process, you are back to the same operational shape.
The renderer updates the file, detects a change, sends SIGTERM, waits for shutdown, and starts the process again.
That gives you a more graceful restart, not zero-downtime rotation.
If you are on older application stacks that cannot reload configuration in-process, this may be the least bad option. It is still a restart-based design.
Approach 4: Vault Agent Sidecar plus Reloadable appsettings.json
This is the pattern that works well in modern .NET.
The application keeps running as its own process. Vault Agent runs beside it. The agent authenticates to Vault, renews leases, and renders a small appsettings.json file into a shared volume. The .NET service watches that file and reloads configuration when it changes.
No restart is required.
That split matters because it aligns with the strengths of both systems.
- Vault Agent is good at authentication, lease renewal, and templating.
- ASP.NET Core is good at layered configuration and file reload.
You do not need to force either one to do the other one's job.
The Sample Implementation
The sample repository keeps the pieces small and explicit.
Step 1: Vault issues short-lived credentials
The Vault bootstrap script enables the database, rabbitmq, and approle backends, defines one role per service, and assigns read-only policies for only the paths that service needs.
From deployments/infrastructure/vault/configure.sh:
#!/bin/sh
set -e
echo "Waiting for Vault to be ready..."
for i in $(seq 1 20); do
if vault status >/dev/null 2>&1; then
echo "Vault is ready!"
break
fi
echo "Attempt $i: Vault not ready yet..."
sleep 3
done
echo "Enabling secrets engines..."
vault secrets enable database 2>/dev/null || true
vault secrets enable rabbitmq 2>/dev/null || true
vault auth enable approle 2>/dev/null || true
vault write database/config/postgres-db \
plugin_name=postgresql-database-plugin \
allowed_roles="orders-role,users-role" \
connection_url="postgresql://{{username}}:{{password}}@postgres:5432/postgres?sslmode=disable" \
username="vault_admin" \
password="vaultpass"
vault write database/roles/orders-role db_name=postgres-db \
creation_statements="CREATE ROLE \"{{name}}\" WITH LOGIN PASSWORD '{{password}}' VALID UNTIL '{{expiration}}' INHERIT; GRANT ALL ON DATABASE orders_db TO \"{{name}}\"; GRANT ALL ON SCHEMA public TO \"{{name}}\";" \
default_ttl="5m" max_ttl="1h"
vault write rabbitmq/roles/orders-role \
vhosts='{"/":{"write": ".*", "read": ".*", "configure": ".*"}}' \
default_ttl="5m" max_ttl="1h"
vault write rabbitmq/roles/users-role \
vhosts='{"/":{"write": ".*", "read": ".*", "configure": ".*"}}' \
default_ttl="5m" max_ttl="1h"
The same structure exists for users-role.
The same script also creates service-scoped policies and AppRole credentials:
vault policy write orders-policy - <<EOF
path "database/creds/orders-role" { capabilities = ["read"] }
path "rabbitmq/creds/orders-role" { capabilities = ["read"] }
EOF
vault policy write users-policy - <<EOF
path "database/creds/users-role" { capabilities = ["read"] }
path "rabbitmq/creds/users-role" { capabilities = ["read"] }
EOF
vault write auth/approle/role/orders token_policies="orders-policy" token_ttl=1h
vault write auth/approle/role/users token_policies="users-policy" token_ttl=1h
mkdir -p /vault/credentials
vault read -field=role_id auth/approle/role/orders/role-id > /vault/credentials/orders-role-id
vault write -f -field=secret_id auth/approle/role/orders/secret-id > /vault/credentials/orders-secret-id
vault read -field=role_id auth/approle/role/users/role-id > /vault/credentials/users-role-id
vault write -f -field=secret_id auth/approle/role/users/secret-id > /vault/credentials/users-secret-id
Source: deployments/infrastructure/vault/configure.sh
This is already better than a shared secret model because the credentials are generated dynamically and expire quickly.
Step 2: Each service gets its own AppRole and policy
The same script creates two separate policies and two separate AppRoles.
vault policy write orders-policy - <<EOF
path "database/creds/orders-role" { capabilities = ["read"] }
path "rabbitmq/creds/orders-role" { capabilities = ["read"] }
EOF
vault write auth/approle/role/orders token_policies="orders-policy" token_ttl=1h
That separation is not decorative. It is what keeps OrdersApi from being able to fetch UsersApi credentials if one service is compromised.
Step 3: Vault Agent renders application settings
The sidecar configuration for each service is intentionally small.
From deployments/agent-config/orders/vault-agent.hcl:
pid_file = "/tmp/vault-agent.pid"
vault {
address = "http://vault:8200"
}
auto_auth {
method {
type = "approle"
config = {
role_id_file_path = "/vault/credentials/orders-role-id"
secret_id_file_path = "/vault/credentials/orders-secret-id"
remove_secret_id_file_after_reading = false
}
}
sink {
type = "file"
config = {
path = "/tmp/vault-token"
mode = 0600
}
}
}
template {
destination = "/vault/secrets/appsettings.json"
perms = 0600
source = "/vault/config/appsettings.ctmpl"
}
The important omission is the point: there is no exec block.
Vault Agent is not responsible for starting or restarting the application. It only authenticates and renders files.
The users sidecar is the same shape, with only the AppRole credential files changed:
pid_file = "/tmp/vault-agent.pid"
vault {
address = "http://vault:8200"
}
auto_auth {
method {
type = "approle"
config = {
role_id_file_path = "/vault/credentials/users-role-id"
secret_id_file_path = "/vault/credentials/users-secret-id"
remove_secret_id_file_after_reading = false
}
}
sink {
type = "file"
config = {
path = "/tmp/vault-token"
mode = 0600
}
}
}
template {
destination = "/vault/secrets/appsettings.json"
perms = 0600
source = "/vault/config/appsettings.ctmpl"
}
Sources:
Step 4: The template writes exactly what .NET expects
From deployments/agent-config/orders/appsettings.ctmpl:
{
"ConnectionStrings": {
"Default": "Host=postgres;Port=5432;Database=orders_db;Username={{ with secret "database/creds/orders-role" }}{{ .Data.username }}{{ end }};Password={{ with secret "database/creds/orders-role" }}{{ .Data.password }}{{ end }}"
},
"RabbitMQ": {
"Host": "rabbitmq",
"Port": "5672",
"Username": "{{ with secret "rabbitmq/creds/orders-role" }}{{ .Data.username }}{{ end }}",
"Password": "{{ with secret "rabbitmq/creds/orders-role" }}{{ .Data.password }}{{ end }}"
}
}
That is one of the nicest parts of this design. The application is not learning a new configuration model. It is still reading a normal appsettings.json shape.
The users template is the same pattern, just pointed at users-role and users_db:
{
"ConnectionStrings": {
"Default": "Host=postgres;Port=5432;Database=users_db;Username={{ with secret "database/creds/users-role" }}{{ .Data.username }}{{ end }};Password={{ with secret "database/creds/users-role" }}{{ .Data.password }}{{ end }}"
},
"RabbitMQ": {
"Host": "rabbitmq",
"Port": "5672",
"Username": "{{ with secret "rabbitmq/creds/users-role" }}{{ .Data.username }}{{ end }}",
"Password": "{{ with secret "rabbitmq/creds/users-role" }}{{ .Data.password }}{{ end }}"
}
}
Sources:
Step 5: The .NET service opts into reloadable configuration
This is the key application change, and it is very small.
From services/UsersApi/Program.cs:
var builder = WebApplication.CreateBuilder(args);
var vaultSettingsPath = builder.Environment.IsDevelopment()
? Environment.GetEnvironmentVariable("VAULT_SECRETS_PATH")
?? $"{builder.Environment.ContentRootPath}/vault-secrets/appsettings.json"
: "/vault/secrets/appsettings.json";
builder.Configuration.AddJsonFile(vaultSettingsPath, optional: true, reloadOnChange: true);
var cfg = builder.Configuration;
builder.Services.AddDbContext<UserDb>(
o => o.UseNpgsql(cfg.GetConnectionString("Default")!),
ServiceLifetime.Transient
);
There are two practical choices here that matter.
First, the application has one path for local development and one for containerized runtime. That keeps the service debuggable from the IDE.
Second, reloadOnChange: true tells the configuration system to watch the rendered file and reload values in-memory when Vault Agent rewrites it.
The DbContext is also registered as transient in this sample. That is deliberate. New request scopes will resolve fresh configuration rather than pinning a long-lived database object built against stale credentials.
Here is the full UsersApi startup from the sample:
using Contracts;
using MassTransit;
using Microsoft.EntityFrameworkCore;
var builder = WebApplication.CreateBuilder(args);
var vaultSettingsPath = builder.Environment.IsDevelopment()
? Environment.GetEnvironmentVariable("VAULT_SECRETS_PATH")
?? $"{builder.Environment.ContentRootPath}/vault-secrets/appsettings.json"
: "/vault/secrets/appsettings.json";
builder.Configuration.AddJsonFile(vaultSettingsPath, optional: true, reloadOnChange: true);
var cfg = builder.Configuration;
builder.Services.AddDbContext<UserDb>(o => o.UseNpgsql(cfg.GetConnectionString("Default")!), ServiceLifetime.Transient);
builder.Services.AddMassTransit(x =>
{
x.UsingRabbitMq(
(ctx, r) =>
{
var rmq = cfg.GetSection("RabbitMQ");
r.Host(
builder.Environment.IsDevelopment() ? "localhost" : rmq["Host"],
ushort.Parse(rmq["Port"]!),
"/",
h =>
{
h.Username(rmq["Username"]!);
h.Password(rmq["Password"]!);
}
);
}
);
});
var app = builder.Build();
app.MapPost(
"/users",
async (string name, UserDb db, IPublishEndpoint pub) =>
{
var u = new User { Name = name };
db.Users.Add(u);
await db.SaveChangesAsync();
await pub.Publish(new UserCreated(u.Id, u.Name));
return Results.Created($"/users/{u.Id}", u);
}
);
app.MapGet("/users", async (UserDb db) => await db.Users.ToListAsync());
app.Run();
And here is the matching OrdersApi startup and consumer path:
using Contracts;
using MassTransit;
using Microsoft.EntityFrameworkCore;
var builder = WebApplication.CreateBuilder(args);
var vaultSettingsPath = builder.Environment.IsDevelopment()
? Environment.GetEnvironmentVariable("VAULT_SECRETS_PATH")
?? $"{builder.Environment.ContentRootPath}/vault-secrets/appsettings.json"
: "/vault/secrets/appsettings.json";
builder.Configuration.AddJsonFile(vaultSettingsPath, optional: true, reloadOnChange: true);
var cfg = builder.Configuration;
builder.Services.AddDbContext<OrderDb>(
o => o.UseNpgsql(cfg.GetConnectionString("Default")!),
ServiceLifetime.Transient
);
builder.Services.AddMassTransit(x =>
{
x.AddConsumer<UserCreatedConsumer>();
x.UsingRabbitMq(
(ctx, r) =>
{
var rmq = cfg.GetSection("RabbitMQ");
r.Host(
builder.Environment.IsDevelopment() ? "localhost" : rmq["Host"],
ushort.Parse(rmq["Port"]!),
"/",
h =>
{
h.Username(rmq["Username"]!);
h.Password(rmq["Password"]!);
}
);
r.ReceiveEndpoint("orders-user-created", e => e.ConfigureConsumer<UserCreatedConsumer>(ctx));
}
);
});
var app = builder.Build();
app.MapGet("/orders", async (OrderDb db) => await db.Orders.ToListAsync());
app.Run();
Sources:
Why This Avoids Downtime
The cleanest part of this approach is what it does not do.
It does not kill the application process when credentials rotate.
That changes the runtime story completely.
Existing work keeps moving. New request paths start using new configuration as the file reload propagates. You still need to think about how your libraries cache connections, but you are no longer forcing the worst possible answer by restarting the whole process.
Security Shape of the Sidecar Pattern
This pattern is not magically perfect. It is just much easier to reason about.
The application container reads rendered runtime credentials. The Vault identity and token handling stay in the sidecar.
That means an attacker who breaks into the application gets far less than they would with VaultSharp in the main process.
The containment comes from several layers working together:
- one AppRole per service
- one read-only policy per service
- short default TTL values
- rendered files scoped to one service
- sidecar holds the Vault identity, not the .NET process
In the sample, the AppRole credential files are also written to a Docker tmpfs-backed volume during Vault initialization, which keeps them off persistent disk in the compose environment.
Docker Compose Shape
The repository includes separate compose files for infrastructure and app-side wiring.
Infrastructure is started through deployments/docker-compose.infrastructure.yml, which brings up:
- PostgreSQL
- RabbitMQ
- Vault
- a
vault-initcontainer that runsconfigure.sh
The app-side compose files focus on Vault Agent containers and secret mounts. In the checked-in development path, deployments/docker-compose.apps.dev.yml starts the Vault Agent sidecars and writes rendered files into services/OrdersApi/vault-secrets and services/UsersApi/vault-secrets. The APIs themselves are then run locally from the services/ folders.
The production compose file keeps one tmpfs-backed secret volume per service, so the sidecar and the application share only that service's rendered configuration file.
That is an important convenience. You keep the same configuration shape in both local and containerized environments instead of inventing a second secret story for development.
Here is the development compose wiring that renders directly into each local service folder:
name: vault-agent-dotnet-sample-services
services:
vault-agent-orders:
image: hashicorp/vault:latest
volumes:
- ./agent-config/orders/vault-agent.hcl:/vault/config/agent.hcl
- ./agent-config/orders/appsettings.ctmpl:/vault/config/appsettings.ctmpl
- vault_approle_creds:/vault/credentials:ro
- ${VAULT_SECRETS_PATH:-../services/OrdersApi/vault-secrets}:/vault/secrets
environment:
VAULT_ADDR: 'http://vault:8200'
entrypoint:
- vault
- agent
- -config=/vault/config/agent.hcl
networks:
- vault_network
vault-agent-users:
image: hashicorp/vault:latest
volumes:
- ./agent-config/users/vault-agent.hcl:/vault/config/agent.hcl
- ./agent-config/users/appsettings.ctmpl:/vault/config/appsettings.ctmpl
- vault_approle_creds:/vault/credentials:ro
- ${VAULT_SECRETS_PATH:-../services/UsersApi/vault-secrets}:/vault/secrets
environment:
VAULT_ADDR: 'http://vault:8200'
entrypoint:
- vault
- agent
- -config=/vault/config/agent.hcl
networks:
- vault_network
volumes:
vault_approle_creds:
external: true
networks:
vault_network:
external: true
And here is the production-oriented compose wiring that uses one tmpfs-backed secret volume per service:
name: vault-agent-dotnet-sample-services
services:
vault-agent-orders:
image: hashicorp/vault:latest
volumes:
- ./agent-config/orders/vault-agent.hcl:/vault/config/agent.hcl
- ./agent-config/orders/appsettings.ctmpl:/vault/config/appsettings.ctmpl
- vault_approle_creds:/vault/credentials:ro
- secrets_orders:/vault/secrets
environment:
VAULT_ADDR: 'http://vault:8200'
entrypoint:
- vault
- agent
- -config=/vault/config/agent.hcl
networks:
- vault_network
vault-agent-users:
image: hashicorp/vault:latest
volumes:
- ./agent-config/users/vault-agent.hcl:/vault/config/agent.hcl
- ./agent-config/users/appsettings.ctmpl:/vault/config/appsettings.ctmpl
- vault_approle_creds:/vault/credentials:ro
- secrets_users:/vault/secrets
environment:
VAULT_ADDR: 'http://vault:8200'
entrypoint:
- vault
- agent
- -config=/vault/config/agent.hcl
networks:
- vault_network
volumes:
vault_approle_creds:
external: true
secrets_orders:
driver: local
driver_opts:
type: tmpfs
device: tmpfs
secrets_users:
driver: local
driver_opts:
type: tmpfs
device: tmpfs
networks:
vault_network:
external: true
If you want the APIs inside the same compose stack instead of running them locally, these are the service definitions already present in the sample as commented blocks:
orders-api:
image: orders-api:latest
build:
context: ..
dockerfile: services/OrdersApi/Dockerfile
volumes:
- secrets_orders:/vault/secrets:ro
ports:
- '5001:8080'
networks:
- vault_network
depends_on:
- vault-agent-orders
users-api:
image: users-api:latest
build:
context: ..
dockerfile: services/UsersApi/Dockerfile
volumes:
- secrets_users:/vault/secrets:ro
ports:
- '5002:8080'
networks:
- vault_network
depends_on:
- vault-agent-users
Sources:
Comparison Matrix
Here is the tradeoff in plain terms.
| Criteria | VaultSharp SDK | Env Var Injection | Template Runner with Restart | Agent + reloadOnChange |
|---|---|---|---|---|
| Vault creds in app process | Yes | No | No | No |
| Zero-downtime rotation | No | No | No | Yes |
| Rotation logic in app | Yes | No | No | No |
| Process restart on change | No, but you write the refresh path | Yes | Yes | No |
| Works naturally with ASP.NET Core config | Poor fit | Poor fit | Partial fit | Strong fit |
| Local dev story | Usually awkward | Usually awkward | Usually awkward | Good |
If your service genuinely needs Vault as an application API, for example transit encryption or per-request secret operations, VaultSharp can still make sense. For runtime database and broker credentials, it is usually too much coupling.
Deployment Notes
If you want to walk the sample from scratch, the sequence is simple:
docker compose -f deployments/docker-compose.infrastructure.yml up -d
docker compose -f deployments/docker-compose.apps.dev.yml up -d
Then run the .NET services locally from services/OrdersApi and services/UsersApi, or uncomment the app containers in deployments/docker-compose.apps.yml if you want a fully containerized path.
The sample pins .NET 10 through global.json, and the relevant application entrypoints are:
services/UsersApi/Program.csservices/OrdersApi/Program.cs
If you want the shortest path into the sample, start with these files in order:
deployments/infrastructure/vault/configure.shdeployments/agent-config/orders/vault-agent.hcldeployments/agent-config/orders/appsettings.ctmplservices/UsersApi/Program.csservices/OrdersApi/Program.cs
That sequence shows the full flow from secret issuance to file rendering to application reload.
When I Would Still Use Something Else
This sidecar pattern is my default for modern .NET microservices, not my answer to every Vault problem.
I would still consider other approaches in a few cases:
- use VaultSharp when the application truly needs Vault as part of its request flow, such as transit encryption or explicit secret operations
- accept process restart patterns for legacy applications that cannot reload configuration cleanly
- use a platform-native injector in Kubernetes if your organization already standardizes that model
The main thing I would avoid is mixing runtime credential rotation logic into business services just because the SDK path looked straightforward on day one.
Conclusion
Secret rotation sounds like a credential problem, but in practice it is an availability problem.
Most approaches fail not because they cannot fetch fresh credentials, but because they have no clean answer for what happens to a running process when those credentials change.
That is why the Vault Agent sidecar plus reloadable appsettings.json pattern works so well in .NET. It keeps Vault concerns in the sidecar, keeps application configuration in the .NET configuration system, and lets credential rotation happen without turning every lease renewal into a restart event.
If you are building .NET microservices that need PostgreSQL, RabbitMQ, or similar runtime secrets to rotate safely, this is the first pattern I would try. It is simple enough to explain, secure enough to defend, and operationally much calmer than the alternatives.


