The right way to manage long-lived infrastructure is declaratively. If your environment has staging URLs, webhook receivers, and per-tenant proxies, you don't want to curl POST /api/v1/proxies from a runbook every time the team adds a new service. You want it in a main.tf next to the rest of your cloud resources, with terraform plan showing the diff and terraform apply shipping the change.
TL;DR: Nodetonet's REST API is fully scriptable. Two stock Terraform primitives — hashicorp/http for reads and null_resource with local-exec for writes — let you manage mobile proxies, rotating proxy pools, and HTTP tunnels as code today, without waiting for a custom provider.
Why manage proxies as code?
Infrastructure-as-code isn't just a DevOps trend — for proxy-heavy workloads it solves real operational problems:
- Reproducibility. Staging and production proxies are defined in the same repo. A new team member runs
terraform applyand gets an identical stack in minutes. - Drift detection. When someone adds a tunnel through the panel and forgets to document it, the next
terraform planshows the gap. - Audit trail. Every proxy creation, update and deletion is a git commit with an author, timestamp and message — far better than scrolling the panel activity log.
- CI/CD integration. A pull request that adds a microservice also provisions the proxy or tunnel that service needs, reviewed and merged in the same flow.
For context on how Nodetonet routes traffic behind the scenes, see how Nodetonet routes traffic. For the REST API basics before you dig into Terraform, read your first REST API call.
1 · The minimum providers block
You need http, the built-in null, and (optionally) random for unique subdomain suffixes. No private registry, no plugin install dance:
terraform {
required_version = ">= 1.6"
required_providers {
http = { source = "hashicorp/http", version = "~> 3.4" }
null = { source = "hashicorp/null", version = "~> 3.2" }
random = { source = "hashicorp/random", version = "~> 3.6" }
}
}
variable "nodetonet_token" {
type = string
sensitive = true
}
locals {
api_base = "https://nodetonet.com/api/v1"
headers = {
Authorization = "Bearer ${var.nodetonet_token}"
Content-Type = "application/json"
}
}
Pass the token with TF_VAR_nodetonet_token=$NTN_TOKEN terraform apply, or look it up from your existing secret backend (AWS Secrets Manager, Vault, Doppler) via a data source. You can generate an API token from your account under the API Keys section — treat it like a password: sensitive in Terraform, stored in your vault, never in version control.
2 · Creating a proxy
The "create" half is a null_resource with two provisioners: local-exec on create calls POST /api/v1/proxies, local-exec on destroy calls DELETE. The trick is capturing the returned id so destroy knows what to delete — we use triggers and a small state file:
resource "random_string" "sub_suffix" {
length = 6
upper = false
special = false
}
resource "null_resource" "tunnel_api" {
triggers = {
subdomain = "api-${random_string.sub_suffix.result}"
port = 8080
state_dir = "${path.module}/.terraform-nodetonet"
}
provisioner "local-exec" {
command = <<-EOT
mkdir -p ${self.triggers.state_dir}
RESP=$(curl -fsS -X POST ${local.api_base}/proxies \
-H "Authorization: Bearer ${var.nodetonet_token}" \
-H "Content-Type: application/json" \
-d '{
"subdomain": "${self.triggers.subdomain}",
"target_host": "127.0.0.1",
"target_port": ${self.triggers.port}
}')
echo "$RESP" > ${self.triggers.state_dir}/${self.triggers.subdomain}.json
EOT
}
provisioner "local-exec" {
when = destroy
command = <<-EOT
ID=$(jq -r .id ${self.triggers.state_dir}/${self.triggers.subdomain}.json)
curl -fsS -X DELETE \
-H "Authorization: Bearer ${var.nodetonet_token}" \
https://nodetonet.com/api/v1/proxies/$ID
rm -f ${self.triggers.state_dir}/${self.triggers.subdomain}.json
EOT
}
}
triggers hashes the inputs into Terraform state. If you change port in the next plan, Terraform detects the drift and re-runs the create provisioner — which calls our API with the new port. That's not a real "update" (it tears down and re-creates) but for ephemeral tunnels it's perfectly fine.
3 · Reading state — the http data source
For reads (anything that shouldn't mutate state), http is cleaner. List proxies, find the URL for one you created out-of-band, or feed it as input to another resource:
data "http" "proxies" {
url = "${local.api_base}/proxies"
request_headers = local.headers
}
locals {
proxies = jsondecode(data.http.proxies.response_body)
}
output "all_urls" {
value = [for p in local.proxies : p.url]
}
Pair this with the null_resource above to make outputs that include the URL of the proxy you just created:
locals {
created = jsondecode(file(
"${null_resource.tunnel_api.triggers.state_dir}/${null_resource.tunnel_api.triggers.subdomain}.json"
))
}
output "tunnel_url" {
value = local.created.url
}
output "tunnel_id" {
value = local.created.id
}
4 · Putting it together — a full main.tf
A complete file you can copy/paste, run terraform init && terraform apply, and end up with a tunnel pointed at a service you'll define elsewhere:
terraform {
required_version = ">= 1.6"
required_providers {
null = { source = "hashicorp/null", version = "~> 3.2" }
random = { source = "hashicorp/random", version = "~> 3.6" }
}
}
variable "nodetonet_token" { type = string, sensitive = true }
variable "service_port" { type = number, default = 3000 }
variable "service_name" { type = string }
resource "random_string" "suffix" {
length = 6, upper = false, special = false
}
resource "null_resource" "tunnel" {
triggers = {
name = "${var.service_name}-${random_string.suffix.result}"
port = var.service_port
}
provisioner "local-exec" {
command = "curl -fsS -X POST https://nodetonet.com/api/v1/proxies -H 'Authorization: Bearer ${var.nodetonet_token}' -H 'Content-Type: application/json' -d '{"subdomain":"${self.triggers.name}","target_port":${self.triggers.port}}' | tee ${path.module}/.tunnel-${self.triggers.name}.json"
}
provisioner "local-exec" {
when = destroy
command = "curl -fsS -X DELETE -H 'Authorization: Bearer ${var.nodetonet_token}' https://nodetonet.com/api/v1/proxies/$(jq -r .id ${path.module}/.tunnel-${self.triggers.name}.json) && rm -f ${path.module}/.tunnel-${self.triggers.name}.json"
}
}
output "url" {
value = jsondecode(file("${path.module}/.tunnel-${null_resource.tunnel.triggers.name}.json")).url
}
Run it with terraform apply -var="service_name=stripe-test". Destroy with terraform destroy — the when = destroy provisioner runs the DELETE.
5 · Managing rotating proxy pools with Terraform
Beyond single tunnels, Nodetonet's rotating proxy pools (called token groups) can also be managed through the API. A token group is a named pool of mobile devices that your clients connect to via a single endpoint — Nodetonet picks a device using round-robin or least-connection selection, with automatic failover if a device goes offline.
The same null_resource pattern applies. Create a group, store the returned group id, reference it when creating per-client credentials. The API returns the proxy endpoint, username and password you hand to your downstream consumers. This is how teams that manage proxies for multiple clients — for example via white-label reseller accounts — can version-control the entire customer topology.
For a conceptual overview of how token groups and pools work before writing Terraform, see token groups: creating pools.
6 · DIY vs official provider — comparison
To be honest: the null_resource approach works, but it isn't beautiful. Here is where it falls short of a real provider, and where it holds up fine:
| Capability | null_resource + curl (today) | Official provider (roadmap) |
|---|---|---|
| Create / delete resources | Yes, via local-exec | Yes, native resource lifecycle |
| Read existing state | Yes, via http data source | Yes, typed data sources |
| Drift detection | Partial — triggers hash changes, not panel edits | Full — refresh detects out-of-band changes |
| In-place updates (PATCH) | No — destroy + recreate | Yes, per-field granularity |
| Human-readable plan diffs | Minimal ("will be replaced") | Full field-by-field diff |
| Typed resource schema | No — raw JSON strings | Yes — validated inputs |
| Works with Terraform Cloud | Yes, with remote state workaround | Yes, natively |
| Required external tools | curl, jq on runner | None |
If you're managing 1–5 long-lived tunnels, the DIY approach is perfectly adequate. If you're managing 50+ resources across multiple tenants, wait for the real provider — or reach out to support@nodetonet.com and tell us your use case; enterprise timelines are flexible.
7 · An official Terraform provider is on the roadmap
Terraform provider · Q4 2026. When it lands the equivalent of the entire main.tf above becomes:
provider "nodetonet" {
token = var.nodetonet_token
}
resource "nodetonet_proxy" "api" {
subdomain = "stripe-test"
target_host = "127.0.0.1"
target_port = 3000
}
output "url" {
value = nodetonet_proxy.api.url
}
Same REST API underneath; the provider just wraps it with proper drift detection, in-place PATCH, and typed schema. Existing main.tf files that use the null_resource pattern will continue to work — you can migrate one resource at a time.
8 · CI/CD integration tips
A few practical details that trip teams up when running the curl approach in automation:
- State file persistence. The
.tunnel-*.jsonfiles written by the create provisioner must survive between theapplyand any futuredestroyrun. In ephemeral CI runners (GitHub Actions, GitLab CI), store the Terraform state and the JSON cache in a remote backend (S3, GCS, Terraform Cloud). A destroy in a fresh runner that can't find the JSON will not be able to call the correct DELETE. - Secret handling. Set
TF_VAR_nodetonet_tokenas a masked CI variable. Never echo it in logs — usecurl -fsS(silent, fail on error) and avoidset -xin provisioner shells. - Idempotency. If a create provisioner fails mid-run, re-running
terraform applywill call the API again. Guard by checking the cache file first, or by queryingGET /api/v1/proxiesfor an existing subdomain before POSTing. - Monitoring. After apply, use Nodetonet's tunnel health monitoring to confirm the tunnel is reachable, and wire an alert to your on-call rotation for sustained downtime.
Get started
Paste the main.tf above, set your token, and run terraform apply. Your first declarative proxy is two minutes away. Questions? Visit contact, join discord.gg/nodetonet, or browse the blog for deeper guides on specific features. If you want to script proxy creation in Python instead, see programmatic tunnel creation with Python.