HashiCorp Boundary gives users access to private hosts by identity instead of by network location. A user authenticates to the Boundary controller, which authorizes a session to a specific target, and a Boundary worker proxies the connection, so users never need a VPN or direct network access to the hosts. In this tutorial you will install Boundary on an Ubuntu 24.04 server with PostgreSQL, run a controller and a worker in one process with a Let's Encrypt certificate, define a private server as an SSH target, and connect to it from your workstation.

Prerequisites

To follow this guide you need:

  • A server running Ubuntu 24.04 LTS for Boundary, for example a CubePath VPS with at least 2 GB of RAM.
  • A non-root user with sudo privileges.
  • A domain name with a DNS A record, such as boundary.your_domain, pointing to the server's public IP.
  • A host to reach through Boundary, such as a server on a private network that the Boundary server can reach on port 22.
  • Your workstation with the boundary CLI (installation is covered in Step 6).

Ports used: TCP 80 briefly for the Let's Encrypt challenge, TCP 9200 for the API and web UI, TCP 9202 for session traffic through the worker. Port 9201 (controller to worker communication) stays on localhost in this single-server setup.

Step 1 - Installing Boundary and PostgreSQL

Add the HashiCorp repository with its signing key in /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.gpg
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/hashicorp.gpg] https://apt.releases.hashicorp.com $(lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/hashicorp.list

Install Boundary, PostgreSQL (Boundary's only storage backend), certbot and jq, which you will use to read IDs from JSON output:

sudo apt update
sudo apt install boundary postgresql certbot jq
boundary version
Version information:
  Git Revision:        ...
  Version Number:      0.x.x

Make sure a boundary system user exists to run the service. The package normally creates it; the command below only adds it if it is missing:

id boundary || sudo useradd --system --home /etc/boundary.d --shell /usr/sbin/nologin boundary

Step 2 - Creating the database

Create a database role and a database owned by it. Being the owner matters on PostgreSQL 15 and later, where ordinary users can no longer create objects in the public schema. Replace your_strong_password with a long random password:

sudo -u postgres psql -c "CREATE ROLE boundary WITH LOGIN PASSWORD 'your_strong_password';"
sudo -u postgres createdb -O boundary boundary

Verify that the role can connect:

psql "postgresql://boundary:[email protected]:5432/boundary" -c "SELECT 1;"
 ?column?
----------
        1
(1 row)

Step 3 - Getting a TLS certificate

Boundary serves its API and web UI over TLS. Open ports 80, 9200 and 9202, then request a certificate with certbot's standalone mode:

sudo ufw allow OpenSSH
sudo ufw allow 80/tcp
sudo ufw allow 9200/tcp
sudo ufw allow 9202/tcp
sudo certbot certonly --standalone -d boundary.your_domain

The certificate files under /etc/letsencrypt/live/ are readable only by root, so copy them to a directory the boundary user can read. Do it from a certbot deploy hook, which also runs after every renewal. Create the hook:

sudo nano /etc/letsencrypt/renewal-hooks/deploy/boundary.sh
#!/usr/bin/env bash
set -euo pipefail

domain="boundary.your_domain"
src="/etc/letsencrypt/live/${domain}"
dst="/etc/boundary.d/tls"

install -d -m 0750 -o root -g boundary "${dst}"
install -m 0640 -o root -g boundary "${src}/fullchain.pem" "${dst}/cert.pem"
install -m 0640 -o root -g boundary "${src}/privkey.pem" "${dst}/key.pem"
systemctl try-restart boundary-server.service

Make it executable and run it once to copy the current certificate:

sudo chmod 755 /etc/letsencrypt/renewal-hooks/deploy/boundary.sh
sudo /etc/letsencrypt/renewal-hooks/deploy/boundary.sh
sudo ls -l /etc/boundary.d/tls
-rw-r----- 1 root boundary 2868 Sep 25 10:00 cert.pem
-rw-r----- 1 root boundary  241 Sep 25 10:00 key.pem

Step 4 - Configuring the controller and worker

Boundary encrypts sensitive data and authenticates workers with keys from a KMS. In production you would use Vault Transit or a cloud KMS; here you use aead keys stored in the configuration file, which is acceptable for a single server as long as the file is protected. Generate three random keys and note them:

for purpose in root worker-auth recovery; do echo "${purpose}: $(openssl rand -base64 32)"; done

Create the configuration file:

sudo nano /etc/boundary.d/boundary-server.hcl

Paste the following, replacing the domain, the database password and the three keys:

disable_mlock = true

controller {
  name        = "controller-1"
  description = "Boundary controller"
  database {
    url = "postgresql://boundary:[email protected]:5432/boundary"
  }
}

worker {
  name              = "worker-1"
  description       = "Worker on the controller host"
  public_addr       = "boundary.your_domain:9202"
  initial_upstreams = ["127.0.0.1:9201"]
}

listener "tcp" {
  address       = "0.0.0.0:9200"
  purpose       = "api"
  tls_cert_file = "/etc/boundary.d/tls/cert.pem"
  tls_key_file  = "/etc/boundary.d/tls/key.pem"
}

listener "tcp" {
  address = "127.0.0.1:9201"
  purpose = "cluster"
}

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

kms "aead" {
  purpose   = "root"
  aead_type = "aes-gcm"
  key       = "your_root_key"
  key_id    = "global_root"
}

kms "aead" {
  purpose   = "worker-auth"
  aead_type = "aes-gcm"
  key       = "your_worker_auth_key"
  key_id    = "global_worker-auth"
}

kms "aead" {
  purpose   = "recovery"
  aead_type = "aes-gcm"
  key       = "your_recovery_key"
  key_id    = "global_recovery"
}

The file contains the database password and the KMS keys, so restrict it:

sudo chown root:boundary /etc/boundary.d/boundary-server.hcl
sudo chmod 0640 /etc/boundary.d/boundary-server.hcl

Initialize the database. This runs the schema migrations and creates an initial password auth method and an admin user:

sudo boundary database init -config /etc/boundary.d/boundary-server.hcl
Initial auth information:
  Auth Method ID:     ampw_1234567890
  Auth Method Name:   Generated global scope initial password auth method
  Login Name:         admin
  Password:           xyzAbc123...
  Scope ID:           global
  User ID:            u_1234567890

The command also creates a generated organization, project and example target. You can delete them later from the web UI.

Step 5 - Running Boundary as a systemd service

Create a unit for the server:

sudo nano /etc/systemd/system/boundary-server.service
[Unit]
Description=HashiCorp Boundary controller and worker
Wants=network-online.target
After=network-online.target postgresql.service

[Service]
User=boundary
Group=boundary
ExecStart=/usr/bin/boundary server -config=/etc/boundary.d/boundary-server.hcl
Restart=on-failure
RestartSec=5
LimitNOFILE=65536

[Install]
WantedBy=multi-user.target

Load it and start Boundary:

sudo systemctl daemon-reload
sudo systemctl enable --now boundary-server
sudo systemctl status boundary-server

Check the log for the listeners and the worker connecting to the controller:

sudo journalctl -u boundary-server -n 30

Open https://boundary.your_domain:9200 in a browser. You should see the Boundary login page with a valid certificate, and you can log in with the admin credentials from Step 4.

Step 6 - Authenticating from your workstation

Install the boundary CLI on your workstation: on Ubuntu or Debian with the same repository as in Step 1, or on macOS with brew install hashicorp/tap/boundary. Point the CLI at your controller and log in:

export BOUNDARY_ADDR="https://boundary.your_domain:9200"
boundary authenticate password -auth-method-id ampw_1234567890 -login-name admin

Enter the password when prompted. The CLI stores the token in your system keyring. Confirm the worker is registered:

boundary workers list

The output should list worker-1 with its address boundary.your_domain:9202.

Step 7 - Defining an SSH target

Boundary organizes resources in scopes: organizations contain projects, and projects contain hosts and targets. Create an organization and a project, saving their IDs in variables:

ORG_ID=$(boundary scopes create -scope-id global -name "production" -format json | jq -r '.item.id')
PROJECT_ID=$(boundary scopes create -scope-id "$ORG_ID" -name "servers" -format json | jq -r '.item.id')

A static host catalog holds hosts you add by address. Create the catalog, a host for your private server (replace 10.0.1.10 with its address as seen from the Boundary server), and a host set that groups them:

CATALOG_ID=$(boundary host-catalogs create static -scope-id "$PROJECT_ID" -name "linux-servers" -format json | jq -r '.item.id')
HOST_ID=$(boundary hosts create static -host-catalog-id "$CATALOG_ID" -name "web-01" -address "10.0.1.10" -format json | jq -r '.item.id')
HOST_SET_ID=$(boundary host-sets create static -host-catalog-id "$CATALOG_ID" -name "web-servers" -format json | jq -r '.item.id')
boundary host-sets add-hosts -id "$HOST_SET_ID" -host "$HOST_ID"

Create a TCP target on port 22 and attach the host set to it:

TARGET_ID=$(boundary targets create tcp -scope-id "$PROJECT_ID" -name "web-ssh" -default-port 22 -format json | jq -r '.item.id')
boundary targets add-host-sources -id "$TARGET_ID" -host-source "$HOST_SET_ID"
echo "$TARGET_ID"
ttcp_AbCdEf1234

Step 8 - Connecting through Boundary

Open an SSH session to the target. Boundary authorizes the session, the worker opens the connection to the host, and the CLI starts your local ssh client through a local proxy port:

boundary connect ssh -target-id "$TARGET_ID" -username ubuntu

You authenticate to the host with your normal SSH key; Boundary controls who can open the path to it. While the session is open, list it from another terminal:

boundary sessions list -scope-id "$PROJECT_ID"
Session information:
  ID:                 s_Zyx9876543
    Status:           active
    Target ID:        ttcp_AbCdEf1234
    User ID:          u_1234567890

An administrator can end any session immediately:

boundary sessions cancel -id s_Zyx9876543

To give other people access, create users in the password auth method and grant them a role in the project with the authorize-session action on the target, instead of sharing the admin account.

Troubleshooting

The service fails with a database error. Check the connection string in /etc/boundary.d/boundary-server.hcl with the psql command from Step 2. If the log says the database is not initialized, run boundary database init from Step 4.

boundary workers list shows no worker. The worker-auth KMS key or the initial_upstreams address is wrong. Look for worker errors with sudo journalctl -u boundary-server | grep -i worker.

boundary connect times out. Your workstation cannot reach port 9202, or the worker cannot reach the host. Test the first with nc -zv boundary.your_domain 9202 from the workstation and the second with nc -zv 10.0.1.10 22 on the Boundary server.

boundary authenticate fails to store the token on a headless machine. Add -keyring-type=none, which prints the token, and export it as BOUNDARY_TOKEN for later commands.

Conclusion

You installed Boundary on Ubuntu 24.04 with PostgreSQL and TLS, ran a controller and worker as a systemd service, defined a private server as a target and opened an audited SSH session without a VPN. Next, add an OIDC auth method so users log in with your identity provider, create least-privilege roles per project, and deploy additional workers inside each private network you need to reach.