Featured image of post Deploying applications in VKS

Deploying applications in VKS

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

  • deploy database

  • create internal DB service

  • initialize sample data

  • deploy web app

  • create LoadBalancer service for web app

  • wait for LB IP

  • create DNS record

  • testing in browser

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.