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
| Question | Answer |
|---|---|
| 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
| Store | Choose it when | Reads with |
|---|---|---|
| The repository | The platform or design team owns the icons and reviews them in pull requests. The default. | file() with fileset() and for_each |
| Amazon S3 | A design team publishes the icons to a bucket other teams already read | The aws_s3_object data source, body |
| Azure Blob Storage | The icons sit in a storage account | data "http" with a read-only SAS address |
| Google Cloud Storage | The icons sit in a bucket | The google_storage_bucket_object_content data source, content |
| A CDN or a web server | A brand portal already serves the icons at public addresses | source_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:
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:
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:
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:
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
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
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 rule | Value |
|---|---|
| Scheme | https only |
| Time limit | 30 seconds |
| Size | At most 32 KB |
| Type | image/svg+xml, or an untyped body that is an SVG |
| In the state and the plan | Sensitive, 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
| Field | Rule | Advice |
|---|---|---|
name | Lower-case letters and digits in words joined by hyphens, at most 48 characters | Name the service, not the picture: payments-api, not blue-card. Diagrams write it after org:, and changing it replaces the icon. |
aliases | At most 10, each at most 60 characters | The 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.
| Limit | Value |
|---|---|
| File size | 32 KB |
| Elements | 2,000 |
| Nesting | 24 levels |
| Path commands | 4,000 |
| Icons per organisation | Pro 100, Enterprise custom |
| Kept | Refused, with a reason | Left out quietly |
|---|---|---|
| Paths, circles, ellipses, rectangles, lines, polylines, polygons, groups | script, event handlers such as onload | Editor metadata, desc, Inkscape and Sodipodi data |
| Linear and radial gradients, clip paths, masks, inside the file | foreignObject, iframe, image, use, a, animations | class and data- attributes |
Fill, stroke, opacity, transform, and the same properties in a style attribute | A <style> block | Style properties that do not paint |
| A title | A 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
| Advice | Why |
|---|---|
A square viewBox, such as 0 0 24 24 or 0 0 64 64, with the art filling it | The 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 colours | An 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 outlines | Text is refused, and a font would not be there to draw it. |
Presentation attributes, not a <style> block | Export with presentation attributes; most editors have the option. |
| Simplified paths | The path command limit is 4,000, and simpler art reads better at the size a node draws it. |
| Run it through SVGO before committing | It removes editor data and shortens paths, so files stay under 32 KB. |
6. Secure the store
| Practice | How |
|---|---|
| Version the bucket | S3 versioning, Azure blob versioning, or GCS object versioning, so an overwritten icon can be restored and a change is traceable. |
| Read with least privilege | The identity that runs the plan needs read on the icon prefix and nothing else. |
| Keep the bucket private | Read with the provider's credentials or a signed address. Nothing about Plotwork needs a public bucket. |
| Mark addresses with a credential sensitive | A SAS or a signed address in a variable with sensitive = true. source_url is sensitive in the provider. |
| Review changes to icons | An 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:
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/*"] } }}| Cloud | Read-only role to give the plan's identity |
|---|---|
| AWS | s3:GetObject on the prefix, and s3:ListBucket limited to it when you list with aws_s3_objects |
| Azure | Storage Blob Data Reader on the container, or a read-only SAS |
| Google Cloud | roles/storage.objectViewer on the bucket |
7. Caching and changes
| Question | Answer |
|---|---|
| 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::
flow "Checkout" direction=rightnode shop service "Storefront"node pay service "Payments API" icon=org:payments-apinode books database "Ledger" icon=org:ledgershop -> paypay -> booksref 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
| Check | Done when |
|---|---|
| One source of truth | Icons live in the repository or in one bucket, never both |
| Names | Lower-case words joined by hyphens, after the service, at most 48 characters |
| Aliases | The words teams write in labels, at most 10 |
| Art | A square viewBox, explicit colours, text as outlines, no <style> block |
| Size | Each file under 32 KB, under 4,000 path commands |
| S3 | Objects stored as application/xml, or served by a CDN through source_url |
| Azure | A read-only SAS with an expiry, in a sensitive variable |
| Access | The plan's identity can read the icon prefix and nothing else |
| Versioning | The bucket keeps old versions |
| Review | Icon changes go through a pull request |
| Key | An organisation key with design:write, in a sensitive environment variable |
See also
| Page | Why |
|---|---|
| Terraform in organisations | Workspaces, keys, CI and drift for the whole organisation |
| Terraform provider | Install, release channels, and every block |
| Custom icons | Uploading in the portal, and how diagrams draw an icon |
| Brand themes | The organisation's colours beside its icons |
| Cloud icon names | The bundled icons and how names are written |