Every Neo4j team eventually hits the same wall: three Aura instances (dev, staging, prod) created by three different people, in two different regions, on two different tiers, with nobody quite sure which one the nightly job writes to. Clicking instances into existence in the Aura Console does not scale past the first month of a project.
This tutorial puts Aura under version control. You will register an Aura API credential, drive the API by hand to understand what it does, then manage the same instances declaratively with the official Terraform provider — including the parts that trip people up: tier changes that resize in place, pausing non-production instances on a schedule, and getting the connection URI into your application without pasting a password into a ticket.
What you need
- An Aura Professional, Business Critical, or Virtual Dedicated Cloud account. The Aura API is not available on Aura Free, so you cannot follow the Terraform half of this on a free tier instance.
- Terraform 1.5+ (or OpenTofu — the provider works with both).
curlandjqfor the API warm-up.
Step 1 — create an API credential
In the Aura Console, open your account menu and go to API Keys (account-level, not instance-level). Create a key and copy the client ID and client secret; the secret is shown once.
The API uses OAuth2 client credentials, so every session starts with a token exchange:
export AURA_CLIENT_ID=...
export AURA_CLIENT_SECRET=...
TOKEN=$(curl -s -X POST https://api.neo4j.io/oauth/token \
-u "$AURA_CLIENT_ID:$AURA_CLIENT_SECRET" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=client_credentials" | jq -r .access_token)
Tokens are short-lived (an hour), which is the first reason you want tooling rather than a shell history full of bearer tokens.
List what you already own:
curl -s https://api.neo4j.io/v1/tenants \
-H "Authorization: Bearer $TOKEN" | jq .
TENANT=<tenant-id-from-above>
curl -s "https://api.neo4j.io/v1/instances?tenantId=$TENANT" \
-H "Authorization: Bearer $TOKEN" \
| jq -r '.data[] | [.id, .name, .cloud_provider, .region, .memory, .type] | @tsv'
That one command is usually worth the whole exercise. The inventory it prints is the answer to "what are we actually paying for?" — and in most first engagements it contains at least one instance nobody has connected to in six months.
Step 2 — the API lifecycle, by hand
Create an instance:
curl -s -X POST https://api.neo4j.io/v1/instances \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{
"tenant_id": "'"$TENANT"'",
"name": "graphguru-dev",
"version": "5",
"region": "europe-west1",
"cloud_provider": "gcp",
"memory": "2GB",
"type": "professional-db"
}' | jq .
The response contains data.username, data.password, and data.connection_url. That password is returned exactly once and is never retrievable again. Pipe it straight into your secret manager in the same command — do not let it land in a terminal scrollback.
The instance comes back with status: creating. Poll it:
curl -s "https://api.neo4j.io/v1/instances/$ID" \
-H "Authorization: Bearer $TOKEN" | jq -r .data.status
Other endpoints you will use: POST /v1/instances/{id}/pause, POST /v1/instances/{id}/resume, PATCH /v1/instances/{id} (rename, resize memory, change tier), GET/POST /v1/instances/{id}/snapshots, and DELETE /v1/instances/{id}. Pause is the cost lever: a paused instance keeps its data and bills at a much lower rate, and dev instances spend most of the week paused anyway.
Step 3 — the same thing in Terraform
The provider is neo4j/neo4j-aura in the public registry.
terraform {
required_version = ">= 1.5"
required_providers {
neo4j = {
source = "neo4j/neo4j-aura"
version = "~> 1.0"
}
}
}
provider "neo4j" {
client_id = var.aura_client_id # or env AURA_API_CLIENT_ID
client_secret = var.aura_client_secret # or env AURA_API_CLIENT_SECRET
}
variable "aura_client_id" { type = string, sensitive = true }
variable "aura_client_secret" { type = string, sensitive = true }
variable "tenant_id" { type = string }
Now describe the environments as data rather than as three hand-made instances:
locals {
environments = {
dev = {
memory = "2GB"
type = "professional-db"
}
staging = {
memory = "4GB"
type = "professional-db"
}
prod = {
memory = "16GB"
type = "enterprise-db"
}
}
}
resource "neo4j_aura_instance" "env" {
for_each = local.environments
tenant_id = var.tenant_id
name = "graphguru-${each.key}"
cloud_provider = "gcp"
region = "europe-west1"
version = "5"
memory = each.value.memory
type = each.value.type
}
output "connection_urls" {
value = { for k, v in neo4j_aura_instance.env : k => v.connection_url }
}
terraform init
terraform plan # read it; see Step 4 before you apply to anything real
terraform apply
Three facts about this resource that save real incidents:
regionandcloud_providerare immutable. Changing them is a destroy-and-recreate, and Terraform will say so in the plan with# forces replacement. Read every plan for that string before applying to production.memoryis updated in place — the provider calls the resize endpoint. The instance stays available, but plan for a short performance dip while it moves.- The initial password is in state. Terraform state for Aura contains credentials, so it belongs in an encrypted remote backend with restricted access, not in a repository and not on a laptop.
Step 4 — import the instances you already have
Nobody starts green-field. Bring existing instances under management rather than recreating them:
import {
to = neo4j_aura_instance.env["prod"]
id = "a1b2c3d4" # the Aura instance id
}
terraform plan -generate-config-out=imported.hcl
Then reconcile imported.hcl against your intended config until terraform plan reports no changes. That "no changes" state is the goal of the whole exercise: from then on, every difference between your repo and your Aura account is a deliberate, reviewed change.
Step 5 — pause dev and staging on a schedule
The pause/resume endpoints are a better cost control than downsizing, and they are trivial to automate. A GitHub Actions workflow that pauses non-production instances every evening:
name: pause-aura-nonprod
on:
schedule:
- cron: "0 19 * * 1-5" # 19:00 UTC, weekdays
workflow_dispatch:
jobs:
pause:
runs-on: ubuntu-latest
steps:
- name: Pause dev and staging
env:
AURA_CLIENT_ID: ${{ secrets.AURA_CLIENT_ID }}
AURA_CLIENT_SECRET: ${{ secrets.AURA_CLIENT_SECRET }}
TENANT: ${{ secrets.AURA_TENANT_ID }}
run: |
TOKEN=$(curl -s -X POST https://api.neo4j.io/oauth/token \
-u "$AURA_CLIENT_ID:$AURA_CLIENT_SECRET" \
-d grant_type=client_credentials | jq -r .access_token)
curl -s "https://api.neo4j.io/v1/instances?tenantId=$TENANT" \
-H "Authorization: Bearer $TOKEN" \
| jq -r '.data[] | select(.name | test("-(dev|staging)$")) | .id' \
| while read -r id; do
echo "pausing $id"
curl -s -X POST "https://api.neo4j.io/v1/instances/$id/pause" \
-H "Authorization: Bearer $TOKEN" | jq -r .data.status
done
Add a mirrored resume workflow at 06:00 and warn your team: a resumed instance needs its page cache warmed before the first query feels normal. Resume ahead of the working day, not at the start of it.
Step 6 — connect the application without copying passwords
Terraform created the instance; it should also hand the credentials to whatever runs your application. Write them into your secret store from the same apply:
resource "google_secret_manager_secret_version" "neo4j_prod" {
secret = google_secret_manager_secret.neo4j_prod.id
secret_data = jsonencode({
uri = neo4j_aura_instance.env["prod"].connection_url
username = neo4j_aura_instance.env["prod"].username
password = neo4j_aura_instance.env["prod"].password
})
}
Substitute AWS Secrets Manager, Vault, or Kubernetes ExternalSecret as appropriate. The rule is the same one we apply in hardening Neo4j for production: a human should never read a database password in order to deploy an application.
Step 7 — put schema in the pipeline too
Infrastructure as code gets you an empty database in a known shape. Constraints and indexes are a separate concern and belong in a versioned migration tool, not in Terraform — see our walkthrough of versioned schema migrations and Testcontainers. The clean division of labour:
| Layer | Owned by | Changes when |
|---|---|---|
| Instance, region, tier, size | Terraform | Capacity or environment changes |
| Users, roles, privileges | Migration scripts (GRANT/DENY) | Access model changes |
| Constraints, indexes | Migration scripts | Data model changes |
| Data | Loaders / CDC | Continuously |
Keep those in separate repositories or at least separate pipelines. Mixing a tier resize and a constraint change into one deploy is how you end up unable to roll back either.
A checklist before you call it done
terraform planon a clean checkout reports no changes for every environment.- State lives in an encrypted remote backend with access limited to the deploy role.
- No
# forces replacementcan reach production without a second reviewer. - Non-production instances pause outside working hours, verified by looking at a bill.
- Snapshots are scheduled, and someone has actually restored one into a scratch instance this quarter — the restore drill from our backup and PITR tutorial applies to Aura too.
- Application credentials come from a secret store, and rotating them does not require a Terraform apply.
Where this usually goes next
Once the instances are declarative, the interesting questions become architectural: is prod sized for its page cache working set, or for a number someone guessed? Should analytics run on a separate instance or on Aura Graph Analytics sessions? Do you need Virtual Dedicated Cloud for the network isolation your security review is about to ask for?
Those are the questions we work through with clients on our Neo4j Aura and managed cloud engagements. If you are standing up Aura environments and want the sizing and isolation decisions reviewed before they calcify, get in touch.