Providers
A construct says a thing exists: new ObjectStorage('Uploads') is a bucket. What backs it on a deployed stage is the stage's to say, under deploy.<kind>.<stage> — the key is the construct's manifest kind. A stage that names a provider has its deploy create the backing resource in the stage's own account and write its connection value into the stage's secrets, where the rest of the deploy reads it as if you had set it by hand.
// gkm.config.ts
deploy: {
objects: {
production: { provider: 's3', region: 'eu-west-1' },
staging: 'external', // the default: the keys are yours to set
scratch: false, // no object storage on this stage at all
},
}gkm deploy --stage production --dry-run # what would be created or changed
gkm deploy --stage production # create it, fix drift, write the keys, deploygkm compose --stage production is the same deploy, and runs the providers the same way.
The kinds are objects — what ObjectStorage and FileServer produce — email, what Email produces, and inbox (see Receiving email). cache takes the same deploy.<kind>.<stage> shape when its provider arrives.
What a provider is
| Part | What it is |
|---|---|
| provisioning credentials | What creates the resource — the stage account's admin credentials. Read only by the deploy, never given to an app. |
ensure() | Run by every deploy, before its checks. Idempotent find-or-create. Tries a good, deterministic name; records what it made in the stage's state; puts back what drifted. Never deletes. |
| runtime credentials | The keys it writes into the stage's secrets for the apps: for s3, the bucket's URL with a key scoped to it. |
verify() | The cheap check every deploy runs: for s3, HeadBucket with the key in the stage's secrets. |
deploy.objects.<stage>
| Value | Meaning |
|---|---|
'external' (or nothing) | Today's behaviour. Each bucket's <ID>_CONNECTION_STRING and each file server's <ID>_URL are set in the stage's secrets by you — S3, R2, any S3-compatible store. A missing key fails validate with ExternalServicesNotConfigured. |
{ provider: 's3', region?, versioning?, cdn? } | Every deploy of the stage creates and maintains the buckets in the stage's AWS account and writes their keys. |
false | The stage has no object storage. A deploy of a workspace that declares a bucket is refused with StageProviderDisabled. |
Anything else — 'minio' included — fails to load with UnknownStageProvider, which lists what is accepted. The local stage ignores deploy.objects: gkm dev and gkm compose on it always run MinIO.
region— where the buckets are created. Defaults to the secrets store's region (secrets.store.region), thenAWS_REGION.versioning— keep previous versions of every object. Defaults to the construct's ownversioned.cdn— serve each file server through CloudFront on its own name (see below). On by default;falseserves from the bucket's own endpoint.
objects: s3
Per bucket construct, on the stage:
The bucket. Named <namespace>-<project>-<stage>-<id> (the project alone when deploy.namespace is unset): lowercase, no dots, at most 56 characters — the longest part is shortened first — so a suffix still fits S3's 63.
CreateBucketin the configured region (noLocationConstraintinus-east-1).BucketAlreadyOwnedByYou— the account already holds it: reused, unless itsgkm:projecttag names another project (BucketOwnedByAnotherProject).BucketAlreadyExists— another account holds the name: a random six character[a-z0-9]suffix is appended and the create retried, up to five times, thenBucketNameUnavailablelists every name tried.- The final name is recorded in the stage's state and reused forever — a later run never regenerates it, and a recorded bucket that has disappeared is created again under the same name.
Its settings, checked on every run and put back when they drifted:
- Block Public Access, all four flags on;
- default encryption, SSE-S3;
- a bucket policy denying every request not made over TLS (
aws:SecureTransportfalse); - versioning, when asked for (turned off, it is suspended, never stripped);
- one lifecycle rule per prefix the construct's
expirenames, idgkm-expire-<prefix>-<hash>, merged into the bucket's lifecycle: every rule not startinggkm-is kept as it is, and agkm-rule nothing declares any more is removed; - CORS allowing
GET,HEAD,POSTandPUTfrom the stage's sites that call an API using the bucket — read off the graph the way an API's own CORS origins are — exposingETag. A site with no address on the stage (nodomains.<stage>) is named and left out; - tags
gkm:project,gkm:stage,gkm:construct.
The IAM user. gkm-<project>-<stage>-<id> (64 characters at most), under the path /gkm/, tagged with the deploy identity — so gkm finds its own users and never touches anyone else's (IamUserNotOwned). Its one inline policy, gkm-bucket, covers that bucket only:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "GkmObjects",
"Effect": "Allow",
"Action": ["s3:GetObject", "s3:PutObject", "s3:DeleteObject",
"s3:AbortMultipartUpload", "s3:ListMultipartUploadParts"],
"Resource": "arn:aws:s3:::<bucket>/*"
},
{
"Sid": "GkmBucket",
"Effect": "Allow",
"Action": ["s3:ListBucket", "s3:GetBucketLocation",
"s3:ListBucketMultipartUploads"],
"Resource": "arn:aws:s3:::<bucket>"
}
]
}s3:GetObjectVersion is added when the bucket keeps versions.
The key. Created once and written into the stage's secrets as the bucket's connection — UPLOADS_CONNECTION_STRING=s3://KEY:SECRET@<bucket>?region=<region>, percent-encoded. The key id is recorded in the state; the secret is never printed.
A FileServer, behind a CDN. By default each file server over the bucket is served through CloudFront on its own name, <fileserver>.<domain> — https://uploads.shop.example.com for new FileServer('Uploads') — and that is its URL (UPLOADS_URL), what uploads.url('brand/logo.png') appends a key to. Per file server, on the stage:
- A certificate for that name, from ACM in
us-east-1(the one region CloudFront reads certificates from), validated by a CNAME the deploy writes through the root domain's DNS provider indns— and keeps, because ACM renews against the same record. The deploy waits for it to be issued, up to 15 minutes. A domain no provider indnswrites (or amanualone) prints the record and stops withCdnCertificatePending; create it and deploy again — everything up to the certificate is recorded, and the run carries on from there. - An origin access control, which signs CloudFront's reads of the bucket.
- Response headers policies: CORS
GET/HEADfor the stage's sites (the same origins as the bucket's CORS rule), and, on the paths the file server callsimmutable,Cache-Control: public, max-age=31536000, immutableover whatever the object was stored with. - A distribution on the name: HTTPS only, compressed, cached with AWS's managed
CachingOptimizedpolicy — keyed on the path alone, so no query string or cookie fragments the cache. - A CNAME from the name to the distribution, through the same provider.
The bucket stays private. Block Public Access keeps all four flags on, and the bucket policy grants s3:GetObject on exactly the open prefixes to CloudFront alone, for exactly the stage's distributions:
{
"Sid": "GkmCdnRead",
"Effect": "Allow",
"Principal": { "Service": "cloudfront.amazonaws.com" },
"Action": "s3:GetObject",
"Resource": ["arn:aws:s3:::<bucket>/brand/*"],
"Condition": { "StringEquals": { "AWS:SourceArn": ["<distribution ARN>"] } }
}A key outside the open paths is refused to CloudFront too, so the edge answers it 403. A signed read is unchanged: signedUrl() is an S3 presign at the bucket's own host.
export const uploads = new FileServer('Uploads', {
open: ['brand/**'],
// Keyed by content hash: never rewritten, so clients keep them forever.
immutable: ['brand/logos/*'],
});immutable is a subset of open (ImmutableNotOpen otherwise): only say it of paths whose keys are written once, because a client holding an immutable copy never asks again.
A CDN is a name on the stage's domain, so a stage with file servers and no domains.<stage> fails with FileServerCdnNeedsDomain. Every resource is recorded in the stage's state and reused, found again by its name when a run died before recording it, and put back when it drifted — while what the provider does not own (a price class, logging, a WAF set by hand) is kept. The deploy role (gkm deploy:github) is given the ACM and CloudFront actions this needs, none of which delete.
Without a CDN. deploy.objects.<stage>.cdn: false serves each file server from the bucket's regional endpoint, https://<bucket>.s3.<region>.amazonaws.com. The open paths are then public on exactly those prefixes — s3:GetObject for Principal: * on arn:aws:s3:::<bucket>/brand/* — and Block Public Access is relaxed only as far as that needs: BlockPublicPolicy and RestrictPublicBuckets off, ACLs still blocked. A bucket with no open paths keeps all four flags on. Either way, a URL you set yourself is kept.
A single * in an S3 policy or a CloudFront path pattern crosses /, so avatars/*.png is wider there than in the construct's own check, which stops at a segment. Prefer ** where crossing segments is what you meant.
deploy.email.<stage>
// gkm.config.ts
deploy: {
email: {
production: { provider: 'ses', region: 'eu-west-1' },
staging: 'external',
},
},| Value | Meaning |
|---|---|
'external' (or nothing) | Each Email construct's <ID>_URL and <ID>_FROM are set in the stage's secrets by you — any SMTP server. |
{ provider: 'ses', region?, from? } | Every deploy of the stage creates what each Email sends with in the stage's AWS account, and writes its keys. |
false | The stage sends no mail. A deploy of a workspace that declares an Email is refused with StageProviderDisabled. |
region— where SES sends from: the identity, and the SMTP endpoint the URL names. Defaults to the secrets store's region, thenAWS_REGION.from— the address mail is sent from. Its domain is the identity the deploy verifies. Defaults to an address you set in the stage's secrets, then the construct's local part at the stage's domain —new Email('Mail', { templates, from: 'info' })sends asinfo@<stage domain>, and withoutfromasnoreply@<stage domain>; a stage with no domain fails withEmailSenderUnknown. An address gkm wrote is not one you set: it follows the stage's domain and the construct'sfrom, so after a change of either mail is sent as the new one.
email: ses
Per Email construct, on the stage:
The sender's domain as an SES identity, verified by Easy DKIM: its three CNAMEs go through the stage's dns config, as a file server's records do. An identity whose DKIM is already verified is left as it is, with signing turned back on if it was turned off. Any other is adopted and given DKIM: one made by another stack and never finished, or a domain verified only by its older TXT record, which would otherwise send unsigned. SES hands back the same tokens, and makes no second identity. An inbox on the same domain uses the same identity, since SES has one per domain. Neither provider deletes it.
An IAM user, gkm-<project>-<stage>-<id> under /gkm/, tagged like a bucket's. Its one inline policy, gkm-email, allows ses:SendRawEmail on that identity and nothing else.
Its access key, written as <ID>_URL:
smtp://<key id>:<smtp password>@email-smtp.<region>.amazonaws.com:587The SMTP password is derived from the key's secret and the region, as SES documents it, so it works in that region only. A URL on another region's endpoint is replaced with a new key.
<ID>_FROM: from, or the address described above. gkm records the address it writes in the stage's deploy state, which is how a later deploy tells it from one you set by hand.
Existing credentials win. A <ID>_URL the stage's secrets already hold, and the provider did not write, is kept: the construct sends with it, and nothing is created for it. Moving a stack that already sends through SES onto compose stays a gkm secrets:import. Delete the key from the stage's secrets to have the deploy create its own.
The sandbox. A new SES account delivers only to verified addresses. When the account's daily quota is the sandbox's (200), the deploy says so, rather than leaving the first send to fail. Request production access in the SES console.
--rotate-keys issues each user a successor key, and with it a new password, as it does for a bucket's (see Rotating a key).
Every deploy checks each construct's <ID>_URL and <ID>_FROM are in the stage's secrets, and stops with EmailNotProvisioned if not.
Receiving is not this: that is deploy.inbox.
Credentials
The deploy provisions in the stage's AWS account with the credentials of the machine it runs on, found the way the SSM and Secrets Manager stores find them:
AWS_PROFILE, elseAWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY(AWS_SESSION_TOKEN) — in CI, whataws-actions/configure-aws-credentialsexports;- the SDK's default chain —
~/.aws's default profile, SSO, a role.
None of them: the deploy stops before anything is created with ProviderCredentialsMissing, naming the environment it reads. In CI it is the stage environment's role (AWS_ROLE_ARN, from gkm deploy:github, which grants exactly what the stage's buckets and users need), assumed before the step that runs the deploy. To set the keys by hand instead, set deploy.objects.<stage>: 'external'.
The providers run first, then the deploy's one readiness check, so ExternalServicesNotConfigured never reports a key the deploy creates. A build (gkm compose --build --push) runs neither: it needs none of the keys.
In the compose workflow the whole deploy runs on the runner, with the stage's role, and drives the server's Docker over SSH: the stage account's credentials never reach the server.
The apps never see the provisioning credentials: only the bucket's own key, in its URL.
--dry-run
gkm deploy --stage production --dry-runReads the account (with the provisioning credentials) and the stage's state, and prints each change it would make:
☁️ objects: s3 (dry run)
would create in eu-west-1 — bucket acme-shop-production-uploads
would encrypt by default (SSE-S3) — bucket acme-shop-production-uploads
would create under /gkm/ — IAM user gkm-acme-shop-production-uploads
would create an access key — IAM user gkm-acme-shop-production-uploads
…Nothing is created, and nothing is written to the stage's secrets or state. No secrets are generated for the stage either. A run that finds nothing to change prints "up to date".
Rotating a key
gkm deploy --stage production --rotate-keys # a second key; this deploy releases on it
gkm deploy --stage production # the old key is deleted--rotate-keyscreates a second key for each user, writes it into the stage's secrets in place of the first, and records the old one in the state. The same deploy then releases the apps on the new key. Both keys work until the old one is deleted.- The next deploy deletes the old key.
gkm deploy --stage <stage> --retire-old-keysdeletes it at once.
gkm compose --stage <stage> takes the same two flags.
Rotating again while an old key is still active fails with RotationInProgress. IAM allows two keys per user; a user that already has two fails with AccessKeyLimit, naming them.
If the stage's secrets lose the key (a store emptied by hand), the next run issues a new one the same way — a secret cannot be read back from IAM.
Never deletes
ensure() creates and repairs; it never removes a bucket, an object, a user or a policy, and a construct you remove from the code leaves its bucket where it is. The one deletion is the old access key a --rotate-keys replaced, after the deploy that stopped using it, or when you pass --retire-old-keys.
A deploy never deletes. Deleting a whole stage is a separate command that asks first: gkm compose --stage <stage> --destroy. It deletes only what it can prove gkm made for that stage. See Destroying a stage.
DNS records
The deploy also points a compose stage's public hosts at its server, through the DNS provider each root domain names in dns:
// gkm.config.ts
domains: { production: 'shop.example.com' },
dns: { 'example.com': { provider: 'godaddy' } },
deploy: { default: 'compose' },gkm secrets:set GKM_SERVER_IPV4 '203.0.113.10' --stage production
gkm deploy --stage production --dry-run # the exact records, and what changes
gkm deploy --stage production # write them, then deployIt is a provider like the others in every way that matters:
- Provisioning credentials come from the machine running the deploy — the DNS provider's token (
GODADDY_API_TOKENorgkm login --provider godaddy,HOSTINGER_API_TOKENorgkm login --provider hostinger), the AWS SDK chain for Route53. With a token provider and no token, the deploy fails at its start withDnsCredentialMissing, naming the key. In CI the token is a secret on the stage's environment, read by the runner's deploy step, so the server never holds it. - Idempotent. A record that already has the right value is left alone; one with another value is replaced, printing
old → new. Only the A, AAAA and CNAME records of the stage's own hosts are ever written — one per host, never a wildcard (DnsWildcardRefused) — and none is ever deleted. A new app's host gets its record from the next deploy. --dry-runprints each record — name, type, value, TTL — and whether it would be created, updated or left alone, and writes nothing.- Confirmed by reading the records back from the provider (
DnsRecordsNotConfirmedif they are not there), before the stack starts and asks for certificates — see the compose guide. --skip-dnsneither writes the records nor checks them.
The server's address is the stage's own secret, never config: GKM_SERVER_IPV4 (required of a compose stage with a domain) and GKM_SERVER_IPV6 (optional: AAAA records). Neither is ever handed to an app. provider: 'manual' prints the records and writes nothing.
Deploys
Every deploy of a stage on a provider — gkm deploy through compose or Dokploy, and gkm compose — runs verify() in its readiness check, before the target is asked anything: HeadBucket with the app's key from the stage's secrets. A bucket that is gone, or a key that is refused, stops the deploy with ProvisionedBucketUnreachable, saying to deploy with the stage account's credentials, which creates or repairs it.
Providers run at the start of every deploy, whatever the target: a Dokploy deploy creates a stage's buckets the same way.
Each deploy runs the providers' ensure(), the DNS records, then the checks and the release; --skip-dns leaves the records out.
--allow-dev-services never stands MinIO in for a bucket a provider backs: the provider accounts for it.
Why the deploy
A deployed stage's resources are created by its deploy, every run, so a stage is never one step behind its code: a new bucket or a new app's host exists by the time the release that needs it starts. gkm setup is a one-time local step — the local stage's secrets and containers on this machine — and gkm setup --stage <deployed> fails with SetupIsLocal, pointing at gkm deploy --stage <stage>.