boring-registry on Ubuntu 24.04 on Azure User Guide
Overview
This guide covers the deployment and configuration of boring-registry on Ubuntu 24.04 on Azure using cloudimg Azure Marketplace images. boring-registry is a simple, open source registry for Terraform and OpenTofu modules and providers, a private and self hosted alternative to the public Terraform Registry. Point your developers and CI pipelines at a boring-registry server and they can consume your organisation's own modules and providers with the standard source syntax, while you keep full control over what is published and who can reach it. boring-registry implements the module registry protocol, the provider registry protocol and the provider network mirror protocol.
The image installs boring-registry 0.18.0 from the official upstream release (a single self contained Go binary). boring-registry has no web interface: it is a headless API server driven by the terraform/tofu command and the boring-registry command line. In this image the registry server is bound to the loopback interface only (127.0.0.1:5601) and nginx is the sole network facing surface on port 80. nginx serves a public health check and the registry service discovery document, and reverse proxies the /v1 module and provider protocol to the registry, which enforces a per instance API token.
Self contained by design. boring-registry stores modules and providers in object storage; it has no local filesystem backend. This appliance bundles a lightweight S3 compatible object store (SeaweedFS) on the same virtual machine, bound to the loopback interface, so the registry works out of the box with no external cloud account or bucket to configure. Module and provider archives are streamed back through the registry itself (its --download-proxy mode), so the object store never has to be reachable by your clients.
Secure by default, no shared credentials. Nothing secret is baked into the image. On the first boot of every instance, boring-registry generates a unique API token that protects the registry protocol and unique credentials for the on box object store, and writes them to a root only file. Unauthenticated requests to the /v1 protocol are rejected, and publishing new modules and providers requires the per instance storage credentials.
What is included:
-
boring-registry 0.18.0 from the official upstream release binary, run under systemd (
boring-registry.service) as an unprivilegedboringuser with the registry bound to127.0.0.1:5601 -
A bundled S3 compatible object store (SeaweedFS 4.39,
seaweedfs.service) bound to loopback, providing the storage backend with no external cloud dependency -
nginx (
nginx.service) on port 80 as the only network facing service, serving a public/healthzprobe and the public/.well-known/terraform.jsonservice discovery document, and reverse proxying the/v1registry protocol to the loopback registry -
A per instance registry API token and object store credentials, generated fresh on first boot, with the per instance values written to
/root/boring-registry-credentials.txt -
A tiny example module (
cloudimg/hello/local) published on first boot so you can confirm the registry end to end immediately
Prerequisites
-
Active Azure subscription, SSH public key, VNet + subnet in target region
-
Subscription to the boring-registry listing on Azure Marketplace
-
Network Security Group rules allowing TCP 22 (admin) and TCP 80 (the registry protocol) from the developers and build agents that use the registry
Recommended virtual machine size: Standard_B2s (2 vCPU, 4 GB RAM) is ample for a team registry. Increase the size and enlarge the OS disk when you publish a large module and provider catalogue.
Step 1: Deploy from the Azure Portal
Search boring-registry in Marketplace, select the cloudimg publisher, and click Create. Configure the Network Security Group to allow TCP 80 from the developer workstations and build agents that will use the registry, and TCP 22 for administration. Keep 80 restricted to the networks you serve; a private registry rarely needs to be reachable from the whole internet.
Step 2: Deploy from the Azure CLI
RG="boring-registry-prod"; LOCATION="eastus"; VM_NAME="boring-registry1"
GALLERY_IMAGE_ID="/subscriptions/<sub-id>/resourceGroups/azure-cloudimg/providers/Microsoft.Compute/galleries/cloudimgGallery/images/boring-registry-ubuntu-24-04/versions/<version>"
SSH_KEY="$(cat ~/.ssh/id_rsa.pub)"
az group create --name "$RG" --location "$LOCATION"
az vm create \
--resource-group "$RG" --name "$VM_NAME" \
--image "$GALLERY_IMAGE_ID" \
--size Standard_B2s \
--admin-username azureuser \
--ssh-key-values "$SSH_KEY" \
--public-ip-sku Standard
az vm open-port --resource-group "$RG" --name "$VM_NAME" --port 80 --priority 1001
az vm open-port --resource-group "$RG" --name "$VM_NAME" --port 22 --priority 1002
Step 3: First boot and per instance credentials
On first boot the image generates a per instance registry API token and per instance object store credentials, creates the storage bucket, publishes the example module, and writes everything to /root/boring-registry-credentials.txt. This completes within seconds. SSH in as azureuser and read the credentials file:
sudo cat /root/boring-registry-credentials.txt
The file is 0600 root:root and records the registry URL and API token, the on box object store endpoint, bucket and credentials, and ready to paste examples. These values are unique to this instance.
Step 4: Confirm the services are running
seaweedfs.service, boring-registry.service and nginx.service are all active, boring-registry version reports 0.18.0, and ss confirms that nginx owns port 80 while the registry and the object store are bound to loopback only on ports 5601 and 8334.
systemctl is-active seaweedfs.service boring-registry.service nginx.service
/opt/boring-registry/boring-registry version
sudo ss -tlnp | grep -E ':80 |:5601 |:8334 '

Step 5: The registry is secure by default
The registry is reachable only through nginx on port 80. The health probe and the service discovery document are public, but every /v1 module and provider request requires the per instance API token: an unauthenticated request returns 401, and a request with the token returns 200. The block below reads the token from the instance credentials file, so it works unchanged on any instance.
TOKEN=$(sudo grep -E '^REGISTRY_TOKEN=' /root/boring-registry-credentials.txt | cut -d= -f2-)
curl -s -o /dev/null -w 'healthz (no auth) -> HTTP %{http_code}\n' http://127.0.0.1/healthz
curl -s -o /dev/null -w 'well-known/terraform.json (no auth) -> HTTP %{http_code}\n' http://127.0.0.1/.well-known/terraform.json
curl -s -o /dev/null -w 'v1/modules/cloudimg/hello/local/versions (no auth) -> HTTP %{http_code}\n' http://127.0.0.1/v1/modules/cloudimg/hello/local/versions
curl -s -o /dev/null -w 'v1/modules/cloudimg/hello/local/versions (token) -> HTTP %{http_code}\n' -H "Authorization: Bearer $TOKEN" http://127.0.0.1/v1/modules/cloudimg/hello/local/versions
curl -s -H "Authorization: Bearer $TOKEN" http://127.0.0.1/v1/modules/cloudimg/hello/local/versions
The public health probe and discovery document return 200, the unauthenticated module request returns 401, and the authenticated request returns 200 with the versions of the bundled cloudimg/hello/local example module: {"modules":[{"versions":[{"version":"0.1.0"}]}]}.

Step 6: Publish your own module
Publishing writes directly to the bundled object store using the per instance storage credentials, so you run it on this VM (or over SSH). Create a small module with a boring-registry.hcl metadata file and upload it with the boring-registry command line. The block below reads the object store credentials from the instance credentials file.
AK=$(sudo grep -E '^S3_ACCESS_KEY=' /root/boring-registry-credentials.txt | cut -d= -f2-)
SK=$(sudo grep -E '^S3_SECRET_KEY=' /root/boring-registry-credentials.txt | cut -d= -f2-)
M=$(mktemp -d)
cat > "$M/main.tf" <<'TF'
variable "name" {
type = string
default = "world"
}
output "greeting" {
value = "Hello, ${var.name}"
}
TF
cat > "$M/boring-registry.hcl" <<'HCL'
metadata {
namespace = "cloudimg"
name = "example"
provider = "local"
version = "1.0.0"
}
HCL
sudo AWS_ACCESS_KEY_ID="$AK" AWS_SECRET_ACCESS_KEY="$SK" AWS_REGION=us-east-1 \
/opt/boring-registry/boring-registry upload module \
--storage-s3-bucket=boring-registry --storage-s3-endpoint=http://127.0.0.1:8334 \
--storage-s3-pathstyle=true --storage-s3-region=us-east-1 --ignore-existing=true "$M"
TOKEN=$(sudo grep -E '^REGISTRY_TOKEN=' /root/boring-registry-credentials.txt | cut -d= -f2-)
curl -s -H "Authorization: Bearer $TOKEN" http://127.0.0.1/v1/modules/cloudimg/example/local/versions
The upload publishes cloudimg/example/local version 1.0.0, and the versions endpoint immediately serves it. You publish providers the same way with boring-registry upload provider; see the boring-registry documentation for the provider signing key requirements.

Step 7: Consume a module over the download proxy
A registry client fetches a module in two steps: it asks the registry for the download location, then downloads the archive. Because this image runs boring-registry in --download-proxy mode, the archive is streamed back through the registry itself, so the object store never has to be reachable by the client. The block below performs both steps with curl and the per instance token.
TOKEN=$(sudo grep -E '^REGISTRY_TOKEN=' /root/boring-registry-credentials.txt | cut -d= -f2-)
GET=$(curl -s -D - -o /dev/null -H "Authorization: Bearer $TOKEN" \
http://127.0.0.1/v1/modules/cloudimg/hello/local/0.1.0/download \
| grep -i '^X-Terraform-Get:' | tr -d '\r' | awk '{print $2}')
echo "download location: $GET"
curl -s -o /tmp/hello.tar.gz -w 'archive fetch -> HTTP %{http_code}, %{size_download} bytes\n' \
-H "Authorization: Bearer $TOKEN" "http://127.0.0.1$GET"
The download endpoint returns an X-Terraform-Get location that points back at the registry's own /v1/proxy/... path, and fetching it returns the module archive with HTTP 200. This is exactly the exchange terraform init and tofu init perform under the hood.

Step 8: Point Terraform and OpenTofu at the registry
To consume modules with the native source syntax, give your client the per instance token and reference the module by its registry address. Replace <vm-host> with this instance's address or a DNS name you point at it.
# Give the terraform/tofu CLI the per instance token as a host credential.
# Dots in the host become underscores, e.g. host 10.0.0.4 -> TF_TOKEN_10_0_0_4:
# export TF_TOKEN_10_0_0_4="<the REGISTRY_TOKEN from /root/boring-registry-credentials.txt>"
#
# Then reference a module in your Terraform/OpenTofu configuration:
# module "hello" {
# source = "10.0.0.4/cloudimg/hello/local"
# }
Production note. Terraform requires a registry host to be served over HTTPS. This image serves the registry over plain HTTP on port 80 so you can front it with your own TLS termination and DNS name (an Azure Application Gateway or Load Balancer, or nginx with your own certificate), which is the standard way to run a private registry with a real domain. Until then, use curl with the token as shown above, or OpenTofu with a host that terminates TLS. The per instance token continues to protect the /v1 protocol behind your TLS front.
Step 9: Provider network mirror
boring-registry also implements the provider network mirror protocol, so you can mirror upstream providers for reproducible, network isolated builds. Configure a provider installation network_mirror block in your CLI configuration pointing at this registry, upload the providers you need with boring-registry upload provider, and your terraform init / tofu init pulls providers from your own mirror instead of the public internet.
Step 10: Security recommendations
-
Restrict the NSG. Allow TCP 80 only from the developer subnets and build agents that use the registry, and TCP 22 only from your administration network. A private registry rarely needs to be public.
-
Protect the token and storage keys. They live in
/root/boring-registry-credentials.txt(0600 root:root). The registry protocol is reachable only with the token; publishing requires the object store keys. Rotate the token by editingBORING_REGISTRY_AUTH_STATIC_TOKENin/etc/boring-registry/boring-registry.envand restartingboring-registry.service. -
Terminate TLS in front for production. Put a DNS name and a CA signed certificate in front of the registry with an Azure Application Gateway, Load Balancer, or an nginx TLS front, so
terraform initandtofu initcan use the registry with the nativesourcesyntax. -
Back up the object store. Your published modules and providers live under
/var/lib/seaweedfs. Snapshot the disk or export the bucket as part of your backup routine. -
Keep the OS patched. Unattended security upgrades remain enabled on the running VM.
Step 11: Support and Licensing
boring-registry is open source software distributed under the MIT License. This cloudimg image bundles the unmodified official boring-registry release binary and the SeaweedFS object store (Apache License 2.0). cloudimg provides the packaging, the systemd hardening, the nginx front, the loopback only registry with a per instance API token, the self contained on box object store, the per instance credential automation, the paired deploy guide, and 24/7 support with a guaranteed 24 hour response SLA.
cloudimg is not affiliated with or endorsed by the boring-registry project. boring-registry is a mark of its respective owner and is used here only to identify the software.
Deploy on Azure
Find boring-registry on Ubuntu 24.04 LTS on the Azure Marketplace, published by cloudimg. Deploy from the Portal or the Azure CLI as shown above.
Need Help?
Email support@cloudimg.co.uk for deployment help, configuration questions, or licensing enquiries.