In the previous articles in my VMware Cloud Foundation 9.0 series, I explored two fundamental paths. First, in “[VCF Automation: From Zero to a Running Virtual Machine],” I demonstrated how to easily deploy a virtual machine using a simple dummyBlueprint. Then, in “[From Zero to Database-as-a-Service],” I explored VMware Data Services Manager (DSM) and deployed a fully managed PostgreSQL database.
Today, it’s time for me to connect the dots. I will show you how to move beyond simple, single resources and start thinking about deploying complete, multi-tier applications. In this article, I’ll demonstrate how a single, cohesive blueprint can define, deploy, and connect:
- A PostgreSQL database provisioned by Data Services Manager.
- A virtual machine for my web application.
- The Cloud-init mechanism for on-the-fly, dynamic configuration of the application server.
- An NSX Load Balancer (provisioned as a
VirtualMachineService
) to expose my application to the world.
I will build a simple, two-tier application where a web server, upon startup, connects to a newly created database, writes information to it, and then reads it back to display on a webpage. All of this will be fully automated and available as a single item in the service catalog.
A Shift in Philosophy: From Resources to Native Kubernetes Objects
One of the key changes in newer versions of VCF Automation is the move away from dedicated resource types towards a more universal and robust mechanism. Instead of using specific types like VMware.vSphere.Machine I will increasingly use CCI.Supervisor.Resource
.
Why is this so important? This is beneficial because it allows me to directly define and deploy native Kubernetes API objects within the Supervisor Cluster. According to the official VMware documentation, this model gives me unprecedented flexibility. I can create not only virtual machines (VirtualMachine
) but also secrets (Secret
), services (VirtualMachineService
), and, most importantly for today’s example, database clusters (PostgresCluster
) provided by DSM.
In practice, this means my blueprint becomes a manifest describing the desired state of an entire group of Kubernetes resources, and Aria Automation acts as the orchestrator that brings this state to life.
The Application Blueprint: A Detailed Code Analysis
Below is the complete YAML code for my blueprint. Let’s analyze it step-by-step so you can understand how the individual components fit together into a cohesive whole.
formatVersion: 2
inputs:
dbInstanceName:
type: string
title: Database Instance Name
default: vcf-db
dbAdminPassword:
type: string
title: Database Administrator Password
encrypted: true
dbVersion:
type: string
title: PostgreSQL Version
enum:
- '17'
- '16'
- '15'
default: '17'
storageSize:
type: integer
title: Database Disk Capacity (GB)
default: 60
dbVmClass:
type: string
title: VM Class for Database
enum:
- medium
- large
default: medium
appVmName:
type: string
title: Application VM Name
default: web-app
appVmClass:
type: string
title: VM Class for Application
enum:
- best-effort-small
- best-effort-medium
default: best-effort-small
vmImage:
type: string
title: Virtual Machine Image
default: vmi-c9e21e0b30ee129c3
outputs:
appServerIpAddress:
type: string
title: Application Server IP Address
value: ${resource.App_Server_VM.object.status.network.primaryIp4}
loadBalancerIpAddress:
type: string
title: Load Balancer IP Address
value: ${resource.App_Server_Service.object.status.loadBalancer.ingress[0].ip}
databaseHost:
type: string
title: Database Host
value: ${resource.Postgres_Database_Cluster.object.status.connection.host}
databasePort:
type: string
title: Database Port
value: ${resource.Postgres_Database_Cluster.object.status.connection.port}
resources:
CCI_Supervisor_Namespace_1:
type: CCI.Supervisor.Namespace
properties:
name: vcf-vworld-nsp-v669r
existing: true
Postgres_Admin_Secret:
type: CCI.Supervisor.Resource
properties:
context: ${resource.CCI_Supervisor_Namespace_1.id}
manifest:
apiVersion: v1
kind: Secret
metadata:
name: ${input.dbInstanceName}-secret-${env.shortDeploymentId}
stringData:
username: pgadmin
password: ${input.dbAdminPassword}
Postgres_Database_Cluster:
type: CCI.Supervisor.Resource
dependsOn:
- Postgres_Admin_Secret
properties:
context: ${resource.CCI_Supervisor_Namespace_1.id}
manifest:
apiVersion: databases.dataservices.vmware.com/v1alpha1
kind: PostgresCluster
metadata:
name: ${input.dbInstanceName}
spec:
databaseName: ${input.dbInstanceName}
version: ${input.dbVersion}
storageSpace: ${input.storageSize + 'Gi'}
vmClass:
name: ${input.dbVmClass}
adminUsername: pgadmin
adminPasswordRef:
name: ${resource.Postgres_Admin_Secret.manifest.metadata.name}
infrastructurePolicy:
name: vcf-infra-policy
storagePolicyName: vworld-cl01 - Optimal Datastore Default Policy - RAID1
replicas: 0
wait:
timeoutSeconds: 1800
conditions:
- type: Ready
status: 'True'
- type: DatabaseEngineReady
status: 'True'
jsonPath:
- path: '{.status.connection.host}'
regex: .+
App_Server_Cloud_Init:
type: CCI.Supervisor.Resource
dependsOn:
- Postgres_Database_Cluster
properties:
context: ${resource.CCI_Supervisor_Namespace_1.id}
manifest:
apiVersion: v1
kind: Secret
metadata:
name: ${input.appVmName}-ci-secret-${env.shortDeploymentId}
stringData:
user-data: |
#cloud-config
hostname: ${input.appVmName}
fqdn: ${input.appVmName}.local
manage_etc_hosts: true
write_files:
- path: /etc/db-config.env
permissions: '0600'
owner: root:root
content: |
DB_HOST=${resource.Postgres_Database_Cluster.object.status.connection.host}
DB_PORT=${resource.Postgres_Database_Cluster.object.status.connection.port}
DB_NAME=${input.dbInstanceName}
DB_USER=pgadmin
DB_PASSWORD=${input.dbAdminPassword}
DEPLOYMENT_ID=${env.shortDeploymentId}
- path: /usr/local/bin/db-test.sh
permissions: '0755'
owner: root:root
content: |
#!/bin/bash
set -e
LOG="/tmp/cloud-init.log"
echo "=== Cloud-init START - $(date) ===" > $LOG
if [ -f /etc/db-config.env ]; then
source /etc/db-config.env
echo "Configuration loaded from /etc/db-config.env" >> $LOG
else
echo "ERROR: /etc/db-config.env file not found!" >> $LOG
exit 1
fi
echo "Installing packages..." >> $LOG
dnf install -y httpd postgresql 2>&1 | tee -a $LOG
echo "Configuring firewall..." >> $LOG
firewall-cmd --permanent --add-service=http 2>&1 | tee -a $LOG
firewall-cmd --reload 2>&1 | tee -a $LOG
echo "Starting httpd..." >> $LOG
systemctl enable --now httpd 2>&1 | tee -a $LOG
if [ -z "$DB_HOST" ] || [ "$DB_HOST" = "null" ]; then
echo "ERROR: DB_HOST is empty or null!" >> $LOG
cat > /var/www/html/index.html << 'HTML'
<!DOCTYPE html>
<html>
<head><title>Configuration Error</title></head>
<body>
<h1>ERROR: Missing database configuration</h1>
<p>The DB_HOST or DB_PORT variables were not set correctly in the cloud-init script.</p>
<p>Check the logs on the virtual machine in the following files:</p>
<ul>
<li>/tmp/cloud-init.log</li>
<li>/etc/db-config.env</li>
</ul>
</body>
</html>
HTML
exit 1
fi
export PGPASSWORD="$DB_PASSWORD"
echo "Waiting for database $DB_HOST:$DB_PORT..." >> $LOG
ATTEMPTS=0
MAX_ATTEMPTS=60
while ! psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" -c '\q' 2>/dev/null; do
ATTEMPTS=$((ATTEMPTS + 1))
if [ $ATTEMPTS -ge $MAX_ATTEMPTS ]; then
echo "ERROR: Database unavailable after $MAX_ATTEMPTS attempts" >> $LOG
cat > /var/www/html/index.html << HTML
<!DOCTYPE html>
<html>
<head><title>Database Connection Error</title></head>
<body>
<h1>ERROR: Could not connect to the database</h1>
<p>The application could not establish a connection to the database server after $MAX_ATTEMPTS attempts.</p>
<hr>
<h2>Connection attempt details:</h2>
<p><b>Host:</b> $DB_HOST</p>
<p><b>Port:</b> $DB_PORT</p>
<p><b>Database:</b> $DB_NAME</p>
<p><b>User:</b> $DB_USER</p>
<br>
<p>Check the logs on the virtual machine in the file: <b>/tmp/cloud-init.log</b></p>
</body>
</html>
HTML
exit 1
fi
echo "Attempt $ATTEMPTS/$MAX_ATTEMPTS..." >> $LOG
sleep 5
done
echo "Database is available!" >> $LOG
echo "Creating test_table..." >> $LOG
psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" << 'SQL' 2>&1 | tee -a $LOG
CREATE TABLE IF NOT EXISTS test_table (id serial primary key, message text, created_at timestamp default now());
INSERT INTO test_table (message) VALUES ('Aria Automation - Full Test Completed Successfully!');
SQL
echo "Reading data from the database..." >> $LOG
DB_MESSAGE=$(psql -tA -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" -c "SELECT message FROM test_table ORDER BY id DESC LIMIT 1;")
if [ $? -ne 0 ]; then
echo "ERROR reading from database: $DB_MESSAGE" >> $LOG
DB_MESSAGE="ERROR READING FROM DATABASE"
else
echo "Read from database: $DB_MESSAGE" >> $LOG
fi
echo "Generating HTML page..." >> $LOG
cat > /var/www/html/index.html << HTML
<!DOCTYPE html>
<html>
<head>
<title>Aria Automation - Test Completed</title>
</head>
<body>
<h1>Application is Running Correctly</h1>
<hr>
<h2>Message read from the PostgreSQL database:</h2>
<p><b>$DB_MESSAGE</b></p>
<hr>
<h2>Deployment Details:</h2>
<p><b>Database Host:</b> $DB_HOST</p>
<p><b>Database Port:</b> $DB_PORT</p>
<p><b>Database Name:</b> $DB_NAME</p>
<p><b>Database User:</b> $DB_USER</p>
<p><b>Deployment ID:</b> $DEPLOYMENT_ID</p>
<p><b>VM Name:</b> $(hostname)</p>
<p><b>Generated:</b> $(date)</p>
<hr>
<p><small>Powered by VMware Aria Automation & vSphere with Tanzu</small></p>
</body>
</html>
HTML
echo "=== Cloud-init DONE - $(date) ===" >> $LOG
echo "HTML page generated successfully" >> $LOG
runcmd:
- /usr/local/bin/db-test.sh
App_Server_VM:
type: CCI.Supervisor.Resource
dependsOn:
- App_Server_Cloud_Init
- Postgres_Database_Cluster
properties:
context: ${resource.CCI_Supervisor_Namespace_1.id}
manifest:
apiVersion: vmoperator.vmware.com/v1alpha3
kind: VirtualMachine
metadata:
name: ${input.appVmName}
labels:
vmoperator.vmware.com/vm-name: ${input.appVmName}
spec:
className: ${input.appVmClass}
imageName: ${input.vmImage}
powerState: PoweredOn
storageClass: vworld-cl01-optimal-datastore-default-policy-raid1
bootstrap:
cloudInit:
rawCloudConfig:
name: ${resource.App_Server_Cloud_Init.manifest.metadata.name}
key: user-data
wait:
timeoutSeconds: 1800
conditions:
- type: VirtualMachineCreated
status: 'True'
- type: VirtualMachineConditionPlacementReady
status: 'True'
- type: VirtualMachineTools
status: 'True'
jsonPath:
- path: '{.status.network.primaryIp4}'
regex: \d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}
App_Server_Service:
type: CCI.Supervisor.Resource
dependsOn:
- App_Server_VM
properties:
context: ${resource.CCI_Supervisor_Namespace_1.id}
manifest:
apiVersion: vmoperator.vmware.com/v1alpha3
kind: VirtualMachineService
metadata:
name: ${input.appVmName}-svc
spec:
type: LoadBalancer
selector:
vmoperator.vmware.com/vm-name: ${input.appVmName}
ports:
- name: http
port: 80
protocol: TCP
targetPort: 80
wait:
timeoutSeconds: 600
jsonPath:
- path: '{.status.loadBalancer.ingress[0].ip}'
1. Inputs and Outputs
inputs
: This section defines the form a user will see in the service catalog. I’ve defined fields for the database instance name, admin password, PostgreSQL version, and also the VM classes and name for the application server. This provides flexibility while maintaining control.outputs
: After the deployment is complete, the blueprint returns key information necessary to use the application. In this case, these are the IP addresses of the server and the load balancer, and most importantly, the host and port for connecting to the database.
2. Resources – The Heart of the Orchestration
This is where all the magic happens. The resources are processed in an order dictated by their dependencies (dependsOn
).
CCI_Supervisor_Namespace_1
: The starting point. I specify an existing namespace in the Supervisor Cluster where all the following objects will be created.Postgres_Admin_Secret
: A crucial step. Instead of passing the database password directly, I first create a native Kubernetes secret. This is a security best practice. The password is taken from theinputs
.Postgres_Database_Cluster
: This is my direct integration with DSM. I create anPostgresCluster
object. Note theapiVersion: databases.dataservices.vmware.com/v1alpha1
. I specify the version, size, VM class, and the infrastructure and storage policies I defined in the previous DSM article. The key part is adminPasswordRef, which refers to the secret created during the last step. Thewait
section ensures that the blueprint will wait until the database is fully ready and has a host assigned before proceeding.App_Server_Cloud_Init
: This is the glue connecting my application to the database. I create another Kubernetes secret, but this time it contains acloud-init
script.write_files
: The script creates two files on the virtual machine./etc/db-config.env
: This is a dynamic configuration. Notice values likeDB_HOST=${resource.Postgres_Database_Cluster.object.status.connection.host}
. Here, Aria Automation injects the host address that was dynamically created and returned by the database resource! The same happens with the port, password, and database name./usr/local/bin/db-test.sh
: This is a bash script that performs all the application configuration logic:- Installs an HTTP server (
httpd
) and a PostgreSQL client. - Configures the firewall to allow HTTP traffic.
- Starts the web server.
- The system waits in a loop until the database becomes available, with robust error handling that generates a helpful HTML error page on failure.
- Once the database is ready, it connects and creates a
test_table
, and inserts a test record. - It reads that record back from the database.
- Finally, it generates a clean, functional
index.html
page that displays the retrieved message and all relevant deployment details.
- Installs an HTTP server (
App_Server_VM
: Now I create the virtual machine. Notice I am leveraging the latestapiVersion: vmoperator.vmware.com/v1alpha3
for enhanced capabilities and future compatibility. I use the image and VM class from theinputs
. The most important part is thebootstrap
section, which instructs the machine to use thecloud-init
script from theApp_Server_Cloud_Init
secret I created earlier. Thewait
section ensures I wait for the VM to be fully up and have an IP address.App_Server_Service
: The final piece of the puzzle. I create aVirtualMachineService
object of typeLoadBalancer
, also using thev1alpha3
API version. It operates based on aselector
That points to my application machine by its label. NSX ALB automatically detects this object, creates a server pool with the VM, and assigns it an external IP address from the VIP pool, exposing port 80.
Deployment and Validation
Once the blueprint is published in the catalog, the deployment process is just a few clicks away.
- A user selects the item from the catalog and clicks “Request.”
- A form generated from the
inputs
section of my blueprint appears. The user provides the database password and selects the versions and VM classes. - After submission, Aria Automation begins the orchestration. In the deployment view, you can track the progress of each resource being created according to the defined dependencies.

After about a dozen minutes, the entire environment is ready. In the outputs
section of the deployment, I will find the IP address of my load balancer.

- Validation: I open a web browser and enter the load balancer’s IP address.

Success! The result is a functional webpage that confirms the application is running, displays the message retrieved from the database, and lists the deployment details. My simple web application was automatically configured, connected to a dynamically created database, and is now displaying data retrieved from it.
By checking the resources in the vSphere Client, you will see two new virtual machines (one for the database, one for the application) and, in the NSX infrastructure, a new load balancer and its server pool.

Summary
Today, I’ve taken you on a journey from simple, isolated resources to a fully integrated, multi-tier application deployed with a single click. This example perfectly demonstrates the power of the VMware Cloud Foundation and VCF Automation platform.
By using native Kubernetes objects as the language for describing infrastructure, I can cohesively integrate services from different domains—compute (VM), storage (vSAN), networking (NSX ALB), and database (DSM). For an administrator, this means complete control and standardization. For a developer, it means unparalleled agility and speed, comparable to public clouds, but within a secure, private environment.
This is the true power of Infrastructure as Code, VMware-style. I hope this example inspires you to create your own, even more advanced blueprints.