Simple Container now deploys to Yandex Cloud — and we had to bridge the provider ourselves

sc now ships Yandex Cloud as a deployment target. Same stack files, same sc provision / sc deploy, a different cloud underneath:
| Purpose | Type string |
|---|---|
| Credentials | yc-service-account |
| Pulumi state backend | yc-object-storage |
| Secrets provider | yc-kms |
| Runtime secrets | yc-lockbox |
| Single-image template | yc-serverless-container |
| Object storage resource | yc-bucket |
| DNS registrar | yc-dns |
Nothing about that list is surprising, which is the point — a Serverless Container is the Lambda-shaped unit, Object Storage is the bucket, Lockbox is the runtime secret store, and they slot into the same five registration calls every other SC provider uses. The interesting part of this work was not the mapping. It was getting a working Pulumi provider to exist at all.
The provider we had to build
Pulumi’s ergonomic path for a cloud it does not cover natively is one command:
pulumi package add terraform-provider yandex-cloud/yandex
It fetches the Terraform provider through a registry, at both SDK-generation time and plugin-download time. Here is what that does from inside Russia:
$ pulumi package gen-sdk terraform-provider yandex-cloud/yandex --language go
error: parameterize: could not connect to registry.opentofu.org: 403 Forbidden
$ curl https://registry.terraform.io/...
"Content not available in your region | HashiCorp"
$ curl https://github.com/yandex-cloud/terraform-provider-yandex
200
Both Terraform registries are geo-blocked from the one country this provider exists to serve. GitHub is not. So the dynamic bridge was out, and we built a static one instead: simple-container-com/pulumi-yandex, our own repo, the Terraform provider as an ordinary Go module dependency, the plugin binary published to GitHub Releases. A deploy touches api.github.com and the Go module proxy, and nothing else.
That is not a hypothetical concern. SC force-overrides PULUMI_HOME per project and stack, so nothing is cached between stacks and every deploy re-downloads the plugin. A provider that can only be fetched from a blocked registry is a provider that works on your laptop and nowhere your CI actually runs.
The half of the provider that is easy to miss
terraform-provider-yandex is a muxed server: an SDKv2 provider and a plugin-framework provider, served together. The community fork we started from bridged only the SDKv2 half.
This is genuinely easy to under-read. The framework provider’s Resources() opens with a hand-written slice that contains none of the interesting resources, and it is tempting to conclude the framework half does not matter. But the function ends:
| |
— a generated registry, and everything we actually needed is in that tail: yandex_iam_service_account, yandex_container_registry, yandex_kms_symmetric_key, yandex_resourcemanager_folder_iam_member, yandex_serverless_container_iam_binding.
Without those there is no service account to run a container as, no registry to push its image to, and no way to grant it anything. That generated registry is not new, so no version bump would ever have fixed it — muxing was mandatory rather than an optimisation. Bridging both halves took the provider from 88 resources to 268.
If you are doing this yourself: both entrypoints need pkg/pf/tfbridge’s MainWithMuxer, not the plain tfbridge.Main. The plain one compiles cleanly and generates a schema that looks complete, then panics on the engine’s first runtime call. Leaving tfgen on the plain Main instead fails at provider startup with Missing precomputed mapping. Did you run make tfgen?, because it is pf/tfgen.MainWithMuxer that writes the mux dispatch table into bridge-metadata.json. Neither failure is caught by go build or by tfgen. Only a real pulumi run finds them.
What it looks like in your stack
Two placements are worth stating explicitly, because both are somewhere other than the obvious guess.
There is no cloud: section. Credentials are an auth: entry in secrets.yaml, referenced from server.yaml as ${auth:yc} — exactly like AWS and GCP:
| |
folderId is a field of the auth config, not a free-floating top-level key. ${auth:yc.projectId} resolves to it — in YC everything is folder-scoped, and the folder is what maps onto an AWS account for SC’s purposes.
Schedules live in the client stack, not the parent. A timer is per-service, so it goes in client.yaml under cloudExtras, next to where an AWS service would put lambdaSchedules:
| |
What the live smoke proved, and the three bugs it found
We ran the whole thing against a real folder from a throwaway stack: sc provision, then sc deploy, 8 resources in 23 seconds, a container answering on <id>.containers.yandexcloud.net.
What that retired, in order of how much reading it replaced:
- The bridged plugin runs inside SC’s inline Automation API — resolved from GitHub, on a network where both Terraform registries are blocked. This was the one thing no amount of source reading could settle.
- Pulumi state, history and backups landed in YC Object Storage.
- All ten
S3_*variables reached the container as Lockbox references, not inlined values, and a/selftestendpoint signed a real SigV4 request with them — so the reference genuinely resolves to plaintext inside the runtime. BUILD_VERSIONwas SC’s injected CalVer, not the Dockerfile’s0.0.0default.- Timer triggers fired six consecutive minutes with six distinct event ids and no duplicates.
And three bugs that only a live run could have produced.
A bucket declared the way every other SC resource is declared would not provision. credentials: "${auth:yc}" alone failed with folderId must be set, because ${auth:...} resolution fills the opaque credentials blob and never the sibling fields — each resource has to rehydrate explicitly. The provider and the container did; the bucket did not.
Yandex Container Registry rejected the name shape of every SC image. Every push returned a bare 400 Bad Request with no hint of what it objected to. It was not auth and it was not SC’s build — a plain docker push of the same image failed identically. YCR enforces the legacy Docker repository grammar, [a-z0-9]+(?:[._-][a-z0-9]+)*: at most one separator between components. Modern Docker clients happily accept -- and __, so the client forms the request and the registry answers 400. We probed it systematically: -- and __ fail, -, _ and . succeed. The registry’s documentation does not mention this.
That is not an odd-name edge case. SC’s image name is the stack name, and a client stack is always <stack>--<env> — so without a fix, no SC service could push an image to YC at all. Runs of separators now collapse to their first character, and the provisioner logs when it rewrites.
retry_interval is a count of seconds, not a duration. retryInterval: 10s reached the provider as the string "10s", and the field — though typed as a string — is parsed with strconv.ParseInt. It fails at apply time, after the image has already been built and pushed, so each attempt costs a full deploy round-trip. The client-facing spelling keeps its unit; only the resource input changed.
Two CLI facts worth knowing while you are in there: sc provision has no -e flag in this build and provisions every environment the stack declares, and the profile file must be .sc/cfg.<profile>.yaml — a bare .sc/cfg.yaml is silently ignored.
The honest state
The provider is released and smoke-proven end to end on a real folder. What has not happened yet is a production service of ours running on it — that work is next, and it will be the first exercise of ${secret:...} resolving into Lockbox from a parent-stack registry, which the smoke never used.
One number to plan around if you are moving a service that talks to a managed database outside Russia. From YC ru-central1 to a MongoDB Atlas cluster in eu-central-1 we measured a warm-socket round-trip of p50 ≈ 45 ms and a cold TCP+TLS setup of ≈ 146 ms. Reachability was never the question; latency is the actual trade-off, and it is a per-service judgement rather than a yes or no.
We are also not going to tell you what any of this means legally. We will tell you which region a bucket is in. Which regulation that satisfies is a question for your counsel, not for a deploy tool’s release notes.
If you want the product side of this — running agents, models and customer-facing sites in the Russian segment rather than the provider internals — that is the companion post on the Forge blog (in Russian).
Read more
- Multi-cloud — AWS, GCP, Yandex Cloud, Kubernetes
simple-container-com/pulumi-yandex— the bridge, Apache-2.0