Deploy a simple two-tier application
In the previous post, I prepared VKS. I can now use it to deploy an application.
I start with a two-tier application: a web frontend and a database.
First, I check for a suitable namespace. I do not have one, so I create demoapp.
1
| kubectl --context vcflab02:svc-tkg-domain-c10 create ns demoapp
|
Next is the Harbor Pull-Secret:
1
2
3
4
5
| kubectl --context vcflab02:svc-tkg-domain-c10 -n demoapp create secret docker-registry harbor-tkg-pull \
--docker-server=harbor.vcf.local \
--docker-username='robot$tkg+tkg-pull' \
--docker-password='YOUR_PASSWORD' \
--docker-email='unused@local'
|
Enter the credentials you created in Harbor, and verify that the robot account can both push and pull images.
Then patch your default service account:
1
2
| kubectl --context vcflab02:svc-tkg-domain-c10 -n demoapp patch serviceaccount default \
--type=merge -p '{"imagePullSecrets":[{"name":"harbor-tkg-pull"}]}'
|
and verify:
1
2
| kubectl --context vcflab02:svc-tkg-domain-c10 -n demoapp get secret
kubectl --context vcflab02:svc-tkg-domain-c10 -n demoapp get sa default -o yaml
|
Now we are ready to run our script demoapp.sh
With Harbor access and image pulls working, I deploy a two-tier application. It uses phpMyAdmin as the web frontend and MariaDB as the backend database. A Kubernetes LoadBalancer service gives the web tier an external IP. The database remains internal through a ClusterIP service.
This keeps the frontend reachable and the database private. At the end, the script prints the assigned LoadBalancer IP for a DNS record such as demoapp.vcf.local.
uses the base context vcflab02 instead of expecting vcflab02:demoapp
deploys to a dedicated namespace, for example demoapp
uses an FQDN, for example demoapp.vcf.local
waits for an assigned LoadBalancer IP
prints the DNS record you need to create at the end
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
| #!/usr/bin/env bash
set -Eeuo pipefail
source /root/bootstrap/00-config.sh
log(){ echo "[INFO] $*"; }
warn(){ echo "[WARN] $*" >&2; }
die(){ echo "[ERROR] $*" >&2; exit 1; }
[[ "${EUID}" -eq 0 ]] || die "Run as root."
command -v kubectl >/dev/null 2>&1 || die "kubectl not found. Run 02-base-tools.sh first."
command -v docker >/dev/null 2>&1 || die "docker not found. Docker is required for image mirroring."
# Force base context for this environment
VCF_CONTEXT_NAME="${VCF_CONTEXT_NAME:-vcflab03}"
# Demo app settings
DEMO_NAME="${DEMO_NAME:-demoapp}"
DEMO_NAMESPACE="${DEMO_NAMESPACE:-demoapp}"
DEMO_FQDN="${DEMO_FQDN:-demoapp.vcf.local}"
# Upstream images
UPSTREAM_DB_IMAGE="${UPSTREAM_DB_IMAGE:-mariadb:11.4}"
UPSTREAM_WEB_IMAGE="${UPSTREAM_WEB_IMAGE:-phpmyadmin:5-apache}"
# Images in Harbor
DEMO_DB_IMAGE="${DEMO_DB_IMAGE:-${HARBOR_FQDN}/${HARBOR_PROJECT}/mariadb:11.4}"
DEMO_WEB_IMAGE="${DEMO_WEB_IMAGE:-${HARBOR_FQDN}/${HARBOR_PROJECT}/phpmyadmin:5-apache}"
# Database settings
DEMO_DB_NAME="${DEMO_DB_NAME:-demoapp}"
DEMO_DB_USER="${DEMO_DB_USER:-demoapp}"
DEMO_DB_PASSWORD="${DEMO_DB_PASSWORD:-ChangeMe123!}"
DEMO_DB_ROOT_PASSWORD="${DEMO_DB_ROOT_PASSWORD:-ChangeRoot123!}"
# Storage
DEMO_DB_STORAGE_SIZE="${DEMO_DB_STORAGE_SIZE:-2Gi}"
DEMO_STORAGE_CLASS="${DEMO_STORAGE_CLASS:-management-storage-policy-thin}"
# Optional static LB IP. Leave empty for auto-assignment.
DEMO_LB_IP="${DEMO_LB_IP:-}"
# Auto mirror required images into Harbor
AUTO_PUSH_IMAGES="${AUTO_PUSH_IMAGES:-true}"
# Use base supervisor context
ctx="${VCF_CONTEXT_NAME}"
ensure_namespace_exists() {
kubectl --context "$ctx" get ns "$DEMO_NAMESPACE" >/dev/null 2>&1 \
|| die "Namespace ${DEMO_NAMESPACE} not found. Create it first."
}
show_input_values() {
log "Using values:"
echo " VCF_CONTEXT_NAME = ${VCF_CONTEXT_NAME}"
echo " DEMO_NAME = ${DEMO_NAME}"
echo " DEMO_NAMESPACE = ${DEMO_NAMESPACE}"
echo " DEMO_FQDN = ${DEMO_FQDN}"
echo " UPSTREAM_DB_IMAGE = ${UPSTREAM_DB_IMAGE}"
echo " UPSTREAM_WEB_IMAGE = ${UPSTREAM_WEB_IMAGE}"
echo " DEMO_DB_IMAGE = ${DEMO_DB_IMAGE}"
echo " DEMO_WEB_IMAGE = ${DEMO_WEB_IMAGE}"
echo " DEMO_DB_NAME = ${DEMO_DB_NAME}"
echo " DEMO_DB_USER = ${DEMO_DB_USER}"
echo " DEMO_DB_STORAGE_SIZE = ${DEMO_DB_STORAGE_SIZE}"
echo " DEMO_STORAGE_CLASS = ${DEMO_STORAGE_CLASS}"
echo " AUTO_PUSH_IMAGES = ${AUTO_PUSH_IMAGES}"
if [[ -n "${DEMO_LB_IP}" ]]; then
echo " DEMO_LB_IP = ${DEMO_LB_IP}"
else
echo " DEMO_LB_IP = "
fi
}
docker_login_harbor() {
log "Logging in to Harbor"
echo "${HARBOR_ROBOT_PASSWORD}" | docker login "${HARBOR_FQDN}" -u "${HARBOR_ROBOT_USER}" --password-stdin >/dev/null
}
image_exists_in_harbor() {
local image="$1"
docker manifest inspect "${image}" >/dev/null 2>&1
}
mirror_image_to_harbor() {
local upstream_image="$1"
local harbor_image="$2"
if image_exists_in_harbor "${harbor_image}"; then
log "Image already exists in Harbor: ${harbor_image}"
return 0
fi
log "Image not found in Harbor, mirroring ${upstream_image} -> ${harbor_image}"
docker pull "${upstream_image}"
docker tag "${upstream_image}" "${harbor_image}"
docker push "${harbor_image}"
}
ensure_demo_images() {
if [[ "${AUTO_PUSH_IMAGES}" != "true" ]]; then
log "AUTO_PUSH_IMAGES=false, skipping automatic image mirroring"
return
fi
docker_login_harbor
mirror_image_to_harbor "${UPSTREAM_DB_IMAGE}" "${DEMO_DB_IMAGE}"
mirror_image_to_harbor "${UPSTREAM_WEB_IMAGE}" "${DEMO_WEB_IMAGE}"
}
deploy_demoapp() {
log "Deploying ${DEMO_NAME} into namespace ${DEMO_NAMESPACE}"
if [[ -n "${DEMO_LB_IP}" ]]; then
cat /dev/null || true
}
wait_for_demo_lb_ip() {
log "Waiting for LoadBalancer IP for ${DEMO_NAME}-web-lb"
local lb_ip=""
local max_tries=36
local i
for i in $(seq 1 "$max_tries"); do
lb_ip="$(get_demo_lb_ip)"
if [[ -n "${lb_ip}" ]]; then
echo "${lb_ip}"
return 0
fi
sleep 5
done
return 1
}
show_status() {
log "Current objects"
kubectl --context "$ctx" -n "$DEMO_NAMESPACE" get deploy,pod,svc,pvc | egrep "NAME|${DEMO_NAME}" || true
}
print_next_steps() {
local lb_ip
lb_ip="$(wait_for_demo_lb_ip || true)"
echo
echo "========================================"
echo "Demo application deployment completed"
echo "========================================"
if [[ -n "${lb_ip}" ]]; then
echo "Assigned LoadBalancer IP : ${lb_ip}"
echo "Requested FQDN : ${DEMO_FQDN}"
echo
echo "Create this DNS record:"
echo " ${DEMO_FQDN} -> ${lb_ip}"
echo
echo "Then open:"
echo " http://${lb_ip}"
echo " http://${DEMO_FQDN}"
echo
echo "phpMyAdmin login details:"
echo " Server : ${DEMO_NAME}-db"
echo " Username : ${DEMO_DB_USER}"
echo " Password : ${DEMO_DB_PASSWORD}"
echo
echo "Demo database:"
echo " Database : ${DEMO_DB_NAME}"
echo " Table : demo_messages"
else
warn "No LoadBalancer IP assigned yet."
echo
echo "Check again with:"
echo " kubectl --context \"$ctx\" -n \"$DEMO_NAMESPACE\" get svc ${DEMO_NAME}-web-lb -o wide"
echo
echo "Once the IP appears, create this DNS record:"
echo " ${DEMO_FQDN} -> "
fi
}
main() {
ensure_namespace_exists
show_input_values
ensure_demo_images
deploy_demoapp
wait_for_rollouts
show_status
print_next_steps
log "08 done."
}
main "$@"
|
You may need to change the storage class for your environment:
The script defines it here:
DEMO_STORAGE_CLASS="${DEMO_STORAGE_CLASS:-management-storage-policy-thin}"
I used “management-storage-policy-thin”
If the deployment fails, clean it up before running it again:
1
2
3
4
5
| kubectl --context vcflab02 -n demoapp delete deployment demoapp-db demoapp-web --ignore-not-found
kubectl --context vcflab02 -n demoapp delete service demoapp-db demoapp-web-lb --ignore-not-found
kubectl --context vcflab02 -n demoapp delete secret demoapp-db-secret --ignore-not-found
kubectl --context vcflab02 -n demoapp delete configmap demoapp-db-init --ignore-not-found
kubectl --context vcflab02 -n demoapp delete pvc demoapp-db-pvc --ignore-not-found
|
To override the FQDN or storage class, run:
1
2
3
4
| DEMO_NAMESPACE="demoapp" \
DEMO_FQDN="demoapp.vcf.local" \
DEMO_STORAGE_CLASS="management-storage-policy-thin" \
./demoapp.sh
|
Otherwise, run: ./demoapp.sh
If the deployment succeeds, the output includes the LoadBalancer IP, requested FQDN, and login details:
========================================
Demo application deployment completed
Assigned LoadBalancer IP : [INFO] Waiting for LoadBalancer IP for demoapp-web-lb
10.31.x.x
Requested FQDN : demoapp.vcf.local
Create this DNS record:
demoapp.vcf.local -> [INFO] Waiting for LoadBalancer IP for demoapp-web-lb
10.31.x.x
Then open:
http://INFO Waiting for LoadBalancer IP for demoapp-web-lb
10.31.x.x
http://demoapp.vcf.local
phpMyAdmin login details:
Server : demoapp-db
Username : demoapp
Password : ChangeMe123!
Demo database:
Database : demoapp
Table : demo_messages
[INFO] 08 done.
root@sv01-mgmt02 [ ~/bootstrap ]#
I add demoapp.vcf.local to DNS with the IP shown above.

I use the login details to verify the deployment:

The database contains the test message " Hello from VKS and MariaDB":

This example verifies the full path from Harbor image access to a working two-tier application.
Many services that ran on dedicated Linux or Windows virtual machines can run as containers on Kubernetes. Each service can then be deployed and updated separately.
The most common examples include web and ingress services such as NGINX, Apache, Envoy, and Traefik; container registries such as Harbor; CI/CD platforms such as GitLab and Argo CD; identity and access services such as Keycloak; secrets management with HashiCorp Vault; databases such as PostgreSQL, MariaDB/MySQL, MongoDB, and Redis; and observability stacks such as Prometheus and Grafana.
Good candidates include web applications, APIs, internal portals, monitoring tools, CI/CD platforms, identity services, message brokers, and many modern databases. Traditional Windows infrastructure, legacy applications, GUI-dependent software, and some specialized or licensed systems are more likely to remain on dedicated virtual machines.
My rule of thumb is:
Many Linux-based infrastructure and application services can now be replaced by containerized platforms and applications, while Windows-based legacy and infrastructure workloads are often modernized more gradually.
Next post will be about configuring a two-tier application published from VCF Automation.