Give a VM Storage Access and an API Key
This guide shows how a Virtual Machine consumes cloud object storage and an external SaaS secret (an OpenAI API key) declaratively — with no hand-attached service account, no gcloud secrets calls, and no keys baked into the boot script.
You express two links:
VM → ObjectStoragewith{ access }— the agent ensures the VM has an identity, grants that identity a bucket role scoped byaccess(least-privilege), and publishes the bucket URI.VM → Unmanaged(anAI.SaaS.Unmanagedcomponent whose secret references an environment secret) — the agent injects the key as a secret reference, never the raw value.
The VM reads both at runtime from /etc/fractal/linked.env.
A working single-VM Fractal (see the basic_gpu_inference sample). This guide adds a results bucket and an OpenAI service to it.
1. Store the OpenAI key as an environment secret
The raw key lives in the environment secret store, defined once. The blueprint references it by short name — the value never appears in the blueprint or the live system.
import {operationalEnvironment} from '@fractal_cloud/sdk/model';
const env = operationalEnvironment('dev')
.withSecret({
shortName: 'openai-api-key',
displayName: 'OpenAI API key',
value: process.env['OPENAI_API_KEY']!, // read once, at deploy time
});
// deploy the environment (see the Environment guide) so the secret exists
// before the live system references it.
2. Blueprint — add the bucket, the AI service, and the links
The architect authors structure once. Add an ObjectStorage and an Unmanaged component next to the VM, then link the VM to each. The blueprint names only abstract components — offers are chosen later.
import {
createFractal,
VirtualNetwork,
Subnet,
SecurityGroup,
VirtualMachine,
ObjectStorage,
Unmanaged,
type ObjectStorageLink,
type UnmanagedLink,
} from '@fractal_cloud/sdk/model';
export function authorFractal() {
return createFractal({
id: 'gpu-eval',
version: {major: 1, minor: 0, patch: 0},
boundedContextId: {ownerType: 'Personal', ownerId: process.env['OWNER_ID'] ?? '', name: 'wizard'},
blueprint: bp => {
const network = bp.add(VirtualNetwork({id: 'eval-net'}).withCidrBlock('10.0.0.0/16'));
const subnet = bp.add(Subnet({id: 'eval-subnet'}).withCidrBlock('10.0.1.0/24').dependsOn(network));
const sg = bp.add(SecurityGroup({id: 'eval-sg'}).dependsOn(network));
// The compute box that will read/write results and call OpenAI.
const vm = bp.add(VirtualMachine({id: 'eval-box'}).dependsOn(subnet));
// A results bucket and an external AI service (references the OpenAI key).
const bucket = bp.add(ObjectStorage({id: 'results-bucket'}));
const openai = bp.add(Unmanaged({id: 'openai'}));
// Membership + the two new access/inject links.
bp.link(vm, sg);
bp.link(vm, bucket, {access: 'read-write'} satisfies ObjectStorageLink);
bp.link(vm, openai, {envPrefix: 'OPENAI'} satisfies UnmanagedLink);
return {network, subnet, sg, vm, bucket, openai};
},
});
}
Nothing here is vendor-specific. access: 'read-write' requests both read and write on the bucket; use 'read' or 'write' for a narrower grant.
3. LiveSystem — select offers (GCP shown)
At selection time you pick one offer per component. The bucket resolves to a GCS bucket; the AI service resolves to the vendor-neutral AI.SaaS.Unmanaged offer, whose secret references the environment secret from step 1 via secretRef.
import {
GcpVpc, GcpSubnet, GcpFirewall, GcpVm, GcsBucket, UnmanagedAi, secretRef,
} from '@fractal_cloud/sdk/model';
const REGION = process.env['REGION'] ?? 'us-central1';
export function buildLiveSystem() {
return authorFractal()
.specialize()
.toLiveSystem({
name: 'gpu-eval',
environment: {ownerType: 'Personal', ownerId: process.env['OWNER_ID'] ?? '', name: 'dev'},
select: {
'eval-net': GcpVpc({region: REGION}),
'eval-subnet': GcpSubnet({region: REGION}),
'eval-sg': GcpFirewall({region: REGION}),
'eval-box': GcpVm({region: REGION, machineType: 'a2-highgpu-1g'}),
'results-bucket': GcsBucket({region: REGION}),
// References the env secret by short name — the raw key never travels.
openai: UnmanagedAi({secret: secretRef('openai-api-key')}),
},
});
}
To target another cloud, swap the offers — AwsS3 / AzureBlob for the bucket, Ec2Instance / AzureVm for the VM. The links and UnmanagedAi({secret: secretRef(...)}) are unchanged.
4. What the VM sees at runtime
The VM's reconcile waits for its injectable link targets (the bucket and the OpenAI service) to be Active before the instance is created. By the time the box boots, the agent has:
- created + attached an identity to the VM (GCP service account / AWS instance profile / Azure managed identity) — only if the VM had none;
- granted that identity the scoped bucket role (
read-write→ GCProles/storage.objectAdmin, AWSs3:GetObject+PutObject, AzureStorage Blob Data Contributor) plus read access to the OpenAI secret; - delivered the linked values at first boot via cloud-init, which writes
/etc/fractal/linked.envand a systemdDefaultEnvironmentdrop-in.
Because the linked values are known before the instance is created, they are present at boot — no polling, no post-boot fetch. You consume them two ways:
Systemd service — inherits the values automatically (the DefaultEnvironment drop-in), or reference the file explicitly:
[Service]
EnvironmentFile=/etc/fractal/linked.env
ExecStart=/usr/local/bin/run-eval
Shell / bootstrap script — source the file:
#!/usr/bin/env bash
set -euo pipefail
source /etc/fractal/linked.env
echo "results bucket: ${RESULTS_BUCKET_URI}" # e.g. gs://results-bucket-ab12
# Secrets arrive as a *_REF the runtime resolves; never the raw value.
OPENAI_API_KEY="$(gcloud secrets versions access latest --secret "${OPENAI_API_KEY_REF}")"
# The box authenticates to the bucket with its own identity — no key files.
gsutil cp ./eval-results.json "${RESULTS_BUCKET_URI}/"
Env-var names are namespaced by the target component id: results-bucket → RESULTS_BUCKET_URI, openai → OPENAI_API_KEY_REF (the envPrefix: 'OPENAI' override). Secrets are always a *_REF.
A VM linking to a bucket or an external service is gated on those targets being Active — the one deliberate exception to "links don't gate provisioning". Your own userData is preserved: the agent merges it with the Fractal cloud-init part as MIME multipart, so both run at first boot.
Why this over a hand-attached service account
- No cross-project SA problem. The identity is created and scoped per live-system; nothing is shared or hard-coded.
- Least-privilege by default. The grant is derived from
access— no broadcloud-platformscope unless you opt in. - No raw secrets on the box or in the blueprint. The key lives in the environment secret store; the live system carries only a
secretRef, and the VM resolves a reference at launch.
See the Component Links page for the full link contract, including environment-secret references in parameters and link settings.