|
| 1 | +## External Secret Syncer (ESS) |
| 2 | + |
| 3 | +### Overview |
| 4 | + |
| 5 | +Creates an application that continuously syncs secrets from external providers into Control Plane secrets on a configurable schedule. Supported providers: **HashiCorp Vault**, **AWS Secrets Manager**, **AWS Parameter Store**, **Doppler**, **GCP Secret Manager**, **1Password**, **1Password Connect**, and **Infisical**. |
| 6 | + |
| 7 | +--- |
| 8 | + |
| 9 | +### How It Works |
| 10 | + |
| 11 | +ESS runs as a workload on Control Plane. Your provider configuration and secrets list are stored in a Control Plane secret and mounted into the workload as `sync.yaml`. On startup, ESS schedules a polling loop for each configured secret. At each interval, it fetches the latest value from the external provider and creates or updates the corresponding Control Plane secret via the API. |
| 12 | + |
| 13 | +ESS tags every secret it manages with `syncer.cpln.io/source` (set to the workload path). This prevents two ESS instances from accidentally overwriting each other's secrets. An hourly cleanup job also deletes any Control Plane secrets that ESS owns but that have been removed from your `sync.yaml` config. |
| 14 | + |
| 15 | +--- |
| 16 | + |
| 17 | +### Patch Notes |
| 18 | + |
| 19 | +This version of ESS fixes a bug preventing the cleanup from running |
| 20 | + |
| 21 | +### Configuring `values.yaml` |
| 22 | + |
| 23 | +#### Top-level fields |
| 24 | + |
| 25 | +| Field | Description | |
| 26 | +|---|---| |
| 27 | +| `image` | The ESS container image. Do not change unless upgrading. | |
| 28 | +| `resources.cpu` / `resources.memory` | Resource limits for the workload container. | |
| 29 | +| `port` | Port for the ESS HTTP admin API (default: `3004`). Used for health checks and manual sync triggers. | |
| 30 | +| `allowedIp` | List of CIDRs allowed to reach the ESS admin API externally. Replace the placeholder with your IP, or use `0.0.0.0/0` to allow all. | |
| 31 | +| `essConfig` | The full sync configuration — providers and secrets (see below). | |
| 32 | + |
| 33 | +--- |
| 34 | + |
| 35 | +#### `essConfig.providers` |
| 36 | + |
| 37 | +Each provider entry requires a unique `name` and exactly one provider block. An optional `syncInterval` sets the default interval for all secrets using that provider. |
| 38 | + |
| 39 | +**Vault** |
| 40 | +```yaml |
| 41 | +- name: my-vault |
| 42 | + vault: |
| 43 | + address: https://my-vault.com:8200 # required |
| 44 | + token: <TOKEN> # required |
| 45 | + syncInterval: 1m # optional — overrides global default |
| 46 | +``` |
| 47 | +
|
| 48 | +**AWS Parameter Store** |
| 49 | +```yaml |
| 50 | +- name: my-aws-ssm |
| 51 | + awsParameterStore: |
| 52 | + region: us-east-1 |
| 53 | + accessKeyId: <ACCESS_KEY> # optional if using an IAM-linked identity |
| 54 | + secretAccessKey: <SECRET_KEY> # optional if using an IAM-linked identity |
| 55 | +``` |
| 56 | +
|
| 57 | +**AWS Secrets Manager** |
| 58 | +```yaml |
| 59 | +- name: my-aws-secrets-manager |
| 60 | + awsSecretsManager: |
| 61 | + region: us-east-1 |
| 62 | + accessKeyId: <ACCESS_KEY> |
| 63 | + secretAccessKey: <SECRET_KEY> |
| 64 | +``` |
| 65 | +
|
| 66 | +**Doppler** |
| 67 | +```yaml |
| 68 | +- name: my-doppler |
| 69 | + doppler: |
| 70 | + accessToken: <TOKEN> # use a Doppler service token (dp.st....) |
| 71 | +``` |
| 72 | +
|
| 73 | +**GCP Secret Manager** |
| 74 | +```yaml |
| 75 | +- name: my-gcp |
| 76 | + gcpSecretManager: |
| 77 | + projectId: 123456789876 |
| 78 | + credentials: # optional — omit to use Application Default Credentials |
| 79 | + clientEmail: <EMAIL> |
| 80 | + privateKey: <PRIVATE_KEY> |
| 81 | +``` |
| 82 | +
|
| 83 | +**1Password** |
| 84 | +```yaml |
| 85 | +- name: my-1password |
| 86 | + onePassword: |
| 87 | + serviceAccountToken: <TOKEN> |
| 88 | + integrationName: my-ess # optional |
| 89 | + integrationVersion: 1.0.0 # optional |
| 90 | +``` |
| 91 | +
|
| 92 | +**1Password Connect** |
| 93 | +```yaml |
| 94 | +- name: my-1password-connect |
| 95 | + onePasswordConnect: |
| 96 | + serverURL: https://my-connect-server.example.com # required |
| 97 | + token: <TOKEN> # required |
| 98 | +``` |
| 99 | +
|
| 100 | +**Infisical** |
| 101 | +```yaml |
| 102 | +- name: my-infisical |
| 103 | + infisical: |
| 104 | + clientId: <CLIENT_ID> # required — from an Infisical machine identity |
| 105 | + clientSecret: <CLIENT_SECRET> # required |
| 106 | + projectId: <PROJECT_ID> # required |
| 107 | +``` |
| 108 | +
|
| 109 | +--- |
| 110 | +
|
| 111 | +#### `essConfig.secrets` |
| 112 | + |
| 113 | +Each secret entry syncs one value (or a set of values) from a provider into a Control Plane secret. |
| 114 | + |
| 115 | +| Field | Description | |
| 116 | +|---|---| |
| 117 | +| `name` | Name of the Control Plane secret to create or update. | |
| 118 | +| `provider` | Must match a provider `name` defined above. | |
| 119 | +| `syncInterval` | Optional. Overrides the provider-level and global default for this specific secret. | |
| 120 | + |
| 121 | +Each secret must use exactly one of the following sync types: |
| 122 | + |
| 123 | +--- |
| 124 | + |
| 125 | +##### `opaque` — Single value (stored as a Control Plane `opaque` secret) |
| 126 | + |
| 127 | +Shorthand (path only, no fallback): |
| 128 | +```yaml |
| 129 | +- name: my-secret |
| 130 | + provider: my-vault |
| 131 | + opaque: /v1/secret/data/myapp |
| 132 | +``` |
| 133 | + |
| 134 | +With options: |
| 135 | +```yaml |
| 136 | +- name: my-secret |
| 137 | + provider: my-vault |
| 138 | + opaque: |
| 139 | + path: /v1/secret/data/myapp # path to fetch |
| 140 | + parse: data.password # optional — extract a key from a JSON/YAML response |
| 141 | + default: fallback-value # optional — used if fetch fails |
| 142 | + encoding: base64 # optional — base64-decode the fetched value |
| 143 | +``` |
| 144 | + |
| 145 | +> **Note:** If you use the shorthand form (`opaque: /some/path`) with no `default`, a fetch failure causes the sync to fail with no fallback. |
| 146 | + |
| 147 | +--- |
| 148 | + |
| 149 | +##### `dictionary` — Multiple values (stored as a Control Plane `dictionary` secret) |
| 150 | + |
| 151 | +Each key in the dictionary is fetched independently: |
| 152 | +```yaml |
| 153 | +- name: my-secret |
| 154 | + provider: my-vault |
| 155 | + dictionary: |
| 156 | + PORT: |
| 157 | + path: /v1/secret/data/app |
| 158 | + parse: data.port |
| 159 | + default: 5432 |
| 160 | + PASSWORD: |
| 161 | + path: /v1/secret/data/app |
| 162 | + parse: data.password |
| 163 | + USERNAME: |
| 164 | + path: /v1/secret/data/app |
| 165 | + parse: data.username |
| 166 | + default: "no username" |
| 167 | +``` |
| 168 | + |
| 169 | +Each key supports `path`, `parse`, `default`, and `encoding` — the same options as `opaque`. A failure on one key does not block others. |
| 170 | + |
| 171 | +--- |
| 172 | + |
| 173 | +##### `dictionaryFromProject` — Sync an entire project (Doppler or GCP Secret Manager) |
| 174 | + |
| 175 | +Syncs all secrets from a provider project in one operation, stored as a Control Plane `dictionary` secret. The expected shape depends on the provider. |
| 176 | + |
| 177 | +**Doppler** — specify a `project/config` path: |
| 178 | +```yaml |
| 179 | +- name: my-doppler-config |
| 180 | + provider: my-doppler |
| 181 | + dictionaryFromProject: |
| 182 | + path: my-project/dev # format: "project/config" — exactly two segments |
| 183 | +``` |
| 184 | + |
| 185 | +**GCP Secret Manager** — set to `true` to pull every accessible secret from the project configured on the provider: |
| 186 | +```yaml |
| 187 | +- name: my-gcp-config |
| 188 | + provider: my-gcp |
| 189 | + dictionaryFromProject: true |
| 190 | +``` |
| 191 | + |
| 192 | +Each fetched secret's latest version becomes one key in the resulting dictionary. Secrets with no accessible latest version (no versions, disabled, or destroyed) are skipped. |
| 193 | + |
| 194 | +> **Note:** `dictionaryFromProject` is only valid with the Doppler or GCP Secret Manager providers. Doppler requires the `{ path: ... }` object form; GCP requires the `true` form. Mixing them (or using either with another provider) causes ESS to exit at startup. |
| 195 | + |
| 196 | +--- |
| 197 | + |
| 198 | +#### Doppler Path Formats |
| 199 | + |
| 200 | +| Sync type | Path format | Example | |
| 201 | +|---|---|---| |
| 202 | +| `opaque` or `dictionary` key | `project/config/SECRET_NAME` | `my-app/production/DATABASE_URL` | |
| 203 | +| `dictionaryFromProject` | `project/config` | `my-app/production` | |
| 204 | + |
| 205 | +--- |
| 206 | + |
| 207 | +#### Infisical Path Formats |
| 208 | + |
| 209 | +The Infisical project is set on the provider (`infisical.projectId`). Secret paths are scoped to an environment within that project. |
| 210 | + |
| 211 | +| Sync type | Path format | Example | |
| 212 | +|---|---|---| |
| 213 | +| `opaque` or `dictionary` key | `<environmentID>/<secret>` | `dev/DATABASE_URL` | |
| 214 | + |
| 215 | +--- |
| 216 | + |
| 217 | +#### Sync Interval Format |
| 218 | + |
| 219 | +Intervals use the format `<hours>h<minutes>m<seconds>s`. All parts are optional but at least one is required. |
| 220 | + |
| 221 | +Examples: `10s`, `5m`, `1h`, `1h30m`, `1h30m10s` |
| 222 | + |
| 223 | +Priority (highest wins): |
| 224 | +1. Secret-level `syncInterval` |
| 225 | +2. Provider-level `syncInterval` |
| 226 | +3. Global default (`300s`) |
| 227 | + |
| 228 | +--- |
| 229 | + |
| 230 | +### Important Notes |
| 231 | + |
| 232 | +- **Conflict protection:** If a Control Plane secret already exists and is managed by a different ESS instance, the sync for that secret will fail. Two ESS instances cannot manage the same secret. |
| 233 | +- **Secret type changes:** Changing a secret from `opaque` to `dictionary` (or vice versa) causes ESS to delete the existing secret and recreate it. There is a brief window where the secret does not exist. |
| 234 | +- **Cleanup:** ESS runs an hourly job that deletes Control Plane secrets it owns but that no longer appear in `sync.yaml`. Removing a secret from the config will eventually result in its deletion from Control Plane. |
| 235 | +- **Doppler `parse`:** The `parse` field only works when the Doppler secret's value is JSON or YAML. Using `parse` on a plain string secret throws an error. |
| 236 | +- **`sync.yaml` hot reload:** ESS watches its config file and automatically restarts when changes are detected (every ~5 seconds). No workload restart is needed after updating the config secret. |
| 237 | + |
| 238 | +### Resources |
| 239 | + |
| 240 | +- [ESS Documentation](https://docs.controlplane.com/template-catalog/templates/external-secret-syncer) |
| 241 | +- [Image Source Code](https://github.com/controlplane-com/external-secret-syncer) |
0 commit comments