# Hosting custom icons for Terraform

> Page: https://docs.plotwork.dev/terraform-icon-hosting. Index of every page: https://docs.plotwork.dev/llms.txt

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:

```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` 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:

```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/*"]
    }
  }
}
```

| 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:`:

```plotwork
flow "Checkout" direction=right
node shop  service  "Storefront"
node pay   service  "Payments API"  icon=org:payments-api
node books database "Ledger"        icon=org:ledger
shop -> pay
pay -> 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

| 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](https://docs.plotwork.dev/terraform-organisations.md) | Workspaces, keys, CI and drift for the whole organisation |
| [Terraform provider](https://docs.plotwork.dev/terraform-provider.md) | Install, release channels, and every block |
| [Custom icons](https://docs.plotwork.dev/custom-icons.md) | Uploading in the portal, and how diagrams draw an icon |
| [Brand themes](https://docs.plotwork.dev/brand-themes.md) | The organisation's colours beside its icons |
| [Cloud icon names](https://docs.plotwork.dev/cloud-architecture/icon-names.md) | The bundled icons and how names are written |
