A Data Factory's stored definition of how to reach a PostgreSQL server, targeting
hashicorp/azurerm ~> 4.0.
- 🔌 Creates one
azurerm_data_factory_linked_service_postgresql— the definition a dataset or copy activity uses to reach a PostgreSQL server. - 🔴 The provider marks nothing on this resource as sensitive.
Sensitive:does not appear once in the entire resource file, whileconnection_stringis required and routinely carries the credential inline. This module marks it sensitive itself. - 🔴 The read never refreshes the connection string, so Terraform cannot see drift in it. A portal edit produces no diff, ever.
- ✅ Which is also why rotation works here — unlike the ODBC and OData siblings, whose block password is frozen behind a sentinel, a connection-string change is a genuine in-place update.
- 🔀 The MySQL sibling is near-identical and carries a trap this one does not.
💡 Why it matters: every check this resource performs is offline and cheap, and every fact that actually bites is invisible in the plan. This module moves those facts into outputs.
If this module saved you time:
- ⭐ Star the repository — it is the cheapest signal that this work is worth continuing.
- 💼 Connect on LinkedIn: linkedin.com/in/microsoftexpert
- ☕ Buy me a coffee: buymeacoffee.com/microsoftexpert
flowchart TB
RG["terraform-azurerm-resource-group"]
ADF["terraform-azurerm-data-factory"]
MY["terraform-azurerm-data-factory-linked-service-mysql"]
PG["terraform-azurerm-data-factory-linked-service-postgresql"]
SHIR["terraform-azurerm-data-factory-integration-runtime-self-hosted"]
SRC["a MySQL or PostgreSQL server, never contacted by Terraform"]
DS["a dataset, then a copy activity"]
RG -->|"name"| ADF
ADF -->|"id"| MY
ADF -->|"id"| PG
SHIR -->|"name, required to reach a private or on-premises server"| MY
SHIR -->|"name, required to reach a private or on-premises server"| PG
MY -->|"connects at pipeline runtime, never at apply"| SRC
PG -->|"connects at pipeline runtime, never at apply"| SRC
MY -->|"name, referenced BY NAME and never by id"| DS
PG -->|"name, referenced BY NAME and never by id"| DS
classDef this fill:#0078D4,stroke:#004578,color:#ffffff,stroke-width:2px
classDef keystone fill:#004578,stroke:#00243d,color:#ffffff,stroke-width:2px
classDef sibling fill:#eef3f8,stroke:#b9c8d8,color:#1b2733
class MY,PG this
class ADF keystone
class RG,SHIR,SRC,DS sibling
Both this module and its MySQL sibling hang off the same factory and share the same runtime requirement. They differ in exactly one thing, described in Provider / Versions below.
flowchart TB
subgraph INPUTS["Inputs"]
ID["name and data_factory_id, THE ONLY TWO FORCE-NEW FIELDS"]
CS["connection_string, REQUIRED, and the provider marks NOTHING sensitive"]
META["integration_runtime_name, parameters, annotations, additional_properties"]
end
THIS["azurerm_data_factory_linked_service_postgresql.this"]
subgraph OUTPUTS["Outputs"]
BRANCH["which suppressor branch the connection string takes"]
DRIFT["read never refreshes the connection string, so drift is invisible"]
IMP["importing brings no connection string with it"]
ROT["rotating the connection string IS a real update"]
PRINT["the provider does not mark the connection string sensitive"]
NOCD["no CustomizeDiff, unlike the MySQL sibling"]
end
ID --> THIS
CS --> THIS
META --> THIS
CS --> BRANCH
CS --> DRIFT
CS --> IMP
CS --> ROT
CS --> PRINT
META --> NOCD
classDef this fill:#0078D4,stroke:#004578,color:#ffffff,stroke-width:2px
classDef sibling fill:#eef3f8,stroke:#b9c8d8,color:#1b2733
class THIS this
class ID,CS,META,BRANCH,DRIFT,IMP,ROT,PRINT,NOCD sibling
| Resource | Count | Notes |
|---|---|---|
azurerm_data_factory_linked_service_postgresql.this |
1 | The keystone. A metadata record. |
timeouts |
dynamic, 0..1 |
All four keys; none bounds a query. |
| Requirement | Value |
|---|---|
| Terraform | >= 1.12.0 |
hashicorp/azurerm |
~> 4.0 |
| Provider block | None. The caller configures the provider, including the mandatory features {} block. |
Schema notes that bite — verified against the live provider source:
- 🔴
Sensitive:does not appear once in this resource, andconnection_stringis required. There is no Key Vault block here, unlike the SQL Server and Azure SQL Database linked services. - 🔴 The read function never sets
connection_string. State keeps whatever was last applied, so drift in it is invisible and an imported resource carries no connection string at all. - ✅ A connection-string change is a real in-place update. The credential is rotatable.
- 🔀 The shared diff suppressor sorts and lowercases segments first, so reordering a connection string or changing only its case is not a diff.
- 🔀 Its password strip is a RAW PREFIX MATCH on an UNTRIMMED segment, and that is sharper than it
reads. It removes a segment whose lowercased form begins with the literal
password. So;Password=xis stripped,; Password=x— the same credential with the space almost everyone types — is NOT,;PasswordExpiry=30is stripped despite not being a credential, and;Pwd=xnever is. ⚠️ The provider checks only thatconnection_stringis non-empty. It does not parse it and does not contact anything.- ℹ️ No
CustomizeDiffand no cross-field rules. Every check the provider performs is a schema validator that refuses a bad configuration atterraform plan, offline, without credentials. - 🔀
azurerm_data_factory_linked_service_mysqladds adriver_versionargument whose default on this provider line collides with a check refusing that same value on create. This resource has neither. ⚠️ The name validator is weaker than its own error message. Its pattern is anchored at both ends, so it rejects only a name composed entirely of the twelve characters the message forbids. It applies no length rule, no leading-character rule, and no emptiness check at all. Azure's published rules are stricter — and Azure's two pages disagree with each other about the leading character and the dash — so this module reports both conditions rather than refusing either.⚠️ Onlynameanddata_factory_idare force-new. There is nolocationand notags.
| Permission | Scope | Why |
|---|---|---|
Microsoft.DataFactory/factories/linkedservices/write |
the Data Factory | Create and update. |
Microsoft.DataFactory/factories/linkedservices/read |
the Data Factory | Refresh and plan. |
Microsoft.DataFactory/factories/linkedservices/delete |
the Data Factory | Destroy — which breaks every dataset referencing it. |
Data Factory Contributor |
the Data Factory | The built-in role containing the above. |
- An existing Data Factory.
- A reachable PostgreSQL server. For a private or on-premises server, a self-hosted integration runtime — the factory's default runtime is Microsoft-hosted and has no route to one.
- A PostgreSQL role whose privileges match what the pipelines actually need.
terraform-azurerm-data-factory-linked-service-postgresql/
├── providers.tf # required_version + the pinned azurerm; no provider block
├── variables.tf # 9 variables, 11 validations
├── main.tf # the keystone, its timeouts block, and the derived locals
├── outputs.tf # 46 outputs: the id first, then the facts no plan shows
├── README.md # this file
├── SCOPE.md # the cross-module contract
├── LICENSE # MIT
└── .gitignore
provider "azurerm" {
features {}
}
module "postgres_link" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-data-factory-linked-service-postgresql.git?ref=v1.0.0"
name = "ls-postgres-warehouse"
data_factory_id = module.data_factory.id
connection_string = var.postgres_connection_string
}🔒 The caller configures the provider, its authentication, and the mandatory
features {}block. The connection string should come from a secret store, never a literal in a repository.
Consumes
| Input | Type | Source |
|---|---|---|
data_factory_id |
string |
terraform-azurerm-data-factory (id) |
connection_string |
string (sensitive) |
a secret store, at the call site |
integration_runtime_name |
string |
terraform-azurerm-data-factory-integration-runtime-self-hosted (name) |
Emits
| Output | Consumed by |
|---|---|
id |
imports, RBAC scoping |
name |
datasets and copy activities |
connection_string_takes_the_suppressor_password_branch |
assertions |
read_never_refreshes_the_connection_string |
operations |
1 · The smallest real call
module "postgres_link" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-data-factory-linked-service-postgresql.git?ref=v1.0.0"
name = "ls-postgres-warehouse"
data_factory_id = module.data_factory.id
connection_string = var.postgres_connection_string
}ℹ️ Three arguments are all this resource requires. Everything else is metadata.
2 · Reaching a private server through a self-hosted runtime
module "postgres_link" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-data-factory-linked-service-postgresql.git?ref=v1.0.0"
name = "ls-postgres-onprem"
data_factory_id = module.data_factory.id
connection_string = var.postgres_connection_string
integration_runtime_name = module.self_hosted_runtime.name
}
⚠️ Omitintegration_runtime_nameand the factory's default AutoResolve runtime is used. It is Microsoft-hosted and has no route to a private or on-premises server. Nothing fails at apply — the pipeline fails later.
3 · Reading the credential from a secret store
data "azurerm_key_vault_secret" "postgres" {
name = "postgres-warehouse-connection-string"
key_vault_id = var.key_vault_id
}
module "postgres_link" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-data-factory-linked-service-postgresql.git?ref=v1.0.0"
name = "ls-postgres-warehouse"
data_factory_id = module.data_factory.id
connection_string = data.azurerm_key_vault_secret.postgres.value
}🔒 This resource has no Key Vault block, so the value must pass through the configuration. Reading it from a vault at plan time keeps it out of the repository — it does not keep it out of state.
4 · Checking where the credential ended up
output "credential_placement" {
value = {
detected = module.postgres_link.connection_string_appears_to_contain_a_password
branch = module.postgres_link.connection_string_takes_the_suppressor_password_branch
}
}💡 Both are known at plan. The detector is anchored to a segment boundary, so a host named
my-password-hostdoes not register as a credential.
5 · The synonym that changes the comparison
# Both of these are functionally identical to PostgreSQL.
# They are NOT identical to the provider's diff suppressor.
locals {
spelled_password = "Server=pg01;Database=sales;UID=svc;Password=..."
spelled_pwd = "Server=pg01;Database=sales;UID=svc;Pwd=..."
}🔀 The suppressor strips segments beginning with the literal
passwordbefore comparing, and leavespwdin place — and, because the match is on an untrimmed segment,; Password=is left in place too.the_password_strip_matches_password_and_not_pwdandthe_password_strip_is_defeated_by_a_space_after_the_semicolonrecord both halves; the branch a given string lands in is reported byconnection_string_takes_the_suppressor_password_branch.
6 · Asserting that drift cannot be detected
output "connection_string_drift_is_invisible" {
value = module.postgres_link.read_never_refreshes_the_connection_string
}
⚠️ Constanttrue. The read never sets the field, so a portal edit to this linked service produces no diff. If you need to know what is actually stored, read it from the Data Factory — not from Terraform.
7 · Rotating the credential
module "postgres_link" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-data-factory-linked-service-postgresql.git?ref=v1.0.0"
name = "ls-postgres-warehouse"
data_factory_id = module.data_factory.id
connection_string = data.azurerm_key_vault_secret.postgres.value # rotated in the vault
}✅ This works, and it is worth stating because the sibling ODBC and OData modules report the opposite for their block password. Here a changed connection string is a real in-place update.
8 · Description, parameters and annotations
module "postgres_link" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-data-factory-linked-service-postgresql.git?ref=v1.0.0"
name = "ls-postgres-warehouse"
data_factory_id = module.data_factory.id
connection_string = var.postgres_connection_string
description = "Warehouse reporting replica"
parameters = { env = "prod" }
annotations = ["owner:data-platform", "tier:gold"]
}
⚠️ annotationsare not Azure resource tags — this resource supports none. They are an ordered list, so reordering them is a real diff.
9 · Why an empty description is rejected
# description = "" # rejected: the provider validates it as non-emptyℹ️ Omit the argument to mean "no description". Passing
""fails atterraform validate, offline.
10 · Several linked services from one map
module "postgres_links" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-data-factory-linked-service-postgresql.git?ref=v1.0.0"
for_each = var.postgres_sources
name = each.key
data_factory_id = module.data_factory.id
connection_string = each.value.connection_string
integration_runtime_name = each.value.runtime_name
}💡 Keys are the linked-service names, which is what datasets reference. Renaming a key destroys and recreates the record, and every dataset pointing at the old name breaks at that moment.
11 · Timeouts
module "postgres_link" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-data-factory-linked-service-postgresql.git?ref=v1.0.0"
name = "ls-postgres-warehouse"
data_factory_id = module.data_factory.id
connection_string = var.postgres_connection_string
timeouts = {
create = "45m"
read = "10m"
update = "45m"
delete = "45m"
}
}
⚠️ These bound Terraform's wait on the Data Factory control-plane call. They do not bound a query against PostgreSQL. A fifth key would be silently discarded by object-type conversion, with no error at all.
12 · What this module refuses, and what it deliberately does not
# Refused at `terraform validate` on a calling configuration, offline and without credentials:
# name = "-.-" entirely punctuation; mirrors the provider's own rule exactly
# data_factory_id = "<a dataset id>" anchored to the factory resource type
# connection_string = "https://..." that is the OData or Web resource, not this one
#
# NOT refused, and reported instead:
# a name containing characters the provider's own message disallows
# name = "1warehouse" Azure's two naming pages disagree about a leading digit💡 The provider accepts those names, so refusing them would reject legal input — and a failed
validation {}blocksterraform destroyas well as apply.
13 · Comparing against the MySQL sibling
output "sibling_difference" {
value = module.postgres_link.the_mysql_sibling_carries_a_driver_version_trap_that_this_one_does_not
}🔀 Constant
true. The two resources have near-identical schemas. MySQL adds adriver_versionwhose default on this provider line collides with a check refusing that value on create; this one has neither. Do not carry an expectation from one module to the other.
14 · 🏗️ End-to-end composition
provider "azurerm" {
features {}
}
module "resource_group" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-resource-group.git?ref=v1.0.0"
name = "rg-data-eastus"
location = "eastus"
}
module "data_factory" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-data-factory.git?ref=v1.0.0"
name = "adf-corp-eastus"
resource_group_name = module.resource_group.name
location = module.resource_group.location
}
module "self_hosted_runtime" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-data-factory-integration-runtime-self-hosted.git?ref=v1.0.0"
name = "ir-onprem-eastus"
data_factory_id = module.data_factory.id
}
module "postgres_link" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-data-factory-linked-service-postgresql.git?ref=v1.0.0"
name = "ls-postgres-warehouse"
data_factory_id = module.data_factory.id
connection_string = var.postgres_connection_string
integration_runtime_name = module.self_hosted_runtime.name
description = "Warehouse reporting replica"
annotations = ["owner:data-platform"]
}
output "review" {
value = {
linked_service = module.postgres_link.name
credential_found = module.postgres_link.connection_string_appears_to_contain_a_password
drift_invisible = module.postgres_link.read_never_refreshes_the_connection_string
rotatable = module.postgres_link.rotating_the_connection_string_is_a_real_update
private_route = !module.postgres_link.runs_on_the_factory_default_integration_runtime
}
}🏗️ The runtime is wired by name, which is how this resource references it — there is no dependency edge from the linked service to the runtime, so nothing proves the runtime exists.
Required: name, data_factory_id, connection_string.
Optional: description, integration_runtime_name, parameters, annotations,
additional_properties, timeouts.
Full schemas
variable "connection_string" {
type = string
sensitive = true # marked by THIS MODULE; the provider marks nothing here
}
variable "timeouts" {
type = object({
create = optional(string)
read = optional(string)
update = optional(string)
delete = optional(string)
})
default = null
}| Output | Notes |
|---|---|
id |
The Resource ID. Emitted first. |
name |
What datasets reference. |
data_factory_id, data_factory_name, resource_group_name, subscription_id |
Parent context. |
description, parameters, annotations, integration_runtime_name |
Metadata as stored. |
connection_string_appears_to_contain_a_password |
Anchored detection, known at plan. |
connection_string_takes_the_suppressor_password_branch |
One of four branches. |
the_password_strip_is_defeated_by_a_space_after_the_semicolon |
The trap in that branch. |
name_does_not_start_with_a_letter |
Reported, not refused. Azure's two naming pages disagree. |
read_never_refreshes_the_connection_string |
Constant. Drift is invisible. |
rotating_the_connection_string_is_a_real_update |
Constant. |
the_provider_does_not_mark_the_connection_string_sensitive |
Constant. |
force_new_fields, fields_that_can_change_after_creation |
Lifecycle. |
no_customize_diff_guards_this_resource |
Constant. |
the_mysql_sibling_carries_a_driver_version_trap_that_this_one_does_not |
Constant. |
No output emits a secret. The full list, with the reasoning behind each constant, is in outputs.tf.
The resource is a metadata record, and almost everything that matters about it is a consequence of that.
Renaming is destructive in a way the plan does not show. name and data_factory_id are the only
force-new fields. Datasets and copy activities select a linked service by name, and nothing points back
from here to them, so Terraform will happily replace this record without any indication of what stops
resolving.
The connection string is the whole security surface, and the provider does not treat it as one. There
is not one Sensitive: flag in the resource. This module marks the input sensitive, which redacts plan
output for callers coming through the module. It does not encrypt state, and it changes nothing for anyone
using the resource directly. The control that matters is an encrypted, access-controlled backend.
Drift in that string is invisible. The read function never sets it. Whatever was last applied is what state believes, indefinitely. This is also why an import brings no connection string with it, and why the first plan after an import proposes writing the configuration's value without ever having read the stored one.
The diff suppressor is stranger than it looks. It lowercases both values, splits on ;, sorts, and
compares — so order and case are irrelevant. Before comparing it removes, from the new value, a segment
whose lowercased form begins with the literal password. It does not trim the segment first and does not
require an =, which makes the match sharper than it reads: ;Password=x is stripped, ; Password=x is
not, ;PasswordExpiry=30 is, and ;Pwd=x never is. Two functionally identical connection strings therefore
compare differently on the synonym typed — or on a single space.
Sensitivity is contagious, including to scalars. Every derived flag in main.tf is unwrapped
individually with nonsensitive(); wrapping a constructed collection would not be enough, because
element-level marks persist.
| Concern | This module's default | Opt-out |
|---|---|---|
| Connection string in plan output | sensitive = true, applied by this module |
none — and it does not affect state |
| Emitting the credential | never emitted; presence only | none |
| Route to a private server | reported through runs_on_the_factory_default_integration_runtime |
name a self-hosted runtime |
| A name the provider accepts | reported, never refused | none needed |
| Values the provider genuinely rejects | refused offline at terraform plan |
none |
The secure-by-default rule cannot fully apply here: connection_string is required, so there is no
empty call to make safe. The module compensates by marking it, detecting a credential inside it, and saying
plainly what the marking does and does not do.
terraform init -backend=false
terraform validate
terraform fmt -checkPin ?ref=v1.0.0, never a branch. Plan-only; a human applies from CI.
terraform validate exercises every check this module and this resource perform — the module's eleven
validations and the provider's schema validators alike. There is no CustomizeDiff on this resource, so
unlike its MySQL sibling there is nothing that fires only at plan.
What validate cannot reach: whether the PostgreSQL server exists, whether the credential works, whether
the named integration runtime exists, and whether the connection string is even syntactically a connection
string. None of those is checked by anything until a pipeline runs.
id = "/subscriptions/.../factories/adf-corp-eastus/linkedservices/ls-postgres-warehouse"
name = "ls-postgres-warehouse"
connection_string_appears_to_contain_a_password = true
connection_string_takes_the_suppressor_password_branch = "password_segment_is_stripped_before_comparison"
read_never_refreshes_the_connection_string = true
rotating_the_connection_string_is_a_real_update = true
runs_on_the_factory_default_integration_runtime = false
force_new_fields = ["name", "data_factory_id"]
| Symptom | Cause | Fix |
|---|---|---|
connection_string must not be empty or whitespace. |
The value resolved to blank — often a secret lookup that returned nothing. | Check the secret exists before blaming the module. |
connection_string looks like a URL. |
The OData or Web linked service was wanted, not this one. | Use azurerm_data_factory_linked_service_odata or _web. |
description must not be an empty or whitespace-only string. |
"" was passed to mean "none". |
Omit the argument. |
name must not consist ENTIRELY of the characters ... |
The name is all punctuation. This mirrors the provider's own shared validator, which rejects exactly that shape and nothing else. | Use a name with at least one other character. |
must be a Data Factory Resource ID |
A dataset, linked service or pipeline ID was passed. | Pass the factory's id. |
| Pipeline fails to connect, but apply succeeded | Nothing here contacts PostgreSQL. | Check the server, the credential, and the runtime's route to it. |
| A portal change to the linked service never appears in a plan | The read never refreshes the connection string. | Expected. Read the Data Factory directly. |
| Datasets break after a rename | The record was replaced; datasets reference it by name. | Update the datasets, or avoid renaming. |
azurerm_data_factory_linked_service_postgresqlazurerm_data_factory_linked_service_mysql— the near-identical sibling- Azure Data Factory naming rules
- This module's
SCOPE.md
💙 "Infrastructure as Code should be standardized, consistent, and secure."