Dockerless Java on HashiCorp Nomad: Running Spring Boot with the Java Driver


Bicycle

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.

How the Nomad Java Driver Works

The java driver is a first-class Nomad task driver. When Nomad schedules a task using it, the lifecycle is:

  1. Artifact fetch — Nomad downloads the specified JAR (or ZIP) to the task's local/ directory.
  2. Allocation setup — Nomad creates a network namespace and renders any template blocks.
  3. Process launch — Nomad invokes java -jar <jar_path> [jvm_options] as a child process.
  4. Supervision — Nomad monitors the process, applies resource limits, and restarts on failure according to the restart stanza.
  5. Log capture — stdout/stderr are streamed through Nomad's log driver, visible in the Nomad UI and CLI just like any container workload.

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.).

The Artifact Pattern: GitHub Releases Instead of a Registry

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.

Dynamic Port Allocation

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 Spring Boot Application

The Java application is a Spring Boot 3.3.5 + Java 21 MVC application that mirrors the Python implementation in functionality:

  • CRUD operations on a customers table in MySQL
  • Vault Transit encryption via Spring Vault for birth_date, address, and salary
  • Dynamic database credentials from Vault's database secrets engine
  • Configuration via config.ini (same format as Python and .NET)
  • /health endpoint for Nomad service checks

Application Structure

app/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 Vault Client — Spring Vault

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.

Transit Encryption with Spring Vault

 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.

Reading Dynamic Database Credentials with Spring Vault

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.

Progressive Job Variants

The nomad/java/ directory contains the same progression of job files as the Python series:

1. Hardcoded (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}

2. Dynamic Database Credentials (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.

3. Transit Encryption (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}

4. Transit + Consul Connect (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 vs Docker Driver: When to Use Each

java driverdocker driver
Container runtime required
Image pull overhead
Artifact sourceAny URL, S3, GCSContainer registry
JVM version managementHost OS or PackerDockerfile
Process isolationOS process (cgroups)Container namespace
Multi-language workloadsJVM onlyAny language
Best forBare metal, edge, JVM-only fleetsMixed 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.

Building and Publishing the JAR

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.

Summary

The Nomad java driver enables a genuinely different deployment model:

  • No Docker daemon on the worker node
  • Artifact-based delivery — the JAR is fetched at schedule time from any HTTP endpoint
  • Dynamic port allocation — Nomad assigns a free port; Spring Boot reads SERVER_PORT
  • Full Vault integration — the same Transit encryption and dynamic credentials pattern works identically to the Docker-based variants
  • Consul Connect — service mesh mTLS is available even without containers

The full source code including all four job variants 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