HashiCorp Nomad is a workload orchestrator: you describe an application in a job file, and Nomad decides which machines run it, restarts it when it fails and replaces it during updates. It is a single binary with far fewer moving parts than Kubernetes. In this tutorial you will install Nomad on two Ubuntu 24.04 servers, one acting as the Nomad server and one as a client that runs Docker containers, secure the cluster with ACLs, and deploy a web service with health checks and rolling updates.
Prerequisites
To follow this tutorial, you will need:
- Two servers running Ubuntu 24.04 LTS, for example CubePath VPS instances, connected through a private network. Each needs at least 1 GB of RAM; the client needs more if you plan to run real workloads.
- A non-root user with
sudoprivileges on both servers. - UFW enabled with SSH allowed.
The examples use these hostnames and private addresses. Replace them with your own:
| Role | Hostname | Private IP |
|---|---|---|
| Nomad server | nomad-server | 10.0.0.10 |
| Nomad client | nomad-client1 | 10.0.0.11 |
A single server is fine for learning and small setups. For production, run three servers so the cluster survives the loss of one; the configuration change is explained in the conclusion.
Step 1 - Installing Nomad from the HashiCorp repository
Run the commands in this step on both servers. HashiCorp publishes signed packages in its own APT repository. Download the signing key into /etc/apt/keyrings:
sudo install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://apt.releases.hashicorp.com/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/hashicorp-archive-keyring.gpg
Add the repository for your Ubuntu release (noble on 24.04):
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/hashicorp-archive-keyring.gpg] https://apt.releases.hashicorp.com $(. /etc/os-release && echo "$VERSION_CODENAME") main" | sudo tee /etc/apt/sources.list.d/hashicorp.list
Install Nomad:
sudo apt update
sudo apt install nomad
Verify the installation:
nomad version
Nomad v1.10.5
BuildDate 2025-09-09T14:35:42Z
Revision ...
The package installs a systemd unit, an example configuration in /etc/nomad.d/nomad.hcl and the data directory /opt/nomad/data. You will replace the example configuration in the next steps.
Step 2 - Configuring the Nomad server
On nomad-server, generate a gossip encryption key. Servers use it to encrypt their cluster membership traffic:
nomad operator gossip keyring generate
YFtsh2DzoNXx7LY6gNsexGLJEdkHSWSG5dqbwrNUxIQ=
Replace the example configuration:
sudo nano /etc/nomad.d/nomad.hcl
datacenter = "dc1"
data_dir = "/opt/nomad/data"
bind_addr = "0.0.0.0"
advertise {
http = "10.0.0.10"
rpc = "10.0.0.10"
serf = "10.0.0.10"
}
server {
enabled = true
bootstrap_expect = 1
encrypt = "your_gossip_key"
}
acl {
enabled = true
}
Replace your_gossip_key with the key you generated. The settings mean:
advertisetells other nodes to reach this server on its private address, even though it listens on all interfaces.bootstrap_expect = 1lets a single server elect itself leader. With three servers, this becomes3.aclrequires a token for every API call, including the web UI. Without it, anyone who can reach port 4646 can run arbitrary workloads on your clients.
Nomad uses three ports: 4646 for the HTTP API and UI, 4647 for RPC from clients and 4648 for gossip between servers. Allow them only from the private network:
sudo ufw allow from 10.0.0.0/24 to any port 4646:4648 proto tcp
sudo ufw allow from 10.0.0.0/24 to any port 4648 proto udp
Start the service:
sudo systemctl enable --now nomad
Check that the server has elected itself leader:
sudo journalctl -u nomad --no-pager | grep -i leader
... nomad.raft: entering leader state: leader="Node at 10.0.0.10:4647 [Leader]"
Step 3 - Bootstrapping the ACL system
With ACLs enabled, you need a management token before you can do anything else. Create it once on nomad-server:
nomad acl bootstrap
Accessor ID = 2c4b1c43-6f0d-2a8e-9b0b-2f1e9a6b9d51
Secret ID = 9d7e2f5a-3b1c-4e8d-a6f0-1c2b3d4e5f60
Name = Bootstrap Token
Type = management
Global = true
Create Time = 2026-09-25 10:12:03 +0000 UTC
Expiry Time = <none>
Create Index = 12
Modify Index = 12
Policies = n/a
Roles = n/a
The Secret ID is a root credential for the whole cluster, and it cannot be displayed again. Store it in your password manager. Export it in your shell so the nomad command can use it:
export NOMAD_TOKEN=your_secret_id
Confirm that the token works by listing the server members:
nomad server members
Name Address Port Status Leader Raft Version Build Datacenter Region
nomad-server.global 10.0.0.10 4648 alive true 3 1.10.5 dc1 global
Step 4 - Installing Docker on the client
Nomad clients run tasks through drivers. The Docker driver is built into Nomad and only needs Docker Engine on the client. On nomad-client1, install Docker from the Ubuntu repositories:
sudo apt install docker.io
sudo systemctl enable --now docker
Verify that Docker can run a container:
sudo docker run --rm hello-world
Hello from Docker!
This message shows that your installation appears to be working correctly.
...
The packaged Nomad service runs as root, which clients need to manage containers and mount task directories, so you do not have to add any user to the docker group.
Step 5 - Configuring the Nomad client
On nomad-client1, replace the example configuration:
sudo nano /etc/nomad.d/nomad.hcl
datacenter = "dc1"
data_dir = "/opt/nomad/data"
bind_addr = "0.0.0.0"
advertise {
http = "10.0.0.11"
rpc = "10.0.0.11"
serf = "10.0.0.11"
}
client {
enabled = true
servers = ["10.0.0.10:4647"]
}
acl {
enabled = true
}
The servers list tells the client where to register. Nomad assigns dynamic ports for tasks from the range 20000 to 32000, so the client must accept those from the network that will consume the services, plus the HTTP API from the server:
sudo ufw allow from 10.0.0.0/24 to any port 4646 proto tcp
sudo ufw allow from 10.0.0.0/24 to any port 20000:32000 proto tcp
Start the client:
sudo systemctl enable --now nomad
Back on nomad-server, with NOMAD_TOKEN exported, check that the client registered and detected Docker:
nomad node status
ID Node Pool DC Name Class Drain Eligibility Status
3c1f2a9b default dc1 nomad-client1 <none> false eligible ready
nomad node status -verbose 3c1f2a9b | grep -A 6 'Drivers'
The docker driver must appear as healthy. If it shows as undetected, Docker is not running on the client.
Step 6 - Writing and running a job
A job file describes what to run. This one runs two NGINX containers, maps a dynamic host port to port 80 in each container, and registers them in Nomad's built-in service catalog with an HTTP health check. On nomad-server, create the file in your home directory:
nano ~/web.nomad.hcl
job "web" {
datacenters = ["dc1"]
type = "service"
group "nginx" {
count = 2
network {
port "http" {
to = 80
}
}
update {
max_parallel = 1
health_check = "checks"
min_healthy_time = "10s"
healthy_deadline = "3m"
auto_revert = true
}
service {
name = "web"
port = "http"
provider = "nomad"
check {
type = "http"
path = "/"
interval = "10s"
timeout = "2s"
}
}
task "nginx" {
driver = "docker"
config {
image = "nginx:1.27-alpine"
ports = ["http"]
}
resources {
cpu = 100
memory = 64
}
}
}
}
The main blocks are:
group: a set of tasks that always run together on the same client.count = 2runs two copies.network: requests a port calledhttp. Withoutstatic, Nomad picks a free port on the client and maps it to port 80 inside the container.servicewithprovider = "nomad": registers each copy in Nomad's service catalog, no Consul required.update: replaces copies one at a time during deployments, waits for health checks to pass and rolls back automatically if they do not.resources: CPU in MHz and memory in MB that Nomad reserves for the task. Nomad refuses to place a task that does not fit on any client.
Validate the file before submitting it:
nomad job validate ~/web.nomad.hcl
Job validation successful
Run the job:
nomad job run ~/web.nomad.hcl
==> 2026-09-25T10:20:14Z: Monitoring evaluation "a1f3c2d4"
2026-09-25T10:20:14Z: Evaluation triggered by job "web"
2026-09-25T10:20:15Z: Allocation "8e5d7c21" created: node "3c1f2a9b", group "nginx"
2026-09-25T10:20:15Z: Allocation "f02b9a64" created: node "3c1f2a9b", group "nginx"
==> 2026-09-25T10:20:15Z: Monitoring deployment "5b7e0d18"
2026-09-25T10:20:41Z: Deployment "5b7e0d18" successful
...
Each running copy of a group is called an allocation. List them with the job status:
nomad job status web
Step 7 - Finding and testing the service
Ask Nomad where the healthy instances of the web service are running:
nomad service info web
Job ID Address Tags Node ID Alloc ID
web 10.0.0.11:24317 [] 3c1f2a9b 8e5d7c21
web 10.0.0.11:29856 [] 3c1f2a9b f02b9a64
Send a request to one of the addresses:
curl -I http://10.0.0.11:24317
HTTP/1.1 200 OK
Server: nginx/1.27.5
...
To read a task's logs, use the allocation ID:
nomad alloc logs 8e5d7c21 nginx
Other jobs can find the service without hard-coding addresses. For example, a template block inside a task can render {{ range nomadService "web" }}{{ .Address }}:{{ .Port }} {{ end }} into a load balancer configuration, and Nomad re-renders it whenever instances move.
Step 8 - Performing a rolling update
To deploy a new version, change the image in ~/web.nomad.hcl:
image = "nginx:1.28-alpine"
Preview what Nomad will do before applying it:
nomad job plan ~/web.nomad.hcl
+/- Job: "web"
+/- Task Group: "nginx" (1 create/destroy update, 1 ignore)
+/- Task: "nginx" (forces create/destroy update)
+/- Config {
+/- image: "nginx:1.27-alpine" => "nginx:1.28-alpine"
}
Scheduler dry-run:
- All tasks successfully allocated.
Job Modify Index: 18
To submit the job with version verification run:
nomad job run -check-index 18 web.nomad.hcl
...
Run the command that plan suggests. The -check-index flag makes the run fail if someone else changed the job since your plan:
nomad job run -check-index 18 ~/web.nomad.hcl
Nomad replaces one allocation, waits until its health check has passed for 10 seconds, then replaces the next. If the new version never becomes healthy, auto_revert restores the previous version. List the versions Nomad has kept:
nomad job history web
To stop the job and remove its allocations, run nomad job stop -purge web.
Step 9 - Opening the web UI
The Nomad UI is served on port 4646, which you only opened to the private network. From your workstation, reach it through an SSH tunnel:
ssh -L 4646:127.0.0.1:4646 your_user@your_server_ip
Open http://localhost:4646 in your browser, choose Sign In and paste the bootstrap Secret ID. The UI shows jobs, allocations, client resources and live logs.
Troubleshooting
Permission denied or 403 from the CLI: NOMAD_TOKEN is not exported in the current shell, or it contains the Accessor ID instead of the Secret ID.
The client never appears in nomad node status: check sudo journalctl -u nomad -n 50 on the client. Messages such as no servers or connection refused mean port 4647 on the server is not reachable from the client, or the servers address is wrong.
Allocations stay pending: run nomad job status web and read the Placement Failure section. The usual causes are resources that do not fit on any client and a missing or unhealthy Docker driver.
Allocations keep restarting: inspect nomad alloc status <alloc_id> for recent events and nomad alloc logs -stderr <alloc_id> nginx for the container output.
Deployments fail with health check failed: the task starts but the check never passes. Confirm that the to port matches the port the application listens on inside the container.
Conclusion
You installed Nomad from HashiCorp's repository, secured the cluster with gossip encryption and ACLs, and deployed a Docker service that Nomad health-checks, registers in its service catalog and updates without downtime. For production, add two more servers with bootstrap_expect = 3 and a server_join { retry_join = [...] } block listing all three, create ACL policies with narrower tokens for your CI pipeline, and enable TLS between nodes with nomad tls ca create and nomad tls cert create.
