Walkthrough · Azure

Boundary in Azure: the private worker, step by step

The gentlest of the three builds. We put a virtual machine in a private network with no public address, and reach it over SSH from a laptop that holds no key — without exposing a single thing of our own to the internet.

· · Beginner friendly · 2–3 hours · Start with the guide →

He who loves Mum, Ei, and Arsenal — and is still chasing the moon🌙

Proudly a HelloCloud ACE candidate.

A note, humbly: I am not an expert, and none of this is a brag. It is all about learning how to learn — and how to unlearn — and cherishing the process somewhere along the way. If you got this far, I hope you learned something too.

Read it, then build it 🛠️

This page is written to be followed along with, not just read. Open the Excalidraw board beside it and work through the steps yourself — every screen I filled in is captured there, in the order I filled it in.

Reading gives you the shape of the thing. Only doing it gives you the understanding. I did not learn any of this by reading, and I do not think anyone does.

Open the Azure board ↗

New to Boundary? Read What is HashiCorp Boundary? first. It explains the vocabulary used here — controller, worker, target, scope — in plain words. This page assumes you have read it.

What we are building

Three virtual machines and two networks. In plain terms:

Because our worker has no public address, our laptop cannot connect to it directly. So the session travels laptop → HashiCorp's managed worker → our worker → target. The setting that tells Boundary which of our workers should make that final hop is called an egress worker filter, and that is where the name of this design comes from.

Is it bad that HashiCorp's machines carry my traffic? For most people, no. The traffic is encrypted end to end and they cannot read it. But if you work somewhere that says session traffic must never transit infrastructure you do not operate, this design will not pass, and the Google Cloud walkthrough shows the alternative.

Before you start

You need:

Money. The NAT gateway is the expensive part here. It bills per hour whether or not anything uses it. Three small VMs and a NAT gateway left running for a month will cost real money. Set a budget alert now, and do the clean-up step when you finish.

Step 1 · Two networks, joined

Everything in Azure lives in a resource group, which is just a folder. Create one first — call it something like boundary-lab — and put everything else inside it. Deleting the folder later deletes everything in it, which makes clean-up a single action.

The boundary network

Create a virtual network (Azure's word for a private network; AWS calls it a VPC). Give it two subnets:

Then create a NAT gateway and attach it to the private subnet. This is what lets the worker reach the internet outbound — to download the binary and to register with HCP — without anything on the internet being able to reach the worker.

The profile network

A second virtual network, with one private subnet, for the target VM. No NAT gateway here at all. This machine has no way to reach the internet and no way to be reached from it, which is exactly what we want.

Join them with peering

Peering connects two networks so machines in one can talk to machines in the other. Open the first network, go to Peerings, and add one pointing at the second.

A pleasant Azure surprise. When you create the peering, Azure adds the routes for you. In AWS you would now be editing route tables by hand on both sides. Here there is nothing more to do — and you do not need a custom route table for simple direct peering at all.

Step 2 · Firewall rules

In Azure the firewall is a network security group, usually shortened to NSG. You attach it to a subnet or to a machine's network card.

The trick to getting these right is to write them in the direction traffic actually flows. Here are the three sets, in order:

jump host NSG
  inbound   SSH (22) from your own public IP address
  outbound  SSH (22) to the worker

worker NSG
  inbound   SSH (22) from the jump host        (for setup only)
  outbound  TCP 9202 to HCP's managed workers  (to register)
  outbound  SSH (22) to the target VM          (the actual job)
  deny      everything else, both directions

target NSG
  inbound   SSH (22) from the worker, and nothing else
  outbound  nothing at all

Read the worker's rules again. There is no inbound rule from the internet. None. The worker reaches HCP by dialling outward and holding that connection open. HCP never connects to it. This is the single most important thing to understand about how Boundary workers behave, and it is why this design exposes nothing.

Notice also that the target's outbound rule is "nothing". It does not need to reach anything, so we say so. Free security, one dropdown.

Step 3 · Three machines

Create three virtual machines, all Ubuntu, all using the SSH key you generated:

MachineSubnetPublic IP?Purpose
jump hostboundary / publicYesCopy the binary across. Deleted afterwards.
workerboundary / privateNoRuns the Boundary worker service.
profile VMprofile / privateNoThe server we want to reach.

After creating them, attach the right NSG to each. In Azure this is done from the machine's network interface, under Settings → Network security group — not from the VM page itself, which is where most people look first.

Step 4 · Install the worker

SSH into the jump host, then from there into the worker. Copy the Boundary binary across with scp, or briefly allow the worker outbound internet access and download it directly:

sudo apt-get update && sudo apt-get install -y jq unzip

wget -q "$(curl -fsSL \
  "https://api.releases.hashicorp.com/v1/releases/boundary/latest?license_class=enterprise" \
  | jq -r '.builds[] | select(.arch=="amd64" and .os=="linux") | .url')"

unzip *.zip

Now write the worker's configuration file:

# /home/azureuser/boundary/pki-worker.hcl

hcp_boundary_cluster_id = "<your-cluster-id>"

listener "tcp" {
  address = "0.0.0.0:9202"
  purpose = "proxy"
}

worker {
  public_addr       = "192.168.9.4:9202"     # the worker's PRIVATE address
  auth_storage_path = "/home/azureuser/boundary/worker1"
  tags {
    owner     = ["kst"]
    cloud-env = ["azure"]
    region    = ["uae-north"]
  }
}

The most important line on this page. public_addr is set to a private address. That is not a mistake. It tells the controller where this worker can be reached — and since your laptop cannot reach a private Azure address, the controller knows the session must be relayed through HCP's managed workers instead. Put a real public IP here and you get a completely different design, which is the Google Cloud walkthrough.

Give the tags some thought while you are here. They are labels, and later you will write a filter that selects a worker by them. Adding a tag now is free; adding one after targets already reference the worker is fiddly. Tag on things you might one day want to select by: cloud, region, owner, environment.

Then run it as a service rather than in your shell, so it survives you logging out:

sudo mv boundary /usr/local/bin/
sudo useradd --system --home /etc/boundary.d --shell /bin/false boundary
sudo chown -R boundary:boundary /home/azureuser/boundary

# /etc/systemd/system/boundary-worker.service
[Unit]
Description=Boundary PKI Worker
Requires=network-online.target
After=network-online.target

[Service]
User=boundary
Group=boundary
ExecStart=/usr/local/bin/boundary server \
  -config=/home/azureuser/boundary/pki-worker.hcl

[Install]
WantedBy=multi-user.target
sudo systemctl daemon-reload
sudo systemctl enable --now boundary-worker
sudo journalctl -u boundary-worker -f

Step 5 · Register the worker

The log will say the node is not yet authorized. This is correct. It is not an error, and nothing is broken.

Registration is a handshake with two halves. The worker writes a token into its storage directory, and you paste that token into the controller to approve it:

cd /home/azureuser/boundary/worker1
cat auth_request_token

Copy that value, open the Boundary admin console, go to Workers → New, and paste it. Watch the worker's log as you do. You will see a line like this:

{"op":"reverseconn_ent.(*dialingListener).SetAddresses",
 "data":{"address":"<id>.proxy.boundary.hashicorp.cloud:9202",
         "msg":"dialing"}}

Read that carefully, because it is the whole architecture in one line. The worker is dialing. It is opening a connection outward to HashiCorp and holding it open, so that work can be pushed down it later. That is why the worker needs no inbound firewall rule.

Step 6 · Credentials and a target

Two things to create in the admin console, in this order.

A credential store

This is where the SSH key for reaching the target VM lives. Create a static credential store, then add a username and key pair credential containing the private key that matches the target VM.

An honest limitation. A static credential like this does not rotate. It is a key sitting in Boundary, and if it leaks you are back to the problem we were trying to escape. For anything real you would use HashiCorp Vault as the credential store, so that a fresh short-lived credential is generated per session. That is a bigger topic; I am flagging it so you do not mistake this for the finished pattern.

A target

Create an SSH target. Give it the target VM's private address and port 22. Then attach the credential you just made as an injected application credential.

"Injected" means the worker uses the credential on your behalf and you never see it. The alternative, "brokered", hands the credential to you to use yourself. Injected is the one you want. It is the difference between "the system logs me in" and "the system gives me a key".

Step 7 · The egress filter

Now we tell Boundary which worker should make the final hop to the target. On the target, set an egress worker filter:

"azure" in "/tags/cloud-env"

That reads as: use any worker whose cloud-env tag contains azure. Which is the worker we built, because we tagged it that way in step 4.

The full path is now decided: laptop → HCP managed worker → our worker → target VM.

Step 8 · Connect, and prove it

Log in and connect:

boundary authenticate password \
  -auth-method-id=<your-auth-method-id> \
  -login-name=<your-user>

boundary connect ssh -target-id=<your-target-id>

If you land on a shell prompt on the target VM, it worked. But do not stop there. The interesting part is proving how it worked. Run this on the target VM itself:

ss output on the Azure target showing sshd listening and an established connection from the worker
sudo ss -tanp | grep ':22' on the target. There is an established SSH connection, and it comes from the worker's private address. Nothing else. The target has no idea Boundary exists — it just sees a neighbour connecting over SSH.

And on the worker, look at where its connections go:

sudo ss -tnp state established '( dport = :9202 )'

Local Address:Port        Peer Address:Port     Process
10.0.201.4:42994          54.172.227.234:9202   boundary
10.0.201.4:49314          54.172.227.234:9202   boundary
10.0.201.4:50574          3.233.88.122:9202     boundary

Those peer addresses are HashiCorp's managed workers. Our worker dialled them. Nobody dialled us. Read the two captures together and the design is no longer a claim on a diagram. It is a fact you can see.

You can also confirm the login on the target the ordinary way, which is a nice reminder that everything downstream of Boundary is completely normal:

sudo tail -f /var/log/auth.log

Azure gotchas

The NSG rules you did not write

Every NSG comes with default rules that you cannot delete. They sit at low priority underneath yours. If something is allowed that you thought you had denied, this is usually why. You were reasoning only about the rules you added. Read the defaults before layering on top of them.

There is no internet gateway to create

If you come from AWS you will go looking for an internet gateway resource and not find one. Azure networks have outbound internet built in. You only create a NAT gateway when you want outbound access from machines that have no public IP — which is exactly our worker's case.

The NSG attaches to the network card

Not to the virtual machine. Look under the VM's network interface settings. This catches everyone once.

Azure writes your peering routes for you

Worth stating as a positive, because it is a genuine difference. Create the peering and the system routes appear. There is no route table step, and going looking for one wastes time.

Clean up

Delete the resource group. Everything you made in this walkthrough was inside it, so one delete removes the VMs, the networks, the NAT gateway and the peering together.

Then remove the worker from the Boundary admin console, and delete the target and credential store. Delete the jump host as soon as the worker is installed. It exists to copy one file across. A public SSH machine with no job left is exactly the kind of thing that quietly becomes permanent.

Where to go next. If you want your own machines out of HashiCorp's traffic path, do the Google Cloud ingress walkthrough. If your target network has no route to the internet at all, the AWS multi-hop walkthrough handles that. And when you want to replace the password login with your company account, that is Boundary and Entra ID.