Skip to content
Connect an agent

Infrastructure as code

Hosting custom icons for Terraform

Where to keep an organisation's icon SVGs so the Terraform and OpenTofu provider can upload them, how to read each store, and what the files must hold to pass Plotwork's check. It is for platform and design teams who own the icons every diagram in the organisation draws as icon=org:<name>.

TL;DR

QuestionAnswer
Where should the SVGs live?In the design workspace's repository, next to the configuration, unless another team publishes them. Then a bucket (S3, Azure Blob Storage, Google Cloud Storage) or a CDN.
How does the provider get the file?As text in svg, from file() or a data source, or as an https address in source_url that the provider downloads during every plan.
What does S3 need?Store the SVG with content_type = "application/xml". The aws_s3_object data source fills body only for text types, and image/svg+xml is not one of them.
What does Azure Blob Storage need?A read-only SAS address and data "http": azurerm_storage_blob does not read a blob's contents.
What does Google Cloud Storage need?The google_storage_bucket_object_content data source, whose content is the file.
How big can an icon be?32 KB, 2,000 elements, 24 levels of nesting and 4,000 path commands.
Is the file kept as uploaded?No. Plotwork reads it with a strict parser and writes it again from an allowlist of drawing elements. Scripts, links, <style> blocks, text and embedded images are refused.
How is a change noticed?The provider hashes the file it would send into content_sha256 during plan. A new file, here or at the address, is a planned update.
Which plan?The owner on Pro (up to 100 icons) or Enterprise. The key needs the design:write scope.

1. Choose where the icons live

StoreChoose it whenReads with
The repositoryThe platform or design team owns the icons and reviews them in pull requests. The default.file() with fileset() and for_each
Amazon S3A design team publishes the icons to a bucket other teams already readThe aws_s3_object data source, body
Azure Blob StorageThe icons sit in a storage accountdata "http" with a read-only SAS address
Google Cloud StorageThe icons sit in a bucketThe google_storage_bucket_object_content data source, content
A CDN or a web serverA brand portal already serves the icons at public addressessource_url

Keep one store as the source of truth. A copy in the repository and another in a bucket drift apart, and the one the configuration reads is the one diagrams draw.

2. Read each store

From the repository

One icon per file, named after the file:

HCL
resource "plotwork_organization_icon" "this" {  for_each = fileset("${path.module}/icons", "*.svg")
  name    = trimsuffix(each.value, ".svg")  svg     = file("${path.module}/icons/${each.value}")  aliases = lookup(var.icon_aliases, trimsuffix(each.value, ".svg"), null)}
variable "icon_aliases" {  type    = map(list(string))  default = { "payments-api" = ["payments service"] }}

From Amazon S3

The aws provider fills body only for text content types, such as text/* and application/xml. An object stored as image/svg+xml comes back with an empty body. Upload the icons as application/xml:

HCL
resource "aws_s3_object" "icon" {  for_each = fileset("${path.module}/icons", "*.svg")
  bucket       = "acme-design"  key          = "icons/${each.value}"  source       = "${path.module}/icons/${each.value}"  content_type = "application/xml"}

Then read every icon under a prefix in the workspace that uploads them to Plotwork:

HCL
data "aws_s3_objects" "icons" {  bucket = "acme-design"  prefix = "icons/"}
data "aws_s3_object" "icon" {  for_each = toset([for key in data.aws_s3_objects.icons.keys : key if endswith(key, ".svg")])
  bucket = "acme-design"  key    = each.value}
resource "plotwork_organization_icon" "from_s3" {  for_each = data.aws_s3_object.icon
  name = trimsuffix(basename(each.key), ".svg")  svg  = each.value.body}

If the bucket must serve the files to browsers as image/svg+xml, put a CDN in front of it and use source_url instead.

From Azure Blob Storage

azurerm_storage_blob manages a blob but does not read its contents, so download it with a read-only SAS address. Keep the address in a sensitive variable:

HCL
variable "queue_icon_sas_url" {  type      = string  sensitive = true}
data "http" "queue_icon" {  url = var.queue_icon_sas_url
  lifecycle {    postcondition {      condition     = self.status_code == 200      error_message = "The blob did not answer 200: check the SAS address and that it has not expired."    }  }}
resource "plotwork_organization_icon" "queue" {  name = "queue"  svg  = data.http.queue_icon.response_body}

Give the SAS only read permission, on the container that holds the icons, with an expiry. A user delegation SAS signed with Microsoft Entra ID is better than one signed with the account key.

From Google Cloud Storage

HCL
data "google_storage_bucket_object_content" "gateway" {  bucket = "acme-design"  name   = "icons/gateway.svg"}
resource "plotwork_organization_icon" "gateway" {  name = "gateway"  svg  = data.google_storage_bucket_object_content.gateway.content}

From a CDN

HCL
resource "plotwork_organization_icon" "gateway" {  name       = "gateway"  source_url = "https://cdn.acme.example/icons/gateway.svg"}

The provider downloads the address itself during every plan, so a new file there is a planned change.

source_url ruleValue
Schemehttps only
Time limit30 seconds
SizeAt most 32 KB
Typeimage/svg+xml, or an untyped body that is an SVG
In the state and the planSensitive, because a signed address holds a credential

A signed address made during each plan, such as one from a data source, is new on every run. Pass a signed address in a sensitive variable, or read the object's content with the store's data source.

3. Names and aliases

FieldRuleAdvice
nameLower-case letters and digits in words joined by hyphens, at most 48 charactersName the service, not the picture: payments-api, not blue-card. Diagrams write it after org:, and changing it replaces the icon.
aliasesAt most 10, each at most 60 charactersThe words people write in labels, such as payments service or Acme Pay. A node whose whole label is an alias draws the icon with no icon=.

Keep names stable. A diagram that names an icon which no longer exists draws a placeholder and the check warns. Keep a name_prefix variable for test runs, such as test-, so a trial apply never replaces a real icon.

4. What Plotwork accepts

Plotwork checks every SVG the provider sends, as it checks an upload in the portal, and keeps its own rewritten copy. A refused file fails the apply with 422 icon.invalid, and details lists every reason with how to fix it.

LimitValue
File size32 KB
Elements2,000
Nesting24 levels
Path commands4,000
Icons per organisationPro 100, Enterprise custom
KeptRefused, with a reasonLeft out quietly
Paths, circles, ellipses, rectangles, lines, polylines, polygons, groupsscript, event handlers such as onloadEditor metadata, desc, Inkscape and Sodipodi data
Linear and radial gradients, clip paths, masks, inside the fileforeignObject, iframe, image, use, a, animationsclass and data- attributes
Fill, stroke, opacity, transform, and the same properties in a style attributeA <style> blockStyle properties that do not paint
A titleA DOCTYPE, entities, CDATA, a link or url() to anything outside the file
Text that is not converted to outlines, filters, patterns

Every id in the file is renamed after the icon, so two icons never share one. The rewritten file is drawn as an image from a data: address, where a browser runs no script and loads nothing. Terraform uploads SVG only; a PNG icon is uploaded in the portal.

Because Plotwork keeps its rewritten copy, the provider compares the file it would send, by content_sha256, not what Plotwork stored. bytes, width and height describe the stored copy.

5. Draw art that reads well

AdviceWhy
A square viewBox, such as 0 0 24 24 or 0 0 64 64, with the art filling itThe icon's width and height come from the view box, and a wide box is fitted into the same space as a square one, so its art reads smaller. A file with no viewBox and no pixel size is refused.
Explicit fill coloursAn organisation icon is drawn in its own colours on a light tile, in both themes. currentColor is accepted, but in an image it has no colour around it and paints black.
Text converted to outlinesText is refused, and a font would not be there to draw it.
Presentation attributes, not a <style> blockExport with presentation attributes; most editors have the option.
Simplified pathsThe path command limit is 4,000, and simpler art reads better at the size a node draws it.
Run it through SVGO before committingIt removes editor data and shortens paths, so files stay under 32 KB.

6. Secure the store

PracticeHow
Version the bucketS3 versioning, Azure blob versioning, or GCS object versioning, so an overwritten icon can be restored and a change is traceable.
Read with least privilegeThe identity that runs the plan needs read on the icon prefix and nothing else.
Keep the bucket privateRead with the provider's credentials or a signed address. Nothing about Plotwork needs a public bucket.
Mark addresses with a credential sensitiveA SAS or a signed address in a variable with sensitive = true. source_url is sensitive in the provider.
Review changes to iconsAn icon is drawn in every team's diagrams. Protect the prefix or folder with the same review as the configuration.

Least-privilege read on AWS, for the role that runs the plan:

HCL
data "aws_iam_policy_document" "read_icons" {  statement {    actions   = ["s3:GetObject"]    resources = ["arn:aws:s3:::acme-design/icons/*"]  }
  statement {    actions   = ["s3:ListBucket"]    resources = ["arn:aws:s3:::acme-design"]
    condition {      test     = "StringLike"      variable = "s3:prefix"      values   = ["icons/*"]    }  }}
CloudRead-only role to give the plan's identity
AWSs3:GetObject on the prefix, and s3:ListBucket limited to it when you list with aws_s3_objects
AzureStorage Blob Data Reader on the container, or a read-only SAS
Google Cloudroles/storage.objectViewer on the bucket

7. Caching and changes

QuestionAnswer
When is an icon uploaded again?When content_sha256 of the file the provider would send changes, or when the icon was replaced or deleted outside Terraform.
Does a CDN's cache delay a change?Yes. The provider downloads what the CDN serves, so a cached old file is no change until the cache expires. Purge the path, or give each version its own address, such as gateway.v3.svg.
When do diagrams show the new icon?Every diagram drawn after the apply. A picture already published keeps the icon it was drawn with; publish it again to draw the new one.
Does the plan download every icon every time?Yes, for every source_url and every data source. Keep source_url for a few icons, and read large sets from the repository or with the bucket's data sources.

8. Use them in diagrams

A diagram drawn by the hosted service for the organisation writes the name after org::

Plotwork
flow "Checkout" direction=rightnode shop  service  "Storefront"node pay   service  "Payments API"  icon=org:payments-apinode books database "Ledger"        icon=org:ledgershop -> paypay -> books

ref on each icon gives the text to write, org:<name>. The hosted MCP server, the REST API and plotwork_diagram with the organisation draw it. The local MCP server, the npm package, the command line and the GitHub Action know no org: name, and report one as an unknown icon.

9. Checklist

CheckDone when
One source of truthIcons live in the repository or in one bucket, never both
NamesLower-case words joined by hyphens, after the service, at most 48 characters
AliasesThe words teams write in labels, at most 10
ArtA square viewBox, explicit colours, text as outlines, no <style> block
SizeEach file under 32 KB, under 4,000 path commands
S3Objects stored as application/xml, or served by a CDN through source_url
AzureA read-only SAS with an expiry, in a sensitive variable
AccessThe plan's identity can read the icon prefix and nothing else
VersioningThe bucket keeps old versions
ReviewIcon changes go through a pull request
KeyAn organisation key with design:write, in a sensitive environment variable

See also

PageWhy
Terraform in organisationsWorkspaces, keys, CI and drift for the whole organisation
Terraform providerInstall, release channels, and every block
Custom iconsUploading in the portal, and how diagrams draw an icon
Brand themesThe organisation's colours beside its icons
Cloud icon namesThe bundled icons and how names are written