Skip to main content

Your First Fractal Cloud Deployment with SDK

Target Audience: DevOps Engineers, Platform Architects, and Developers. Goal: Deploy a standard three-tier stack (network + managed cluster + PostgreSQL + web workload) with the Fractal Cloud TypeScript SDK, and understand why the code is split the way it is.

Just want something running? The IaC quick start is the short path — one component, seven steps. This tutorial is the full picture.


Part 1: Core Concepts & Architecture​

Fractal Cloud replaces imperative provisioning scripts with typed, composable definitions. Before the implementation, three distinctions matter.

The object model​

  • Component — an abstract capability contract (Storage.ObjectStorage, NetworkAndCompute.ContainerPlatform). Says what is needed, never how. Never provisioned directly.
  • Offer — a concrete, vendor-specific implementation of a Component (Gke, AwsS3, GcpPostgresDbms). The only level that maps to real infrastructure.
  • Fractal (blueprint) — a reusable, governed architecture pattern composed of Components and the links between them. References Components only, so it never names a vendor.
  • Live System — the running instance of a Fractal: one Offer selected per Component, deployed into an Environment.

The consequence worth internalizing: a Fractal is vendor-agnostic by construction. Add a new vendor to the catalogue tomorrow and every existing Fractal supports it, unchanged.

Operational boundaries​

  • Bounded Context — a logical container for ownership and governance, isolating your Fractals and Live Systems so policy is enforced consistently within one business domain. The control-plane API still calls this a Resource Group.
  • Environment — a segment of your IT landscape ("GCP Dev", "AWS Prod") that maps to a specific cloud target (a GCP project, an AWS account). Establishes the physical deployment boundary and hosts the Cloud Agent.
  • Cloud Agent — the reconciler. It runs inside your cloud account, calculates the diff between the declared state and actual cloud state, and closes it. The control plane holds no standing access to your cloud, and there is no state file: your cloud is the source of truth.

Two kinds of specialization​

This is the distinction the rest of the tutorial turns on.

Set byWhenChangeable by consumer
GuardrailThe architect, via .withXxx() on a ComponentDesign timeNo — throws before any network call
OperationThe consuming team, via the Fractal's typed interfaceInstantiation timeYes — it is theirs to decide

Guardrails are infrastructure parameters (CIDR blocks, ingress rules, node-pool topology, backup retention). Operations are application-level verbs (which image ships, how many replicas, which databases the app owns). Operations are not pass-through setters for infra knobs — if a consumer can set it, it was never governed.


Part 2: The Pilot Project Scenario​

We implement a three-tier architecture for a "Pilot Project" using the TypeScript SDK. Prefer a visual approach? The GUI tutorial builds the same shape on the Design Canvas.

The stack:

  1. Network — a VPC, a private subnet, and a security group with governed ingress rules.
  2. Compute — a managed Kubernetes cluster.
  3. Data — a managed PostgreSQL engine, with the application's databases beneath it.
  4. Workload — a web application running on the cluster, wired to the database.

We simulate the collaboration between three roles.

Project layout​

src/
environment.ts # Ops: the environment tree and its cloud agent
fractal.ts # Platform: the governed blueprint — authored once
deploy.ts # Developer: specialize, select offers, deploy
fatal.ts # shared: the one place a failure is reported
npm install @fractal_cloud/sdk

Everything imports from the locked model surface, @fractal_cloud/sdk/model.


Part 3: Implementation Workflow​

Role A: The Ops Engineer​

Responsibility: governance and boundary definition.

Ops declares the Environment: where resources are allowed to be provisioned, and which cloud identity does the provisioning. Environments come in two tiers, and the split is not ceremony — the management tier carries full cloud identity (organization + project) and owns the operational tiers beneath it, which declare only an account and inherit the rest.

Step 1: Declare the environment tree​

src/environment.ts
import {
ManagementEnvironment,
OperationalEnvironment,
} from '@fractal_cloud/sdk/model';

const OWNER_ID = process.env['OWNER_ID']!;

// Where Live Systems land. Declares a GCP project; inherits the organization
// identity from the management agent when the tree resolves.
const dev = OperationalEnvironment({
shortName: 'dev',
resourceGroups: [`Personal/${OWNER_ID}/pilot-rg`],
}).withGcpProject({
region: 'europe-west1',
projectId: process.env['GCP_OPERATIONAL_PROJECT_ID']!,
});

// Owns the cloud agent — full identity — and the operational envs beneath it.
export const management = ManagementEnvironment({
id: {type: 'Personal', ownerId: OWNER_ID, shortName: 'mgmt'},
resourceGroups: [`Personal/${OWNER_ID}/mgmt-rg`],
})
.withGcpCloudAgent({
region: 'europe-west1', // mandated for compliance
organizationId: process.env['GCP_ORGANIZATION_ID']!,
projectId: process.env['GCP_MANAGEMENT_PROJECT_ID']!,
})
.withOperationalEnvironments([dev]);

An operational environment declaring an account for a provider the management environment has no agent for is rejected, with the provider named — the inheritance is checked, not hoped for.

Step 2: Deploy it and initialize the Cloud Agent​

src/environment.ts
await cloud.environments.deploy(management, {
agentInit: 'wait',
providerCredentials: {
gcp: {
serviceAccountEmail: process.env['GCP_SA_EMAIL']!,
serviceAccountCredentials: process.env['GCP_SA_CREDENTIALS']!,
},
},
});

This creates or updates both environments, pushes their secrets and CI/CD profiles, then initializes the Cloud Agent in each. agentInit: 'wait' blocks until every initialization completes.

providerCredentials is read only from the call site — never from ambient environment variables. A deployment that needs to initialize a provider with no credentials supplied fails rather than silently picking up whatever happens to be in the shell, so what a deployment used is always visible in the code that ran it. Federated (OIDC) variants are accepted for AWS, Azure and GCP, so CI never needs a long-lived secret.

This step claims cloud accounts. Each tier initializes a real Cloud Agent, so two distinct, unclaimed projects are required, and the initialization principal needs provisioning rights that differ per scope. Missing grants do not fail early — the claim succeeds and the run fails later, partway through provisioning, looking like a credential problem. Read Advanced initialization for your provider first.

Ops runs this once per environment. Everything after it is repeatable without touching cloud credentials again.


Role B: The Platform Engineer​

Responsibility: standardization.

Platform authors the Fractal: the components, the structure between them, the guardrails that cannot be overridden, and the narrow interface consumers may use. Their job is to make the compliant path the easy one.

Step 3: Author the blueprint​

Note what is not here: no vendor, no offer, no region on a component. Those are Role C's, and the absence is what makes this file reusable.

src/fractal.ts
import {
createFractal,
VirtualNetwork,
Subnet,
SecurityGroup,
ContainerPlatform,
RelationalDbms,
RelationalDatabase,
Workload,
type RelationalDatabaseLink,
} from '@fractal_cloud/sdk/model';

const boundedContextId = {
ownerType: 'Personal',
ownerId: process.env['OWNER_ID']!,
name: 'pilot-project',
};

export function authorFractal() {
return createFractal({
id: 'fractal-pilot',
version: {major: 1, minor: 0, patch: 0},
description: 'Pilot project: network, managed cluster, PostgreSQL, web app.',
boundedContextId,
blueprint: bp => {
// ── Network topology — CIDR blocks are governed. ──
const network = bp.add(
VirtualNetwork({id: 'main-network', displayName: 'Main Network'})
.withCidrBlock('10.0.0.0/16'),
);
const subnet = bp.add(
Subnet({id: 'private-subnet', displayName: 'Private Subnet'})
.withCidrBlock('10.0.1.0/24')
.dependsOn(network),
);

// ── Security posture — HTTPS from anywhere to the web tier, nothing else. ──
const sg = bp.add(
SecurityGroup({id: 'app-sg', displayName: 'Application Security Group'})
.dependsOn(network)
.withIngressRules([
{fromPort: 443, toPort: 443, sourceCidr: '0.0.0.0/0'},
]),
);

// ── Managed cluster — capacity and autoscaling are infra decisions. ──
const cluster = bp.add(
ContainerPlatform({id: 'app-cluster', displayName: 'Application Cluster'})
.dependsOn(subnet)
.withNodePools([
{name: 'system', minNodeCount: 1, maxNodeCount: 3, autoscalingEnabled: true},
]),
);

// ── Database engine — HA, backups, capacity and engine version governed.
// The logical databases are declared by the app, via an operation. ──
const dbms = bp.add(
RelationalDbms({id: 'app-dbms', displayName: 'Application Database Engine'})
.withHighAvailability('zone-redundant')
.withBackupRetentionDays(30)
.withStorageGb(100)
.withEngineVersion('16'),
);

// ── Workload — runs on the cluster, placed in the subnet. The image and
// replica count are the app's, so they are NOT set here. ──
const web = bp.add(
Workload({id: 'web-workload', displayName: 'Web Workload'})
.dependsOn(cluster)
.dependsOn(subnet),
);

// ── Structure: links are architect-owned. ──
bp.link(web, sg); // membership in the app security group

return {network, subnet, sg, cluster, dbms, web};
},

// ── The interface: application-level verbs only. ──
operations: (s, {link}) => ({
/** The container image the web tier ships. */
withImage: (image: string) => s.web.set('image', image),
/** How many replicas of the web tier to run. */
withReplicas: (replicas: number) => s.web.set('replicas', replicas),
/**
* The databases the application owns, by name. Each becomes a
* RelationalDatabase child under the DBMS, and the web tier is linked to
* it — created and wired in the same transform.
*/
withDatabases: (names: string[]) => {
const transforms = names.flatMap(name => {
const db = RelationalDatabase({id: name, displayName: name})
.withCharset('UTF8')
.withCollation('en_US.utf8');
return [
s.dbms.addChild(db),
link(s.web, db, {access: 'read-write'} satisfies RelationalDatabaseLink),
];
});
return st => transforms.reduce((acc, t) => t(acc), st);
},
}),
});
}

Three things to notice.

Dependencies and links are different, and the difference is not stylistic. A dependency means I cannot exist without this — it drives ordering, and the agent will not reconcile a component until its dependencies are Active. A link means I have a runtime relationship with this — it drives derived configuration. web.dependsOn(cluster) says the workload needs somewhere to run. link(s.web, db, {access: 'read-write'}) says the workload talks to that database, and the agent turns it into a scoped database role plus injected connection env (DB_HOST, DB_PORT, DB_NAME, DB_USERNAME, DB_PASSWORD_REF) at reconciliation time.

The database link targets a database, not the engine. RelationalDatabaseLink is a link to a Storage.RelationalDatabase, which is why it is authored inside withDatabases rather than in the blueprint body: the database does not exist until the consuming team names it. Linking to the RelationalDbms instead would leave the agent with no database to scope a role on and no DB_NAME to inject. Operations may author links exactly like the blueprint can — operations receives a link function as its second argument, and creating the child and its link in one transform keeps them from drifting apart.

Connection facts never travel on a link. The database publishes them as output fields; the agent injects them downstream. And the raw password is never in an output field — DB_PASSWORD_REF carries only a secret-store reference the workload runtime resolves at launch.

access has no default. A link without it is rejected rather than guessed at, because both guesses are bad: read-only silently breaks a writer, read-write silently over-grants a reader.

The returned Fractal is immutable. .specialize() never mutates it, so authoring once and instantiating many times is safe.


Role C: The Developer​

Responsibility: composition and instantiation.

The developer consumes the governed Fractal. They do not author blueprints, and they cannot reach past the interface: .withImage() exists, .withCidrBlock() does not.

Step 4: Specialize, select offers, deploy​

src/deploy.ts
import {
createFractalCloudClient,
GcpVpc,
GcpSubnet,
GcpFirewall,
Gke,
GcpPostgresDbms,
K8sWorkload,
} from '@fractal_cloud/sdk/model';
import {authorFractal} from './fractal';
import {management} from './environment';
import {fatal} from './fatal';

const cloud = createFractalCloudClient({
clientId: process.env['SERVICE_ACCOUNT_ID']!,
clientSecret: process.env['SERVICE_ACCOUNT_SECRET']!,
});

async function main() {
const fractal = authorFractal();

const liveSystem = fractal
.specialize()
// Application-level choices — everything the interface permits.
.withImage('ghcr.io/acme/pilot-web:1.4.2')
.withReplicas(2)
.withDatabases(['orders', 'audit'])
.toLiveSystem({
name: 'pilot-dev',
environment: management.operational('dev').ref(),
// ── The ONLY cloud-specific lines: one Offer per Component. ──
select: {
'main-network': GcpVpc({}),
'private-subnet': GcpSubnet({}),
'app-sg': GcpFirewall({}),
'app-cluster': Gke({region: 'europe-west1'}),
'app-dbms': GcpPostgresDbms({tier: 'db-custom-2-7680'}),
'web-workload': K8sWorkload({namespace: 'default'}),
},
});

// A blueprint and a Live System are different entities. Register the reusable,
// vendor-agnostic blueprint first — the API rejects a Live System whose
// Fractal is not registered.
await cloud.blueprints.create(fractal);
await cloud.liveSystems.deploy(liveSystem, {mode: 'wait'});

const state = await cloud.liveSystems.outputs(liveSystem);
console.log(state.status);
console.log(state.components['app-dbms'].outputFields);
}

main().catch(fatal);

management.operational('dev') is typo-proof — it throws if no operational environment named dev exists, rather than deploying somewhere unexpected. Use management.ref() to target the management environment itself.

select is checked against the blueprint at compile time. An unknown component id, a missing component, or an Offer that does not satisfy that Component's contract is a type error, not a deploy-time failure. Unknown keys are rejected at runtime too.

Child components are not selected. withDatabases(['orders', 'audit']) added two RelationalDatabase children under the DBMS; selecting GcpPostgresDbms emits them as GcpPostgresDatabase automatically, in the parent's vendor family. There is no separate Offer to pick, and no way to accidentally straddle two vendors within one engine.

Step 5: Run it​

npx tsx src/deploy.ts

mode: 'wait' polls to Active and emits an append-only log — no ANSI escapes, no cursor rewrites, safe for any CI log aggregator:

[2026-03-10T14:23:01Z] INFO Deploying Live System system=<id> fractal=<id> provider=GCP
[2026-03-10T14:23:11Z] CHECK Polling Live System status system=<id> round=1 status=<status> elapsed=10s
[2026-03-10T14:24:52Z] INFO Live System Active system=<id> elapsed=111s

mode: 'fire-and-forget' submits and returns immediately, emitting nothing — use it when a pipeline should not block on provisioning.

Never print a raw error from a failed call. Credentials travel to the API as HTTP headers, and Node's inspection of a failed request walks the raw request header block — so a bare catch (err) { console.error(err) } prints your service-account secret in full. The likeliest failure on a first run is exactly the one that triggers it: a 401 or 403 from a mistyped credential. Copy fatal.ts from the sample repository rather than writing your own catch.

Rerunning is safe and is the point. The agent applies only the delta between what you declared and what is actually in the cloud, correcting drift in either direction. blueprints.create is an idempotent upsert. There is no state file to lose, lock, or reconcile.


Redeploying elsewhere​

Two changes cover the two axes.

Another environment, same cloud — one line:

environment: management.operational('prod').ref(),

Another cloud — swap the select map. Nothing in fractal.ts changes:

select: {
'main-network': AwsVpc({}),
'private-subnet': AwsSubnet({}),
'app-sg': AwsSecurityGroup({}),
'app-cluster': Eks({}),
'app-dbms': AwsRdsPostgresDbms({}),
'web-workload': K8sWorkload({namespace: 'default'}),
},

Every Offer and its parameters are in the Component Reference, one page per vendor.


Summary of Roles & Responsibilities​

RoleFocusOwnsObjective
Ops EngineerSecurity & governanceenvironment.tsDefine the Bounded Context and the cloud identity that provisions. Run once.
Platform EngineerStandardizationfractal.tsAuthor compliant Components with baked-in guardrails, and the narrow interface consumers get.
DeveloperApplication logicdeploy.tsApply application intent, select Offers, deploy self-service without tickets.

The separation is enforced by the type system, not by convention: a developer cannot widen a security group, because the method does not exist on their surface.


What next​

  • Core API reference — the full authoring surface, including extending the catalogue with your own Components and Offers.
  • Link Settings — the settings contract for every link type the platform resolves: databases, object storage, identity providers, messaging.
  • Environments — every field on both environment tiers, secrets, CI/CD profiles, and DNS zones.
  • Runnable samples — basic_container_platform is closest to this tutorial; basic_environment covers Role A in full.
  • The same stack in the GUI — the Design Canvas equivalent.