HashiCorp Nomad and Vault with .NET: ASP.NET Core in a Secure Workload
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

Every Nomad tutorial you will find online uses the docker driver. That makes sense — containers are portable, images bundle everything, and Docker is ubiquitous. But Docker is not always available. Edge nodes, bare-metal servers with minimal tooling, air-gapped environments, or simply teams that prefer to ship plain JARs rather than images all run into the same wall: Docker is not there.
Nomad has a built-in answer: the java driver.
This post extends our Nomad + Vault + MySQL demo series by replacing the docker driver entirely. The same Spring Boot application — with Vault Transit encryption, dynamic database credentials, and Consul service discovery — runs as a supervised JVM process, downloaded directly from GitHub Releases at schedule time.
The java driver is a first-class Nomad task driver. When Nomad schedules a task using it, the lifecycle is:
local/ directory.template blocks.java -jar <jar_path> [jvm_options] as a child process.restart stanza.The Nomad worker needs a JDK/JRE on its PATH. Nomad does not manage the JVM installation itself — that is handled by your base image or configuration management tooling (Ansible, Packer, etc.).
In this series, the container-based variant pulls images from a registry. The Java variant instead fetches a fat JAR published to GitHub Releases:
1artifact {
2 source = "https://github.com/infralovers/nomad-vault-mysql/releases/download/java-artifact-latest/nomad-vault-mysql-java.jar"
3 destination = "local/"
4}
Nomad's artifact stanza supports HTTP(S), S3, GCS, and several other backends. GitHub Releases is a convenient default for open-source projects — no private registry credentials, no image layer cache to maintain, and the JAR URL is stable across versions via the java-artifact-latest tag.
For production deployments the same pattern works with any artifact store: Nexus, Artifactory, S3, or a plain HTTPS file server.
A subtle difference between the Java driver and the Docker driver is port allocation. Docker jobs typically pin the container's internal port with to = 8080. With the Java driver there is no container network namespace — the process binds directly to a port on the host.
Nomad allocates a random free port and exposes it via the NOMAD_PORT_web environment variable:
1network {
2 port "web" {} # no static = ..., no to = ...
3}
The Spring Boot application reads this at startup:
1env {
2 APP_CONFIG_PATH = "${NOMAD_TASK_DIR}/config/config.ini"
3 SERVER_PORT = "${NOMAD_PORT_web}"
4}
Spring Boot respects SERVER_PORT out of the box. The Consul health check and service registration use the same allocated port automatically.
The Java application is a Spring Boot 3.3.5 + Java 21 MVC application that mirrors the Python implementation in functionality:
customers table in MySQLbirth_date, address, and salaryconfig.ini (same format as Python and .NET)/health endpoint for Nomad service checksapp/java/src/main/java/io/infralovers/nomadvaultmysql/
├── NomadVaultMysqlApplication.java # Spring Boot entry point
├── config/
│ └── AppProperties.java # INI → Java config binding
├── controller/
│ └── AppController.java # MVC routes
├── model/
│ └── Customer.java # Data model
└── service/
├── VaultService.java # Spring Vault Transit client
└── CustomerRepository.java # JDBC + Vault integration
The Java app uses Spring Vault for clean, idiomatic Vault integration. VaultService.java initializes Spring Vault's VaultTemplate with token authentication and wraps it for easy use throughout the application:
1public void initialize() {
2 if (!config.isEnabled()) { return; }
3
4 token = config.isInjectToken()
5 ? System.getenv().getOrDefault("VAULT_TOKEN", "").strip()
6 : config.getToken().strip();
7
8 if (config.getAddress().isBlank() || token.isBlank()) {
9 log.warn("Vault address or token is missing.");
10 return;
11 }
12
13 try {
14 VaultEndpoint endpoint = VaultEndpoint.from(URI.create(config.getAddress()));
15 vaultTemplate = new VaultTemplate(endpoint, new TokenAuthentication(token));
16 vaultTransit = vaultTemplate.opsForTransit(config.getKeyPath());
17 log.info("Spring Vault client initialized for mount path {}", config.getKeyPath());
18 enabled = true;
19 } catch (Exception e) {
20 log.warn("Spring Vault initialization failed ({}); falling back to raw HTTP.", e.getMessage());
21 // Falls back to raw HTTP for Vault operations if Spring Vault fails
22 }
23}
The token is injected by Nomad via the VAULT_TOKEN environment variable when InjectToken = True in the config — the same mechanism used by the Python variant.
1public String encrypt(String value) {
2 if (!enabled || value == null || value.isBlank()) return value;
3
4 if (vaultTransit != null) {
5 try {
6 return vaultTransit.encrypt(config.getKeyName(), value);
7 } catch (Exception e) {
8 log.warn("Spring Vault transit encrypt failed ({}); using raw HTTP fallback.", e.getMessage());
9 }
10 }
11
12 // Fallback to raw HTTP if Spring Vault is unavailable
13 String b64 = Base64.getEncoder().encodeToString(value.getBytes(StandardCharsets.UTF_8));
14 String body = json.writeValueAsString(new PlainTextPayload(b64));
15 String path = String.format("/v1/%s/encrypt/%s", config.getKeyPath(), config.getKeyName());
16 HttpRequest req = buildPost(path, body);
17 HttpResponse<String> res = http.send(req, HttpResponse.BodyHandlers.ofString());
18 return json.readTree(res.body()).path("data").path("ciphertext").asText("");
19}
20
21public String decrypt(String value) {
22 if (!enabled || value == null || !value.startsWith("vault:v")) return value;
23
24 if (vaultTransit != null) {
25 try {
26 return vaultTransit.decrypt(config.getKeyName(), value);
27 } catch (Exception e) {
28 log.warn("Spring Vault transit decrypt failed ({}); using raw HTTP fallback.", e.getMessage());
29 }
30 }
31
32 // Fallback to raw HTTP if Spring Vault is unavailable
33 String body = json.writeValueAsString(new CiphertextPayload(value));
34 String path = String.format("/v1/%s/decrypt/%s", config.getKeyPath(), config.getKeyName());
35 HttpRequest req = buildPost(path, body);
36 HttpResponse<String> res = http.send(req, HttpResponse.BodyHandlers.ofString());
37 JsonNode root = json.readTree(res.body());
38 String b64 = root.path("data").path("plaintext").asText("");
39 return new String(Base64.getDecoder().decode(b64), StandardCharsets.UTF_8);
40}
Spring Vault's Transit API handles the encryption/decryption directly: vaultTransit.encrypt() and vaultTransit.decrypt() manage Base64 encoding, HTTP serialization, and response parsing. If Spring Vault encounters an error, the application falls back to raw HTTP calls using the same Vault API endpoints — ensuring resilience when the SDK library has version incompatibilities.
Spring Vault also handles database credentials seamlessly:
1public Optional<DatabaseCredentials> readDatabaseCredentials(String path) {
2 if (!enabled || path.isBlank()) return Optional.empty();
3
4 try {
5 if (vaultDbOps != null) {
6 // Spring Vault database credentials API
7 var creds = vaultDbOps.getCredentials(roleName);
8 return Optional.of(new DatabaseCredentials(creds.getUsername(), creds.getPassword()));
9 }
10 } catch (Exception e) {
11 log.warn("Spring Vault DB credentials failed ({}); using fallback.", e.getMessage());
12 }
13
14 // Fallback to raw HTTP if Spring Vault unavailable
15 HttpRequest req = buildGet("/v1/" + path);
16 HttpResponse<String> res = http.send(req, HttpResponse.BodyHandlers.ofString());
17 // ... parse JSON response ...
18}
The path (dynamic-app/db/creds/app) comes from config.ini — the same configuration-driven approach as the other implementations.
The nomad/java/ directory contains the same progression of job files as the Python series:
app_hardcoded.hcl)Credentials written directly into the template. Identical to the starting point in the original post, just using the java driver.
1task "java-app" {
2 driver = "java"
3
4 config {
5 jar_path = "local/nomad-vault-mysql-java.jar"
6 jvm_options = ["-Xmx256m", "-Xms128m"]
7 }
8
9 artifact {
10 source = "https://github.com/infralovers/nomad-vault-mysql/releases/download/java-artifact-latest/nomad-vault-mysql-java.jar"
11 destination = "local/"
12 }
13
14 template {
15 destination = "local/config/config.ini"
16 data = <<EOF
17[DATABASE]
18Address = 127.0.0.1
19Port = 3306
20Database = my_app
21User = root
22Password = super-duper-password
23
24[VAULT]
25Enabled = False
26EOF
27 }
28}
app_dynamic.hcl)Adds the vault stanza and Consul template expressions to pull short-lived credentials from Vault's database secrets engine:
1group "java-app" {
2 vault {
3 policies = ["nomad-dynamic-app"]
4 change_mode = "signal"
5 change_signal = "SIGINT"
6 }
7
8 task "java-app" {
9 driver = "java"
10 # ...
11 template {
12 destination = "local/config/config.ini"
13 data = <<EOF
14[DATABASE]
15{{ range service "mysql-server" }}
16Address = {{ .Address }}
17Port = {{ .Port }}
18{{ end }}
19{{ with secret "dynamic-app/kv/database" }}
20Database = {{ .Data.data.database }}
21{{ end }}
22{{ with secret "dynamic-app/db/creds/app" }}
23User = {{ .Data.username }}
24Password = {{ .Data.password }}
25{{ end }}
26
27[VAULT]
28Enabled = False
29EOF
30 }
31 }
32}
When Vault rotates the credentials (default TTL: 1 hour), Nomad receives a SIGINT and the application re-reads the config file — zero-downtime credential rotation without any application-level logic.
app_transit.hcl)Enables the Vault Transit engine for field-level encryption. The only change from app_dynamic.hcl is adding the [VAULT] section with Enabled = True:
1template {
2 destination = "local/config/config.ini"
3 data = <<EOF
4# ... DATABASE section as above ...
5
6[VAULT]
7Enabled = True
8InjectToken = True
9Namespace =
10Address = {{ env "VAULT_ADDR" }}
11KeyPath = dynamic-app/transit
12KeyName = app
13EOF
14}
app_transit_connect.hcl)The most complete variant adds a Consul Connect sidecar so MySQL traffic flows through the service mesh (mTLS), matching the topology from our Consul Connect post:
1network {
2 mode = "bridge"
3 port "web" {}
4}
5
6service {
7 connect {
8 sidecar_service {
9 proxy {
10 upstreams {
11 destination_name = "mysql-server"
12 local_bind_port = 3306
13 }
14 }
15 }
16 }
17}
The application connects to 127.0.0.1:3306 — the local Envoy sidecar — and Consul handles the mTLS connection to the MySQL server transparently.
java driver | docker driver | |
|---|---|---|
| Container runtime required | ✗ | ✓ |
| Image pull overhead | ✗ | ✓ |
| Artifact source | Any URL, S3, GCS | Container registry |
| JVM version management | Host OS or Packer | Dockerfile |
| Process isolation | OS process (cgroups) | Container namespace |
| Multi-language workloads | JVM only | Any language |
| Best for | Bare metal, edge, JVM-only fleets | Mixed workloads, portability |
The java driver is not a replacement for Docker — it is a first-class option for environments where Docker is impractical or where you simply want to ship JARs rather than images.
The CI workflow builds the fat JAR and publishes it to GitHub Releases on every push to main:
1- name: Build JAR
2 run: mvn -q package -DskipTests
3 working-directory: app/java
4
5- name: Publish to GitHub Releases
6 uses: softprops/action-gh-release@v2
7 with:
8 tag_name: java-artifact-latest
9 files: app/java/target/nomad-vault-mysql-java-*.jar
10 name: nomad-vault-mysql-java.jar
The java-artifact-latest tag is updated on every build, so the Nomad job always downloads the latest version. For pinned deployments, use a versioned tag instead.
The Nomad java driver enables a genuinely different deployment model:
SERVER_PORTThe full source code including all four job variants is at github.com/infralovers/nomad-vault-mysql.
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