Guide: Configuring a Parent Stack for Yandex Cloud with Simple Container
This guide is for DevOps teams deploying services on Yandex Cloud (YC) with
Simple Container. The template it covers pushes a container image to Container
Registry, runs it as a Serverless Container, delivers secrets via Lockbox
references, provisions the state bucket and blobs on Object Storage, and (when
a .ru domain is declared) publishes the service through an API Gateway with a
CNAME in the Cloud DNS zone you own.
Prerequisites
- Simple Container is installed:
admin on that folder. Simple Container provisions Container Registry,
Serverless Containers, Lockbox, IAM bindings, DNS recordsets and API Gateways
under this identity.
- A service-account key (yc iam key create --service-account-id ... --output ...)
and a static access key pair for the S3-compatible API
(yc iam access-key create --service-account-id ...). The service-account key
authenticates every YC gRPC call; the static key pair signs the Object Storage
and Container Registry API calls that use the S3 wire protocol.
- A Pulumi state bucket created by hand — yc storage bucket create --name
<name>. The yc-object-storage state backend has no ProvisionFunc registered
and a self-provisioning state backend is chicken-and-egg anyway; SC references
the bucket with provision: false.
Setting Up Yandex Cloud Secrets
Step 1: Define secrets.yaml
Create the .sc/stacks/devops/secrets.yaml file to hold the YC credentials:
---
# File: "myproject/.sc/stacks/devops/secrets.yaml"
schemaVersion: 1.0
auth:
yc:
type: yc-service-account
cloudId: b1g...
folderId: b1g...
# The full contents of `yc iam key create --output` for the SA that will
# provision the stack (folder-admin).
serviceAccountKey: |
{
"id": "...",
"service_account_id": "...",
"created_at": "...",
"key_algorithm": "RSA_2048",
"public_key": "-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----\n",
"private_key": "PLEASE DO NOT REMOVE THIS LINE! Yandex.Cloud SA Key ID <...>\n-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----\n"
}
# The static access key pair (from `yc iam access-key create --output`)
# signs S3-compatible calls: Object Storage and Container Registry.
accessKey: "YCAJE..."
secretAccessKey: "..."
region: ru-central1
zone: ru-central1-a
values:
# Passphrase used by the `passphrase` secrets-provider below to encrypt the
# stack's Pulumi state. Any random string; treat it like a KMS key.
state-passphrase: "..."
What This Does
${auth:yc}interpolation resolves to the credentials blob above — every YC resource that needs to authenticate acceptscredentials: "${auth:yc}".- The blob is opaque. SC resolves
${auth:yc}into theCredentialsfield of a config struct and never fans it out into sibling fields such asfolderId. Any custom resource type must callapi.ConvertAuth(cfg, &cfg.AccountConfig)itself or fail withfolderId must be set— the built-inyc-*types already do.
The
.sc/stacks/*/secrets.yamlfiles are gitignored; the encrypted.sc/secrets.yamlregistry is committed. Do not commit the plaintext.
Configuring Infrastructure Provisioning (server.yaml)
Step 2: Define server.yaml
---
# File: "myproject/.sc/stacks/devops/server.yaml"
schemaVersion: 1.0
# Provisioning state management
provisioner:
type: pulumi
config:
# Pulumi state in a YC Object Storage bucket you created by hand.
# `provision: false` is required — see Prerequisites.
state-storage:
type: yc-object-storage
config:
credentials: "${auth:yc}"
bucketName: myproject-sc-yc-state
provision: false
# `yc-kms` has no ProvisionFunc registered in the current build, so
# `passphrase` is the practical choice for the initial deploy.
secrets-provider:
type: passphrase
config:
passPhrase: "${secret:state-passphrase}"
# Deployment template — one YC Serverless Container per service.
templates:
yc-container:
type: yc-serverless-container
config:
credentials: "${auth:yc}"
# `registryId` and `serviceAccountId` are deliberately left unset:
# SC then creates both itself (one Container Registry per stack, one
# per-container service account with the default roles listed below).
resources:
# Multi-registrar DNS. Declaring `registrars:` (plural) rather than the
# legacy `registrar:` block accepts several DNS providers side-by-side;
# each service's `domain:` is routed to the entry whose zone is the
# longest suffix match, on a label boundary.
registrars:
yandex:
type: yc-dns
config:
credentials: "${auth:yc}"
# The YC *resource* name of the DNS zone, not the DNS name of the
# zone. `simple-forge.ru.` is served by a resource named
# `simple-forge-ru`. Set `zoneId:` directly to skip the lookup.
zoneName: simple-forge-ru
# The wildcard certificate covering this zone. Adopted, never
# issued — one managed cert per zone shares the single
# `_acme-challenge.<zone>` CNAME target; a second contends with
# its renewal and fails silently up to 90 days later.
certificateId: fpqu...
resources:
staging:
template: yc-container
resources:
blobs:
# A YC Object Storage bucket shared by all services deploying
# into this env under the template above. SC provisions it, its
# service account, its folder-role binding and a static S3 key
# pair — a `${resource:blobs.accessKey}` on a client stack
# references the key pair rather than embedding it.
type: yc-bucket
config:
credentials: "${auth:yc}"
name: myproject-blobs
# Optional. `true` lets `sc destroy` empty the bucket during
# teardown; without it a bucket with objects blocks the
# destroy.
forceDestroy: false
What This Does
- Provisions Pulumi state in YC Object Storage using the S3-compatible API.
The state URL SC emits is
s3://<bucket>?endpoint=https://storage.yandexcloud.net®ion=<region>&s3ForcePathStyle=true— those three parameters are allgocloudaccepts on that backend; any additional query parameter is rejected at login time. - Declares the
yc-containertemplate every service will point at. The provisioning code creates one Container Registry per stack (whose name is<image-name>-registry) and one service account per container (whose folder roles default tocontainer-registry.images.puller+lockbox.payloadViewer, and can be extended viacloudExtras.roles). - Wires the
yc-dnsregistrar for the shared zone. Records underNS,SOAand anything below_acme-challenge.are refused at conversion time — overwriting any of those breaks the zone or a renewal. - Declares one
yc-bucketresource per environment; the S3 credentials for that bucket are exported so any client stack listinguses: [blobs]picks them up via${resource:blobs.*}placeholders.
The edge for a .ru domain is an API Gateway, not a CDN
The yc-dns registrar publishes each service through an API Gateway
resource that terminates TLS from the adopted certificate and forwards the
target's own Host header to the container URL. The CDN alternative was
rejected before this design landed for two reasons: one CDN resource per
container is required (a wildcard CDN cannot fan out to multiple container
IDs — the hostname is the identity), and YC CDN keys the cache on URI
alone, so byte-identical responses on distinct hostnames leak across
services. API Gateway has neither issue and no per-resource flat fee.
Step 3: Provision the Parent Stack
sc provisionhas no-eflag in the current build — it provisions every environment the stack declares. Usesc deploy -e <env>for the per-env action.
What This Does
Reads server.yaml, resolves ${auth:yc} and ${secret:…} placeholders,
then hands the plan to Pulumi, which creates the Container Registry,
service accounts, folder-role bindings, buckets and DNS-facing resources
inside YC.
Deploying Services to Yandex Cloud
Step 1: Define client.yaml for a service
---
# File: "myproject/.sc/stacks/myservice/client.yaml"
schemaVersion: 1.0
stacks:
staging:
type: single-image
template: yc-container
parent: myproject/devops
config:
# Routed to the parent's `yc-dns` registrar by zone suffix. Building
# an API Gateway in front of the container is what makes TLS + a
# `Host`-preserving edge work on a `.ru` domain.
domain: myservice.example.ru
image:
dockerfile: ${git:root}/Dockerfile
# YC container memory is capped at the free-tier limit (128 MB) up
# to 2 GB paid, and must be a multiple of 128; SC rejects any other
# value rather than silently rounding.
maxMemory: 128
# Per YC quotas: 30 s free tier / up to 3600 s paid.
timeout: 30
uses:
- blobs # picks up ${resource:blobs.*} from the parent
env:
LOG_LEVEL: info
# Values here are placed in a per-container Lockbox secret, and the
# container revision receives Lockbox *references* rather than the
# inlined values.
secrets:
SOME_TOKEN: "${secret:MY_SERVICE_TOKEN}"
Advanced: YC CloudExtras
The cloudExtras block on client.yaml carries YC-specific extras — most
prominently schedules, which produce YC Timer Triggers pointing back at
the container.
stacks:
staging:
type: single-image
template: yc-container
parent: myproject/devops
config:
# ... basic configuration above
cloudExtras:
# Extra IAM roles granted to the container's service account on
# top of `container-registry.images.puller` and
# `lockbox.payloadViewer`.
roles:
- storage.viewer
- lockbox.payloadViewer
# Cron schedules — one YC Timer Trigger per entry, named
# <container>-<schedule.name>. The trigger POSTs a YC-shaped
# envelope to the container's ROOT path (`/`) and ignores the
# `path` in its own payload — go-aws-lambda-sdk >= 2026.9.1
# unwraps this envelope for you; a stdlib service can unwrap it
# by hand.
schedules:
- name: cleanup
# 6-field YC cron (min hour day-of-month month day-of-week
# year), interpreted in UTC. Exactly one of day-of-month /
# day-of-week must be `?`. A 5-field crontab line is
# rejected. A minute is the minimum granularity.
expression: "0 3 ? * * *"
# Sent as the `details.payload` inside the YC trigger
# envelope. Max 4096 characters. Not a URL query — YC does
# not parse this string.
request: '{"path":"/cleanup"}'
# 1..5. Default 0 (no retry). A failed invocation is retried
# this many times at `retryInterval` before being dropped
# (or DLQ'd, if configured).
retryAttempts: 2
# 10s..60s. Must carry a unit — a bare integer is rejected
# early with a fix-in-the-message error.
retryInterval: 10s
# Optional: send failed invocations to a Message Queue.
# deadLetterQueue:
# queueId: ${resource:dlq.id}
CloudExtras Field Reference
| Field | Type | Description | Example |
|---|---|---|---|
roles |
[]string |
Extra folder-role bindings for the container's SA | ["storage.viewer"] |
schedules |
[]object |
Timer Triggers, one per entry | See below |
Schedules Object Fields:
| Field | Type | Description | Required |
|---|---|---|---|
name |
string |
Unique per container; produces <container>-<name> (YC ≤ 63 chars) |
Yes |
expression |
string |
6-field YC cron, UTC, exactly one of DOM/DOW is ? |
Yes |
request |
string |
Payload string (≤ 4096 chars) delivered as details.payload |
Optional |
retryAttempts |
int |
1..5 | Optional |
retryInterval |
string |
10s..60s, must carry a unit | Optional |
deadLetterQueue.queueId |
string |
YC Message Queue id for failed messages | Optional |
Schedule Examples
# Every minute (useful for a smoke or a heartbeat).
- name: heartbeat
expression: "* * * * ? *"
request: '{"path":"/heartbeat"}'
# Daily at 03:00 UTC.
- name: nightly
expression: "0 3 ? * * *"
request: '{"path":"/cron/nightly"}'
# Every 15 minutes.
- name: sweep
expression: "0/15 * ? * * *"
request: '{"path":"/cron/sweep"}'
retryAttempts: 3
retryInterval: 30s
An AWS-style cron(...) wrapper is accepted and stripped, so a schedule
block can be shared byte-identical between an AWS and a YC client stack:
Schedule change → trigger REPLACE, not update
SC replaces the underlying Timer Trigger whenever its schedule shape
(cron expression, container ref or DLQ) changes, rather than issuing YC's
UpdateTrigger. This is deliberate: an in-place update via YC's API
leaves the trigger ACTIVE with the new spec but does not reliably re-arm
YC's scheduler when the next fire is more than a short window away. A
+- replace on the trigger in Pulumi's plan is expected on any cron
change and always lands the new spec on a fresh scheduler slot.
Step 2: Deploy the Service
SC builds the image, pushes it to the parent stack's Container Registry, creates the container's Lockbox secret + version, provisions the container revision that references them, wires the API Gateway custom domain to the adopted certificate, and lands a CNAME in the DNS zone.
Verifying the Deploy
# From outside YC:
curl -sS -o - https://myservice.example.ru/ # 200 + your service's response
curl -sS -o /dev/null -w "%{http_code}\n" -X POST \
https://myservice.example.ru/ # non-GET methods also forward
dig +short myservice.example.ru CNAME # <apigw-id>.<subdomain>.apigw.yandexcloud.net.
The container also carries a set of SIMPLE_CONTAINER_* env vars SC
injects on every deploy:
| Var | Value |
|---|---|
SIMPLE_CONTAINER_CLOUD |
yandex |
SIMPLE_CONTAINER_ENV |
your env name, e.g. staging |
SIMPLE_CONTAINER_RESOURCE_TYPE |
yc-serverless-container |
SIMPLE_CONTAINER_STACK |
<client-stack>--<env> |
SIMPLE_CONTAINER_VERSION |
CalVer YYYY.MM.DD-<sha> in CI, latest locally |
Tearing It Down
What sc destroy removes: the container, all revisions, its Lockbox
secret + version, its service account and folder-role bindings, the Timer
Triggers, the API Gateway, the DNS recordset that points at it, the
Container Registry and the bucket declared under the parent.
What is intentionally preserved:
- The DNS zone itself.
yc-dnslooks it up (data source) and never creates it — a second public zone for the same domain would be one nobody's nameservers point at. - The adopted certificate referenced by
certificateId. It is a plain string argument on the API Gateway, not a Pulumi resource, so destroy leaves it in place. - The Pulumi state bucket (
provision: false).
Known destroy hazard: a ContainerRegistry with images still pushed
into it refuses to delete (Registry ... is not empty, you must delete
all images first). The upstream yandex_container_registry TF resource
has no force_destroy knob. Empty the registry by hand before re-running
destroy:
for img in $(yc container image list --registry-id <id> --format json \
| jq -r '.[].id'); do
yc container image delete --id "$img"
done
sc destroy -s myservice -e staging
(A follow-up will add a pre-destroy hook to SC that empties the registry
via the YC containerregistry.v1 API before Pulumi tries to delete it.)
Lockbox secrets enter YC's usual PENDING_DELETE state on destroy —
this is a YC platform behaviour, not an SC bug, and there is no cost
implication for zero-entry secrets.
Summary
For a .ru service on YC, Simple Container:
- Bridges the Pulumi YC provider from
simple-container-com/pulumi-yandexso asc deployruns inline Automation-API against YC even from a network where the upstream Terraform provider registries are geo-blocked. - Provisions Container Registry, Serverless Containers, Lockbox and IAM bindings with the same client/parent shape as the AWS ECS Fargate and GCP Cloud Run templates.
- Publishes the service through an API Gateway backed by an adopted wildcard certificate, with a CNAME in the DNS zone SC looks up (never creates).
- Replaces the Timer Trigger on any schedule change, so a
sc deploythat changes a cron always lands on a fresh YC scheduler slot. - Cleanly tears down with
sc destroy— except when aContainerRegistrystill holds images (see hazard above).