Nomad Platform Deployment
Nomad is the scale-up path from the single-node Compose bundle: the same pinned platform versions, scheduled by a HashiCorp Nomad cluster. Below: installing Nomad, what the platform jobs look like, how to migrate the database before the Manager starts, and how to deploy, monitor and troubleshoot the jobs.
These jobs are rendered, not hand-authored
The platform stack (Manager, proxy, Pocket ID, Postgres, and the Caddyfile prelude) renders from a single install.yaml with pfy platform render --target nomad / pfy platform deploy, and application jobs render from a deployment spec with pfy nomad render|deploy. Do not hand-edit job files: hand edits are drift and are overwritten.
The HCL on this page is illustrative. It shows the shape of each job and the environment the Manager needs; it is not byte-for-byte renderer output. The real rendered platform jobs for a sample install are committed in deployments/examples/qa.productify.dev/rendered/ (productify-manager.nomad, productify-proxy.nomad, productify-pocket-id.nomad, productify-postgres.nomad and the Caddyfile), and deployments/NOMAD.md walks the single-node render + deploy. Start from Generated Artifacts.
Prerequisites
Required Software
- Nomad: 1.6+ cluster (server and client nodes)
- PostgreSQL: 16+ database
- Docker: Runtime on all Nomad client nodes
System Requirements
- Memory: 8 GB RAM minimum per node (16 GB recommended)
- CPU: 4 cores minimum per node
- Storage: 50 GB free disk space per node
- Network: Low latency between nodes (< 10ms for same datacenter)
Nomad Installation
Linux (AMD64)
# Download Nomad
wget https://releases.hashicorp.com/nomad/1.7.3/nomad_1.7.3_linux_amd64.zip
# Extract
unzip nomad_1.7.3_linux_amd64.zip
# Move to system path
sudo mv nomad /usr/local/bin/
# Verify installation
nomad versionmacOS (Homebrew)
# Install Nomad
brew install nomad
# Verify installation
nomad versionOther Platforms
See the official Nomad installation guide for other platforms and installation methods.
Nomad Agent Setup
Development Mode (Single Node)
For local testing and development:
# Start Nomad in development mode (server + client)
sudo nomad agent -dev
# In another terminal, verify
nomad node status
nomad server members
# Access Web UI
open http://localhost:4646Important Nomad Ports:
4646: HTTP API and Web UI4647: RPC (Remote Procedure Call)4648: Serf WAN (gossip protocol)
Production Cluster
For production, separate server and client nodes are recommended.
Server Node:
sudo nomad agent -config=/etc/nomad.d/server.hclserver.hcl:
datacenter = "dc1"
data_dir = "/var/lib/nomad"
server {
enabled = true
bootstrap_expect = 3
}Client Node:
sudo nomad agent -config=/etc/nomad.d/client.hclclient.hcl:
datacenter = "dc1"
data_dir = "/var/lib/nomad"
client {
enabled = true
}
plugin "docker" {
config {
allow_privileged = false
}
}Complete Job Specification
Manager Job
A maintained reference job with an api group and an executor group is at manager/nomad/manager.nomad; it takes its image and database settings as Nomad variables. The matching one-shot migration job is manager/nomad/manager-migrate.nomad (see Database migrations).
job "manager" {
datacenters = ["dc1"]
type = "service"
group "api" {
count = 3
update {
max_parallel = 1
health_check = "checks"
min_healthy_time = "10s"
healthy_deadline = "3m"
auto_revert = true
}
network {
port "http" {
to = 8080
}
port "health" {
to = 8081
}
}
service {
name = "manager-api"
port = "http"
provider = "nomad"
tags = ["productify", "api"]
check {
type = "http"
port = "health"
path = "/healthz"
interval = "10s"
timeout = "2s"
}
}
task "server" {
driver = "docker"
config {
image = "ghcr.io/productifyfw/manager:2.0.0"
ports = ["http", "health"]
}
env {
PFY_ENV = "production"
PFY_RUN_MODE = "api"
PFY_PORT = "8080"
PFY_HEALTH_PORT = "8081"
# PFY_DB_SSLMODE is NOT set here on purpose. It defaults to "require",
# and setting it to "disable" alongside PFY_ENV=production makes the
# Manager exit at startup:
# invalid configuration: db.sslmode=disable is not allowed in
# production (use require or stronger)
# Set it to verify-full once the database presents a verifiable cert.
}
# Read secrets from Nomad Variables
template {
data = <<EOH
{{ with nomadVar "nomad/jobs/manager" -}}
PFY_DB_HOST={{ .db_host }}
PFY_DB_PORT={{ .db_port }}
PFY_DB_USER={{ .db_user }}
PFY_DB_PASSWORD={{ .db_password }}
PFY_DB_NAME={{ .db_name }}
PFY_POCKET_ID_HOST={{ .pocket_id_host }}
PFY_POCKET_ID_API_KEY={{ .pocket_id_api_key }}
# Trust boundary. Must equal the proxy's PFY_MANAGER_TOKEN. The API task refuses
# to start in production without it; a wrong value is not caught at startup and
# answers every request with 401 {"error": "missing or invalid X-Proxy-Auth ..."}.
PFY_PROXY_SECRET={{ .proxy_secret }}
# At-rest encryption of endpoint/backend secrets. Required in production; the
# Manager refuses to start without it.
PFY_ENCRYPTION_KEY={{ .encryption_key }}
# Pilet provenance signing. Not checked at startup -- unset means the first
# pilet upload is rejected instead.
PFY_PILETS_SIGNING_PRIVATE_KEY={{ .pilet_signing_key }}
PFY_PILETS_SIGNING_KEY_ID={{ .pilet_signing_key_id }}
# Scale-from-zero. The default kind is "noop", which answers every wake
# successfully and scales nothing.
PFY_ORCHESTRATOR_KIND=nomad
PFY_ORCHESTRATOR_NOMAD_ADDRESS={{ .nomad_address }}
PFY_ORCHESTRATOR_NOMAD_TOKEN={{ .nomad_token }}
{{- end }}
EOH
destination = "secrets/env"
env = true
}
resources {
cpu = 500
memory = 512
}
}
}
group "executor" {
count = 1
constraint {
operator = "distinct_hosts"
value = "true"
}
network {
port "metrics" {
to = 9090
}
}
service {
name = "manager-executor"
port = "metrics"
provider = "nomad"
tags = ["productify", "executor"]
}
task "executor" {
driver = "docker"
config {
image = "ghcr.io/productifyfw/manager:2.0.0"
ports = ["metrics"]
}
env {
PFY_ENV = "production"
PFY_RUN_MODE = "executor"
PFY_CRON_METRICS_PORT = "9090"
# Same rule as the api group: leave PFY_DB_SSLMODE at its "require"
# default. "disable" plus env=production is a fatal startup error.
}
template {
data = <<EOH
{{ with nomadVar "nomad/jobs/manager" -}}
PFY_DB_HOST={{ .db_host }}
PFY_DB_PORT={{ .db_port }}
PFY_DB_USER={{ .db_user }}
PFY_DB_PASSWORD={{ .db_password }}
PFY_DB_NAME={{ .db_name }}
# Validate() and ConfigureEncryption() run in EVERY process, not just the API.
# An executor missing either of these refuses to start.
PFY_POCKET_ID_API_KEY={{ .pocket_id_api_key }}
PFY_ENCRYPTION_KEY={{ .encryption_key }}
{{- end }}
EOH
destination = "secrets/env"
env = true
}
resources {
cpu = 200
memory = 256
}
}
}
}Optimizer Job
The optimizer image ghcr.io/productifyfw/autoscaler/optimizer:<version> is a private ghcr package until it is made public; the Nomad client's Docker driver needs registry credentials (auth in the task config, or a docker config on the node) to pull it. ghcr.io/productifyfw/autoscaler-nomadscaler is public.
job "optimizer" {
datacenters = ["dc1"]
type = "service"
group "optimizer" {
count = 2
network {
port "http" {
to = 8015
}
}
service {
name = "optimizer"
port = "http"
provider = "nomad"
tags = ["productify", "autoscaler"]
check {
type = "http"
path = "/health"
interval = "10s"
timeout = "2s"
}
}
task "server" {
driver = "docker"
config {
image = "ghcr.io/productifyfw/autoscaler/optimizer:latest"
ports = ["http"]
}
env {
CONFIG_PATH = "${NOMAD_TASK_DIR}/config.ini"
PORT = "${NOMAD_PORT_http}"
}
template {
data = <<EOF
[main]
loglevel=info
api_loglevel=warning
only_test_data=false
enable_test_metrics=false
prometheus_url=http://prometheus:9090
port={{ env "NOMAD_PORT_http" }}
token=<optimizer-token>
EOF
destination = "${NOMAD_TASK_DIR}/config.ini"
}
resources {
cpu = 1000
memory = 1024
}
}
}
}Proxy Job
The job below is the smallest thing that runs: a hand-written Caddyfile with the admin API off and no login portal, fronting the Manager. It is enough to see how the proxy is scheduled, and not enough for a real install: without the caddy-security portal nobody can log in, and with admin off the Manager cannot push application vhosts. Use the rendered productify-proxy.nomad (see the tip at the top) or proxy/nomad/proxy.nomad, which reads the rendered Caddyfile, manager_token and oauth_client_secret from the Nomad Variable nomad/jobs/proxy.
job "proxy" {
datacenters = ["dc1"]
type = "system" # Deploy on all nodes
group "caddy" {
network {
port "http" {
static = 80
to = 80
}
port "https" {
static = 443
to = 443
}
port "metrics" {
to = 2112
}
}
service {
name = "proxy"
port = "http"
provider = "nomad"
tags = ["productify", "proxy"]
check {
type = "http"
port = "metrics"
path = "/metrics"
interval = "10s"
timeout = "2s"
}
}
task "caddy" {
driver = "docker"
config {
image = "ghcr.io/productifyfw/proxy:1.1.0"
ports = ["http", "https", "metrics"]
volumes = [
"local/Caddyfile:/etc/caddy/Caddyfile"
]
}
template {
data = <<EOH
{
admin off
email admin@example.com
}
manager.example.com {
reverse_proxy {
{{- range nomadService "manager-api" }}
to {{ .Address }}:{{ .Port }}
{{- end }}
lb_policy least_conn
health_interval 10s
}
}
EOH
destination = "local/Caddyfile"
change_mode = "script"
change_script {
command = "/usr/bin/caddy"
args = ["reload", "--config", "/local/Caddyfile", "--force"]
timeout = "5s"
fail_on_error = false
}
}
resources {
cpu = 200
memory = 256
}
}
}
}proxy/nomad/proxy.nomad is the maintained reference: it runs the proxy in host networking with the rendered Caddyfile, plus a pocketid group. The renderer's output for a sample install is deployments/examples/qa.productify.dev/rendered/productify-proxy.nomad.
Autoscaler Plugin Job
The ghcr.io/productifyfw/autoscaler-nomadscaler image (published by the autoscaler release workflow) bundles the Nomad Autoscaler agent with the productify-scaler strategy plugin preinstalled at /plugins/productify-scaler. A complete reference job (agent + Optimizer + Prometheus) is at autoscaler/nomadscaler/config/autoscaler.hcl; that file uses locally built productify/scaler:local and productify/optimizer:local images (from nomadscaler/build.sh), so substitute the published tags when you deploy it.
job "nomad-autoscaler" {
datacenters = ["dc1"]
type = "service"
group "autoscaler" {
count = 1
network {
port "http" {
to = 8080
}
}
service {
name = "nomad-autoscaler"
port = "http"
provider = "nomad"
check {
type = "http"
path = "/v1/health"
interval = "10s"
timeout = "2s"
}
}
task "autoscaler" {
driver = "docker"
config {
image = "ghcr.io/productifyfw/autoscaler-nomadscaler:latest"
command = "nomad-autoscaler"
args = ["agent", "-config", "${NOMAD_TASK_DIR}/config.hcl"]
}
# Autoscaler configuration
template {
data = <<EOH
plugin_dir = "/plugins"
nomad {
address = "http://{{ env "attr.unique.network.ip-address" }}:4646"
}
apm "nomad" {
driver = "nomad-apm"
config = {
address = "http://{{ env "attr.unique.network.ip-address" }}:4646"
}
}
strategy "productify-scaler" {
driver = "productify-scaler"
config = {
optimizer_url = "http://{{- with nomadService "optimizer" }}{{ with index . 0 }}{{ .Address }}:{{ .Port }}{{ end }}{{ end }}"
optimizer_token = "<optimizer-token>"
}
}
http {
bind_address = "0.0.0.0"
bind_port = 8080
}
telemetry {
prometheus_metrics = true
prometheus_retention_time = "24h"
}
EOH
destination = "local/config.hcl"
}
resources {
cpu = 200
memory = 256
}
}
}
}Scaling policies are embedded in the target job's scaling block (not deployed with the agent). For example, in the manager job's api group:
scaling {
enabled = true
min = 2
max = 10
policy {
evaluation_interval = "10s"
cooldown = "30s"
check "productify-scale-check" {
source = "nomad-apm"
query = "avg_cpu-allocated"
strategy "productify-scaler" {
min = 2
max = 10
metric_app_name = "manager-api"
# The Nomad identifiers, which are a different identity from the `app`
# label above: the nomad_client_allocs_* series that measure per-replica
# capacity are exported by the Nomad client and carry the job's OWN
# names. This policy lives in the `api` group, whose task is `server`.
group = "api"
task = "server"
# Seconds each optimizer response covers. Above the optimizer's step
# (15s by default) it holds a multi-step plan, so the count can ramp
# between calls instead of moving one step per call.
cache_size = 30
}
}
}
}Monitoring
Monitor the Manager Executor, the Nomad Autoscaler, and Nomad itself using Prometheus:
# prometheus.yml
scrape_configs:
- job_name: "manager-executor"
static_configs:
- targets: ["manager-executor:9090"]
- job_name: "nomad-autoscaler"
metrics_path: /v1/metrics
params:
format: ["prometheus"]
static_configs:
- targets: ["nomad-autoscaler:8080"]
- job_name: "nomad"
metrics_path: /v1/metrics
params:
format: ["prometheus"]
static_configs:
- targets: ["localhost:4646"]Deployment Commands
Database migrations
Run this before the Manager job, on a fresh database and on every upgrade that ships a migration. The Manager never migrates on boot in any run mode, and the API group will start against an unmigrated database and then fail on every query.
Simplest path: a batch job running manager -migrate from the image you are deploying. It applies the SQL compiled into that binary through the Atlas Go SDK, so nothing is mounted and nothing is fetched, and the migration set cannot drift from the binary:
job "manager-migrate" {
datacenters = ["dc1"]
type = "batch"
group "migrate" {
task "migrate" {
driver = "docker"
config {
image = "ghcr.io/productifyfw/manager:2.0.0"
args = ["/app/manager", "-migrate"]
}
template {
data = <<EOT
{{ with nomadVar "nomad/jobs/manager" }}PFY_DB_HOST={{ .db_host }}
PFY_DB_PORT={{ .db_port }}
PFY_DB_USER={{ .db_user }}
PFY_DB_PASSWORD={{ .db_password }}
PFY_DB_NAME={{ .db_name }}{{ end }}
PFY_DB_SSLMODE=require
EOT
destination = "secrets/env"
env = true
}
}
}
}Applying is idempotent, so the batch job can stay in the deploy pipeline, and concurrent runs are serialised by a Postgres advisory lock.
Or from a checkout on any host that can reach the database:
cd manager
git checkout <the-tag-you-are-deploying> # migrations must match the image
make migrate-apply DB_URL='postgres://user:pass@db.internal:5432/productify?sslmode=require'The Atlas CLI path still works and writes the same revision history, so an existing pipeline built on it needs no change. manager/nomad/manager-migrate.nomad takes it, using the upstream Atlas image with the migration files delivered by artifact (run it with -var db_host=… -var db_password=… and a pinned -var ref=<tag>; the Nomad client needs git on its PATH for the git artifact). The rendered platform stack instead runs migrate as a prestart task from a pinned tarball named in install.yaml (deployments/NOMAD.md, "Database schema"). Abbreviated:
job "manager-migrate" {
datacenters = ["dc1"]
type = "batch"
group "migrate" {
task "atlas" {
driver = "docker"
config {
image = "arigaio/atlas:latest"
work_dir = "/local"
args = [
"migrate", "apply",
"--dir", "file:///local/migrations",
"--url", "${DB_URL}",
]
}
artifact {
# The migration set for the exact manager version being deployed.
source = "git::https://github.com/ProductifyFW/manager.git//ent/migrate/migrations?ref=v2.0.0"
destination = "local/migrations"
}
template {
data = "DB_URL={{ with nomadVar \"nomad/jobs/manager\" }}postgres://{{ .db_user }}:{{ .db_password }}@{{ .db_host }}:{{ .db_port }}/{{ .db_name }}?sslmode=require{{ end }}"
destination = "secrets/env"
env = true
}
}
}
}atlas migrate apply is idempotent too. Its apply path takes no advisory lock, so do not run it alongside a manager -migrate.
Also run the derived-name audit before an upgrade
manager -audit-derived-names reports deployments whose derived Nomad upstream or proxy hostname collides, and exits non-zero when any do. Run it after the migration and before starting the new server; a collision that predates the uniqueness rule will otherwise block every deployment write. Like -migrate, it runs from the shipped image: it needs only the binary and the database.
Deploy Manager Component
cd manager/nomad
# Validate job specification
nomad job validate manager.nomad
# Plan the deployment (dry run). The job takes its image and database settings
# as variables (see the `variable` blocks at the top of the file).
nomad job plan -var image=ghcr.io/productifyfw/manager:<tag> -var db_host=... manager.nomad
# Deploy the job
nomad job run -var image=ghcr.io/productifyfw/manager:<tag> -var db_host=... manager.nomad
# Check job status
nomad job status manager
# View allocations (running instances)
nomad alloc status <alloc-id>
# Follow logs for a specific allocation
nomad alloc logs -f <alloc-id> serverManager Job Components:
- API Group: 3 instances (horizontally scaled)
- Serves GraphQL API
- Handles HTTP requests
- Registers with Nomad service discovery
- Executor Group: 1 instance
- Runs scheduled jobs and triggers
- Processes background tasks
Deploy Proxy and Authentication
cd proxy/nomad
# The proxy job includes both the Caddy proxy and Pocket ID
nomad job validate proxy.nomad
nomad job run proxy.nomad
# Check status
nomad job status proxy
# View service endpoints
nomad service listDeploy Autoscaler Stack
cd autoscaler/nomadscaler/config
# Deploy autoscaler with optimizer and Prometheus
nomad job validate autoscaler.hcl
nomad job run autoscaler.hcl
# Check status
nomad job status autoscaler
# Find the dynamically assigned Prometheus port
nomad alloc status <autoscaler-alloc-id> | grep -A 10 "Ports"
# Access Prometheus UI (using the assigned port)
open http://localhost:<prometheus-port>
# Verify metrics collection
curl http://localhost:<prometheus-port>/api/v1/targetsAutoscaler Components:
- Nomad Autoscaler Plugin: Evaluates scaling policies
- Optimizer Service: Forecasts demand and solves for the replica count (Python/FastAPI)
- Prometheus: Collects and stores metrics
Deploy All Jobs (Complete Stack)
The rendered path does the ordering for you:
pfy platform render --target nomad --out-dir ./out # inspect the four jobs + Caddyfile
pfy platform deploy # render, run migrations, run each jobHand-run equivalent, in order (migrations first; see Database migrations):
nomad job run manager/nomad/manager-migrate.nomad
nomad job run manager/nomad/manager.nomad
nomad job run proxy/nomad/proxy.nomad
nomad job run autoscaler/nomadscaler/config/autoscaler.hcl
# Verify all jobs are running
nomad job statusRolling Update
# Update Manager
nomad job run -check-index $(nomad job inspect manager | jq .JobModifyIndex) manager.nomad
# Monitor deployment
nomad deployment status <deployment-id>
# Watch deployment progress
watch -n 2 'nomad job status manager | head -20'Rollback
# View job versions
nomad job history manager
# Revert to previous version
nomad job revert manager <version>
# Stop a failed deployment
nomad deployment fail <deployment-id>Monitoring and Verification
Nomad Web UI
Access the Nomad UI at http://localhost:4646:
- Jobs: View all running jobs, their status, and allocations
- Allocations: See details of each running container instance
- Nodes: Infrastructure information and resource usage
- Topology: Visual overview of the cluster state
- Evaluations: View scheduling decisions
Check Job Health
# Job overview
nomad job status manager
# Specific allocation details
nomad alloc status <alloc-id>
# Service health checks
nomad service info manager-api
# Recent job history
nomad job history manager
# View deployment status
nomad deployment status <deployment-id>Prometheus Metrics
Access Prometheus (on its assigned port) and query metrics:
# Manager API availability
curl 'http://localhost:9090/api/v1/query?query=up{job="manager"}'
# CPU usage across cluster
curl 'http://localhost:9090/api/v1/query?query=nomad_client_host_cpu_user'
# Memory usage per allocation
curl 'http://localhost:9090/api/v1/query?query=nomad_client_alloc_memory_usage'
# HTTP request rate (if exposed by app)
curl 'http://localhost:9090/api/v1/query?query=rate(http_requests_total[5m])'
# Active connections
curl 'http://localhost:9090/api/v1/query?query=nomad_client_alloc_network_rx_bytes'Test Autoscaler
Verify autoscaler functionality by generating load:
# Install Apache Bench (if not already installed)
# Ubuntu/Debian: apt-get install apache2-utils
# macOS: brew install ab
# Generate load
ab -n 10000 -c 100 http://localhost:8080/
# Watch scaling events in real-time
nomad job history manager
# Monitor allocation count changes
watch -n 2 'nomad job status manager | grep "Allocations"'
# View autoscaler decision logs
nomad alloc logs -f <autoscaler-alloc-id> autoscaler
# Check optimizer service health
curl http://localhost:8015/healthService Discovery Verification
# List all registered services
nomad service list
# Get specific service information
nomad service info manager-apiHigh Availability
Executor Instance
Only one Executor instance should run. The Executor uses database-level locking to ensure single-instance execution across the cluster.
Increase the count for API instances to scale horizontally:
group "api" {
count = 5 # Scale as needed
# ...
}Troubleshooting
Job Won't Start
# Validate job file syntax
nomad job validate <job-file>.hcl
# Get detailed error message
nomad job status <job-name>
# Check allocation-level errors
nomad alloc status <alloc-id>
# View allocation logs
nomad alloc logs <alloc-id> <task-name>
# Check Docker driver status
docker ps
docker imagesNetwork Connectivity Issues
# Check service discovery
nomad service list
nomad service info <service-name>
# Verify port mappings
nomad alloc status <alloc-id> | grep -A 10 "Ports"
# Enter container for debugging
nomad alloc exec -task <task-name> <alloc-id> /bin/sh
# Test connectivity from inside container
nomad alloc exec <alloc-id> wget -O- http://localhost:8081/healthz
nomad alloc exec <alloc-id> ping postgresDatabase Connection Problems
# Test database connectivity
nomad alloc exec <postgres-alloc-id> psql -U postgres -d productify -c "SELECT version();"
# Check database logs
nomad alloc logs <postgres-alloc-id>Autoscaler Not Scaling
# Check scaling policies
nomad scaling policy list
# View policy details
nomad scaling policy info <policy-id>
# Test manual scaling
nomad job scale manager 5
# Verify Prometheus targets
curl http://localhost:9090/api/v1/targets
# Check optimizer logs
nomad alloc logs -f <optimizer-alloc-id> optimizer
# Verify autoscaler plugin
nomad alloc logs -f <autoscaler-alloc-id> autoscalerHigh Resource Usage
# Check cluster resources
nomad node status
# View resource allocation
nomad status
# Check specific node usage
nomad node status <node-id>
# See resource constraints
nomad job inspect manager | jq '.Job.TaskGroups[].Tasks[].Resources'Check Job Status
nomad job status manager
nomad alloc status <alloc-id>
nomad alloc logs <alloc-id>Networking Issues
# Check allocations
nomad alloc status -verbose <alloc-id>
# Exec into container
nomad alloc exec <alloc-id> shAdvanced Configuration
Auto-Revert on Failure
update {
max_parallel = 1
health_check = "checks"
min_healthy_time = "10s"
healthy_deadline = "5m"
auto_revert = true
auto_promote = false
}Canary Deployments
update {
max_parallel = 1
canary = 1
min_healthy_time = "30s"
healthy_deadline = "10m"
auto_promote = false
auto_revert = true
}Resource Limits
resources {
cpu = 1000 # MHz
memory = 1024 # MB
memory_max = 2048 # Max memory before OOM
}Spread Across Nodes
spread {
attribute = "${node.unique.id}"
weight = 100
}See Also
- Generated Artifacts:
install.yamland the render pipeline - Manager Deployment
- Autoscaler Deployment
- Proxy Deployment
- Architecture