Deploy a simple two-tier application
In the last post we prepared VKS, and we are now ready to try some more useful applications.
Lets start with a two-tier application with a web frontend and a db.
First i check if i have a suitable namespace to deploy to.I dont`t, so i create a new one called 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'
|
fill in your own user info that you created previous in Harbor and verify that your robot account can do both push and pull.
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 pull already working, the next step is to deploy a more realistic application. In this example, I use a simple two-tier setup with phpMyAdmin as the web frontend and MariaDB as the backend database. The web tier is exposed through its own Kubernetes LoadBalancer service and external IP, while the database remains internal only through a ClusterIP service.
This is a useful pattern because it reflects how many real applications are structured: the frontend is published externally, while the database is kept private inside the cluster. At the end of the script, the assigned LoadBalancer IP is printed so a DNS record such as demoapp.vcf.local can be created afterwards.
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 adjust to your specified storage class:
It`s defined in the script like this:
DEMO_STORAGE_CLASS="${DEMO_STORAGE_CLASS:-management-storage-policy-thin}"
I used “management-storage-policy-thin”
If something fails, you should do a cleanup and run it again, use this:
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
|
If you want to override fqdn or storage class, then run:
1
2
3
4
| DEMO_NAMESPACE="demoapp" \
DEMO_FQDN="demoapp.vcf.local" \
DEMO_STORAGE_CLASS="management-storage-policy-thin" \
./demoapp.sh
|
otherwise, just run: ./demoapp.sh
if all goes well, you should get something like this๐๐ป wich will give you the IP you need for the FQDN and the 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 my dns with the IP above.

I use the login info and verify:

and the test message " Hello from VKS and MariaDB":

This is just an example, i hope you found it useful.
Many services that traditionally ran on dedicated Linux or Windows virtual machines can now be delivered as containerized applications on platforms such as Kubernetes. Instead of deploying one VM per service, organizations often run one containerized service per function, which makes deployment and lifecycle management more flexible.
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.
In practice, the best candidates for moving from VMs to containers are 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 highly specialized or licensed systems are more likely to remain on dedicated virtual machines.
A simple way to describe it 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.