Friday, November 22, 2024
Google search engine
HomeData Modelling & AIInstall Vault Cluster in GKE via Helm, Terraform and BitBucket Pipelines

Install Vault Cluster in GKE via Helm, Terraform and BitBucket Pipelines

.tdi_3.td-a-rec{text-align:center}.tdi_3 .td-element-style{z-index:-1}.tdi_3.td-a-rec-img{text-align:left}.tdi_3.td-a-rec-img img{margin:0 auto 0 0}@media(max-width:767px){.tdi_3.td-a-rec-img{text-align:center}}

Motivation

The management of secrets in an organisation holds a special place in the running of day to day activities of the business. All the way from access to the building down to securing personal and confidential documents in laptops or computers, secrets continually show up which speaks about the importance secrets wield not only in our personal lives but in the highways of businesses. A secret is anything that you want to tightly control access to, such as API encryption keys, passwords, or certificates.

Taking it away from there, most applications in the current era are going towards the micro-services way and Kubernetes has come strong as the best platform to host the applications designed in this new paradigm. Kubernetes brought about new opportunities and a suite of challenges at the same time. It brought about agility, self healing, ease of scalability, ease of deployment and a good way of running decoupled systems. Now comes the issue of secrets and Kubernetes provides a way of managing them natively. The only problem with it is that it works if the workloads being run are few or the team managing the cluster is relatively small. When the applications being spawned are in the range of hundreds, it becomes difficult to manage secrets in that manner. Moreover, the native Kubernetes secrets engine lacks the capability of encryption which brings an issue of security to the fore.

HashiCorp Vault is a secrets management solution that is strongly designed to provide the management of secrets at scale, with ease and it integrates well with a myriad of other tools Kubernetes included. It is an identity-based secrets and encryption management system. Let us see the features Vault comes with:

.tdi_2.td-a-rec{text-align:center}.tdi_2 .td-element-style{z-index:-1}.tdi_2.td-a-rec-img{text-align:left}.tdi_2.td-a-rec-img img{margin:0 auto 0 0}@media(max-width:767px){.tdi_2.td-a-rec-img{text-align:center}}

Features of Vault

The key features of Vault are: Source Vault Documentation

  • Secure Secret Storage: Arbitrary key/value secrets can be stored in Vault. Vault encrypts these secrets prior to writing them to persistent storage, so gaining access to the raw storage isn’t enough to access your secrets. Vault can write to disk, Consul, and more.
  • Dynamic Secrets: Vault can generate secrets on-demand for some systems, such as AWS or SQL databases. For example, when an application needs to access an S3 bucket, it asks Vault for credentials, and Vault will generate an AWS key-pair with valid permissions on demand. After creating these dynamic secrets, Vault will also automatically revoke them after the lease is up.
  • Data Encryption: Vault can encrypt and decrypt data without storing it. This allows security teams to define encryption parameters and developers to store encrypted data in a location such as SQL without having to design their own encryption methods.
  • Leasing and Renewal: All secrets in Vault have a lease associated with them. At the end of the lease, Vault will automatically revoke that secret. Clients are able to renew leases via built-in renew APIs.
  • Revocation: Vault has built-in support for secret revocation. Vault can revoke not only single secrets, but a tree of secrets, for example all secrets read by a specific user, or all secrets of a particular type. Revocation assists in key rolling as well as locking down systems in the case of an intrusion.

Project Pre-requisites

  • BitBucket account
  • BitBucket Pipelines already setup
  • Docker or any tool to create images like Podman, Buildah etc
  • Existing Google Cloud Credentials (json) in BitBucket Environment variable
  • An existing Google Cloud Bucket for Terraform Backend (where it will keep state)

We will create terraform scripts, push it to BitBucket whence BitBucket pipelines will take over and deploy vault in Google Kubernetes Engine (GKE) using the image we will build.

Installation of Vault Cluster in Google Kubernetes Engine

We can now embark on setting up Vault on an existing Google Kubernetes Engine via Helm, BitBucket pipelines and Terraform. The following are the steps that will get you up and running. Some parts are optional in case you do not use BitBucket pipelines in your setup.

Step 1: Prepare Terraform and Google SDK Image

In this step we are going to create a Docker image that has Terraform and Google Cloud SKD and then host it in DockerHub so that BitBucket can pull and use it while deploying the infrastructure. First let us create Dockerfile file and populate it with the following. We will use Google’s cloudsdk image as the base then add Terraform.

$ vim Dockerfile
FROM gcr.io/google.com/cloudsdktool/cloud-sdk:alpine
ENV TERRAFORM_VERSION=1.0.10
# Installing terraform
RUN apk --no-cache add curl unzip && \
    cd /tmp && \
    curl -o /tmp/terraform.zip https://releases.hashicorp.com/terraform/${TERRAFORM_VERSION}/terraform_${TERRAFORM_VERSION}_linux_amd64.zip && \
    unzip /tmp/terraform.zip && \
    cp /tmp/terraform /usr/local/bin && \
    chmod a+x /usr/local/bin/terraform && \
    rm /tmp/terraform /tmp/terraform.zip

After that, let us build and tag the image. Make sure the Dockerfile file is in the same place you are running this command.

docker build -label imagename .

Tag the image

docker tag imagename penchant/cloudsdk-terraform:latest

Then push it to public DockerHub or any registry you prefer

docker push penchant/cloudsdk-terraform:latest

And we are done with the first part

Step 2: Prepare Terraform and Helm scripts

In order to avoid re-inventing the wheel, this project heavily borrows from a project already in GitHub by mohsinrz. We are grateful and celebrate them for the fine work they have done. We will clone the project and then customise it to befit our environment.

cd ~
git clone https://github.com/mohsinrz/vault-gke-raft.git

Since we already have a GKE Cluster, we will not use the module geared towards creating one. We will further disable the use of certificates because BitBucket uses an ephemeral container and will not be able to store certificates in it and we will have trouble joining vault workers to the leader later.

We will add GCP bucket to store Terraform state so that we can track changes in the what we will be deploying. Add the following in the “main.yaml” file. Ensure that the bucket name already exists in GCP.

$ cd ~/vault-gke-raft
$ vim main.tf

## Disable the gke cluster module if you have on already
#module "gke-cluster" {
#  source                     = "./modules/google-gke-cluster/"
#  credentials_file           = var.credentials_file
#  region                     = var.region
#  project_id                 = "project-id"
#  cluster_name               = "dev-cluster-1"
#  cluster_location           = "us-central1-a"
#  network                    = "projects/${var.project_id}/global/networks/default"
#  subnetwork                 = "projects/${var.project_id}/regions/${var.region}/subnetworks/default"
#  initial_node_count         = var.cluster_node_count
#}

module "tls" {
  source                     = "./modules/gke-tls"
  hostname                   = "*.vault-internal"
}

module "vault" {
  source                     = "./modules/gke-vault"
  num_vault_pods             = var.num_vault_pods
  #cluster_endpoint           = module.gke-cluster.endpoint
  #cluster_cert               = module.gke-cluster.ca_certificate
  vault_tls_ca               = module.tls.ca_cert
  vault_tls_cert             = module.tls.cert 
  vault_tls_key              = module.tls.key
}

terraform {
  backend "gcs"{
    bucket      = "terraform-state-bucket"
    credentials = "gcloud-api-key.json"
  }
}

Another modification we shall make is disable TLS because in our setup, an ephemeral container in BitBucket will provision our infrastructure and some of the certificates are meant to be stored where terraform is running. So we get to lose the certificates after the deployment is done. To disable, navigate to the modules folder and into the vault directory module. Then edit the “vault.tf” file and make it like below. Changes made are:

  • we changed tlsDisable field to true from false
  • we changed VAULT_ADDR environment variable from https to http
  • we commented/removed VAULT_CACERT environment variable
  • we changed tls_disable field from 0 to 1
  • we changed VAULT_ADDR from 127.0.0.1 to 0.0.0.0
  • And removed the certificate paths under listener block

The same has been updated in the file below.

$ cd ~/vault-gke-raft/modules/vault
$ vim vault.tf

resource "helm_release" "vault" {
  name          = "vault"
  chart         = "${path.root}/vault-helm"
  namespace     = kubernetes_namespace.vault.metadata.0.name

  values = [<<EOF
global:
## changed tlsDisable field to true from false
  #tlsDisable: false
  tlsDisable: true
server:
  extraEnvironmentVars:
#changed VAULT_ADDR environment variable from https to http
    #VAULT_ADDR: https://127.0.0.1:8200
    VAULT_ADDR: http://0.0.0.0:8200
    VAULT_SKIP_VERIFY: true
#removed VAULT_CACERT environment variable
    #VAULT_CACERT: /vault/userconfig/vault-tls/vault.ca
  extraVolumes:
    - type: secret
      name: vault-tls
  ha:
    enabled: true
    replicas: ${var.num_vault_pods}    

    raft:      
      # Enables Raft integrated storage
      enabled: true
      config: |
        ui = true

        listener "tcp" {
#changed tls_disable field from 0 to 1
          #tls_disable = 0
          tls_disable = 1
          address = "[::]:8200"
          cluster_address = "[::]:8201"
#removed the certificate paths here
          #tls_cert_file = "/vault/userconfig/vault-tls/vault.crt"
          #tls_key_file  = "/vault/userconfig/vault-tls/vault.key"
          #tls_client_ca_file = "/vault/userconfig/vault-tls/vault.ca"           
        }

        storage "raft" {
          path = "/vault/data"
        }
ui:
  enabled: true
  serviceType: "LoadBalancer"
  serviceNodePort: null
  externalPort: 8200
EOF
]
}

We will make one more modification that will enable Kubernetes provider to communicate with GKE API. Navigate to the modules folder and into the vault module directory. Then edit the “provider.tf” file. We have added details of the GKE cluster that already exists and used the values in the Kubernetes provider. We commented the one that we fetched from the repo and added the new one as shown below. The helm provider has been edited as well by updating the host, token and cluster ca certificate with what already exists.

$ cd ~/vault-gke-raft/modules/vault
$ vim provider.tf
data "google_client_config" "provider" {}

data "google_container_cluster" "cluster-name" {
  name     = "cluster-name"
  location = "us-central1-a"
  project  = "project-name"
}

# This file contains all the interactions with Kubernetes
provider "kubernetes" {
  #host = google_container_cluster.vault.endpoint
  host = "https://${data.google_container_cluster.dev_cluster_1.endpoint}"
  token = data.google_client_config.provider.access_token
  cluster_ca_certificate = base64decode(
    data.google_container_cluster.dev_cluster_1.master_auth[0].cluster_ca_certificate,
  )
}

#provider "kubernetes" {
#  host  = var.cluster_endpoint
#  token = data.google_client_config.current.access_token
#
#  cluster_ca_certificate = base64decode(
#    var.cluster_cert,
#  )
#}

provider "helm" {
  kubernetes {
    #host  = var.cluster_endpoint
    host  = "https://${data.google_container_cluster.dev_cluster_1.endpoint}"
    #token = data.google_client_config.current.access_token
    token = data.google_client_config.provider.access_token
    cluster_ca_certificate = base64decode(data.google_container_cluster.dev_cluster_1.master_auth[0].cluster_ca_certificate,
    )
  }
}

After we are done editing the files, let us clone vault-helm in the root directory that will deploy the entire infrastructure for us at a go via terraform helm provider

cd ~/vault-gke-raft
git clone https://github.com/hashicorp/vault-helm

Step 3: Create BitBucket pipelines file

In this step, we are going to create and populate the BitBucket pipelines file that will steer our deployment. As you can see, we are using the image we pushed to DockerHub in Step 1.

$ cd ~
$ vim bitbucket-pipelines.yaml

image: penchant/cloudsdk-terraform:latest ## The image
pipelines:
  branches:
    vault:
      - step:
          name: Deploy to Vault Namespace
          deployment: production
          script:
            - cd install-vault # I placed my files in this directory in the root of the files
            - export TAG=$(git log -1 --pretty=%h)
            - echo $GCLOUD_API_KEYFILE | base64 -d  > ./gcloud-api-key.json
            - gcloud auth activate-service-account --key-file gcloud-api-key.json
            - export GOOGLE_APPLICATION_CREDENTIALS=gcloud-api-key.json
            - gcloud config set project <your_project_id>
            - gcloud container clusters get-credentials <your_cluster> --zone=<your_zone> --project <your_project_id>
            - terraform init
            - terraform plan -out create_vault 
            - terraform apply -auto-approve create_vault
          services:
            - docker

Step 4: Initialise cluster by creating the leader node/pod

After installing vault cluster via terraform and helm, it is time to bootstrap the cluster and unseal it. Initialise the cluster by making vault-0 node as the leader then we can unseal it and then later join the rest of the nodes to the cluster and unseal them as well.

Initialize the cluster by making node vault-0 as the leader as shown follows:

$ kubectl exec -ti vault-0 -n vault -- vault operator init

Unseal Key 1: 9LphBlg31dBKuVCoOYRW+zXrS5zpuGeaFDdCWV3x6C9Y
Unseal Key 2: zfWTzDo9nDUIDLuqRAc4cVih1XzuZW8iEolc914lrMyS
Unseal Key 3: 2O3QUiio8x5W+IJq+4ze45Q3INL1Ek/2cHDiNHb3vXIz
Unseal Key 4: DoPBFgPte+Xh6L/EljPc79ZT2mYwQL6IAeDTLiBefwPV
Unseal Key 5: OW1VTaXIMDt0Q57STeI4mTh1uBFPJ2JvmS2vgYAFuCPJ

Initial Root Token: s.rLs6ycvvg97pQNnvnvzNZgAJ

Vault initialized with 5 key shares and a key threshold of 3. Please securely
distribute the key shares printed above. When the Vault is re-sealed,
restarted, or stopped, you must supply at least 3 of these keys to unseal it
before it can start servicing requests.

Vault does not store the generated master key. Without at least 3 keys to
reconstruct the master key, Vault will remain permanently sealed!

It is possible to generate new unseal keys, provided you have a quorum of
existing unseal keys shares. See "vault operator rekey" for more information.

Now we have the keys and the root token. We will use the keys to unseal the nodes and join every node to the cluster.

Step 5: Unsealing the leader node/pod

We will use the unseal keys from the output of above command in Step 4 to unseal Vault as shown below. Run the command three times and supply the keys in the order they have been generated above. After running the command, you will be presented with a prompt where you are required to enter one of the seals generated above. Simply copy and paste one of them and hit enter.

$ kubectl exec -ti vault-0 -n vault -- vault operator unseal

Unseal Key (will be hidden): <Enter Unseal Key 1>
Key                Value
---                -----
Seal Type          shamir
Initialized        true
Sealed             true
Total Shares       5
Threshold          3
Unseal Progress    1/3
Unseal Nonce       f4c34433-6ef1-59ca-c1c9-1a6cc0dfabff
Version            1.8.4
Storage Type       raft
HA Enabled         true

Run it the second time

$ kubectl exec -ti vault-0 -n vault -- vault operator unseal

Unseal Key (will be hidden): <Enter Unseal Key 2>
Key                Value
---                -----
Seal Type          shamir
Initialized        true
Sealed             true
Total Shares       5
Threshold          3
Unseal Progress    2/3
Unseal Nonce       f4c34433-6ef1-59ca-c1c9-1a6cc0dfabff
Version            1.8.4
Storage Type       raft
HA Enabled         true

Run it again the third time

$ kubectl exec -ti vault-0 -n vault -- vault operator unseal

Unseal Key (will be hidden): <Enter Unseal Key 3>
Key                     Value
---                     -----
Seal Type               shamir
Initialized             true
Sealed                  false
Total Shares            5
Threshold               3
Version                 1.8.4
Storage Type            raft
Cluster Name            vault-cluster-3d108027
Cluster ID              a75de185-7b51-6045-20ca-5a25ca9d9e70
HA Enabled              true
HA Cluster              n/a
HA Mode                 standby
Active Node Address     <none>
Raft Committed Index    24
Raft Applied Index      24

For now, we have not added the remaining four nodes/pods of vault statefulsets into the cluster. If you check the status of the pods, you will see the they are not ready. Let us confirm that.

$ kubectl get pods -n vault

NAME                                    READY   STATUS    RESTARTS   AGE
vault-0                                 1/1     Running   0          14m
vault-1                                 0/1     Running   0          14m
vault-2                                 0/1     Running   0          14m
vault-3                                 0/1     Running   0          14m
vault-4                                 0/1     Running   0          14m
vault-agent-injector-5c8f78854d-twllz   1/1     Running   0          14m

As you can see, vault-1 through to vault-4 are not ready (0/1)

Step 6: Add the rest of the nodes to the cluster and Unsealing them

This is the step where we are going to add each one of them to the cluster step by step. The procedure is as follows:

  • Add a node to the cluster
  • Then unseal it using the number of threshold shown in the status command above (kubectl exec -ti vault-0 -n vault — vault status). It is 3 in this example.
  • This means we will run the unseal command three times for each node. When you are unsealing, use the same keys that we used for the node 1 above. Let us get rolling.

Join the other nodes to the cluster starting with vault-1 node.

$ kubectl exec -ti vault-1 -n vault --  vault operator raft join --address "http://vault-1.vault-internal:8200" "http://vault-0.vault-internal:8200"     

Key       Value
---       -----
Joined    true

After it has successfully joined, unseal the node three times.

## First Time
$ kubectl exec -ti vault-1 -n vault -- vault operator unseal
Unseal Key (will be hidden): <Enter Unseal Key 1>
Key                Value
---                -----
Seal Type          shamir
Initialized        true
Sealed             true
Total Shares       5
Threshold          3
Unseal Progress    1/3
Unseal Nonce       188f79d8-a87f-efdf-4186-73327ade371a
Version            1.8.4
Storage Type       raft
HA Enabled         true

## Second Time
$ kubectl exec -ti vault-1 -n vault -- vault operator unseal
Unseal Key (will be hidden): <Enter Unseal Key 2>
Key                Value
---                -----
Seal Type          shamir
Initialized        true
Sealed             true
Total Shares       5
Threshold          3
Unseal Progress    2/3
Unseal Nonce       188f79d8-a87f-efdf-4186-73327ade371a
Version            1.8.4
Storage Type       raft
HA Enabled         true

## Third Time
$ kubectl exec -ti vault-1 -n vault -- vault operator unseal
Unseal Key (will be hidden): <Enter Unseal Key 3>
Key                Value
---                -----
Seal Type          shamir
Initialized        true
Sealed             true
Total Shares       5
Threshold          3
Unseal Progress    0/3
Unseal Nonce       n/a
Version            1.8.4
Storage Type       raft
HA Enabled         true

Then join node vault-2 to the cluster and then unseal it just like it was done in node vault-1

$ kubectl exec -ti vault-2 -n vault --  vault operator raft join --address "http://vault-2.vault-internal:8200" "http://vault-0.vault-internal:8200"

Key       Value
---       -----
Joined    true

Unseal node vault-2 three times entering one of the 3 keys on each run

##First Time
$ kubectl exec -ti vault-2 -n vault -- vault operator unseal

Unseal Key (will be hidden): <Enter Unseal Key 1>
Key                Value
---                -----
Seal Type          shamir
Initialized        true
Sealed             true
Total Shares       5
Threshold          3
Unseal Progress    1/3
Unseal Nonce       60ab7a6a-e7dc-07c8-e73c-11c55bafc199
Version            1.8.4
Storage Type       raft
HA Enabled         true

##Second time
$ kubectl exec -ti vault-2 -n vault -- vault operator unseal
Unseal Key (will be hidden): <Enter Unseal Key 2>
Key                Value
---                -----
Seal Type          shamir
Initialized        true
Sealed             true
Total Shares       5
Threshold          3
Unseal Progress    2/3
Unseal Nonce       60ab7a6a-e7dc-07c8-e73c-11c55bafc199
Version            1.8.4
Storage Type       raft
HA Enabled         true

##Third Time
$ kubectl exec -ti vault-2 -n vault -- vault operator unseal
Unseal Key (will be hidden): <Enter Unseal Key 3>
Key                Value
---                -----
Seal Type          shamir
Initialized        true
Sealed             true
Total Shares       5
Threshold          3
Unseal Progress    0/3
Unseal Nonce       n/a
Version            1.8.4
Storage Type       raft
HA Enabled         true

Do the same for the remaining pods/nodes in your cluster that are still not ready. Simply check your pods as follows

$ kubectl get pods -n vault

NAME                                    READY   STATUS    RESTARTS   AGE
vault-0                                 1/1     Running   0          14m
vault-1                                 1/1     Running   0          14m
vault-2                                 1/1     Running   0          14m
vault-3                                 0/1     Running   0          14m
vault-4                                 0/1     Running   0          14m
vault-agent-injector-5c8f78854d-twllz   1/1     Running   0          14m

The ones with 0/1 are not yet ready so join them to the cluster and unseal them.

Add Node vault-3

$ kubectl exec -ti vault-3 -n vault --  vault operator raft join --address "http://vault-3.vault-internal:8200" "http://vault-0.vault-internal:8200"

Key       Value
---       -----
Joined    true

Unseal Node vault-3 three times again.

##First Time
$ kubectl exec -ti vault-3 -n vault -- vault operator unseal

Unseal Key (will be hidden): <Enter Unseal Key 1>
Key                Value
---                -----
Seal Type          shamir
Initialized        true
Sealed             true
Total Shares       5
Threshold          3
Unseal Progress    1/3
Unseal Nonce       733264c0-bfc6-6869-a3dc-167e642ad624
Version            1.8.4
Storage Type       raft
HA Enabled         true

##Second Time
$ kubectl exec -ti vault-3 -n vault -- vault operator unseal

Unseal Key (will be hidden): <Enter Unseal Key 2>
Key                Value
---                -----
Seal Type          shamir
Initialized        true
Sealed             true
Total Shares       5
Threshold          3
Unseal Progress    2/3
Unseal Nonce       733264c0-bfc6-6869-a3dc-167e642ad624
Version            1.8.4
Storage Type       raft
HA Enabled         true

##Third Time
$ kubectl exec -ti vault-3 -n vault -- vault operator unseal

Unseal Key (will be hidden): <Enter Unseal Key 3>
Key                Value
---                -----
Seal Type          shamir
Initialized        true
Sealed             true
Total Shares       5
Threshold          3
Unseal Progress    0/3
Unseal Nonce       n/a
Version            1.8.4
Storage Type       raft
HA Enabled         true

Add Node vault-4

$ kubectl exec -ti vault-4 -n vault --  vault operator raft join --address "http://vault-4.vault-internal:8200" "http://vault-0.vault-internal:8200"

Key       Value
---       -----
Joined    true

Unseal Node vault-4 three times once more.

##First Time
$ kubectl exec -ti vault-4 -n vault -- vault operator unseal

Unseal Key (will be hidden): <Enter Unseal Key 1>
Key                Value
---                -----
Seal Type          shamir
Initialized        true
Sealed             true
Total Shares       5
Threshold          3
Unseal Progress    1/3
Unseal Nonce       543e3a67-28f9-9730-86ae-4560d48c2f2e
Version            1.8.4
Storage Type       raft
HA Enabled         true

##Second Time
$ kubectl exec -ti vault-4 -n vault -- vault operator unseal

Unseal Key (will be hidden): <Enter Unseal Key 2>
Key                Value
---                -----
Seal Type          shamir
Initialized        true
Sealed             true
Total Shares       5
Threshold          3
Unseal Progress    2/3
Unseal Nonce       543e3a67-28f9-9730-86ae-4560d48c2f2e
Version            1.8.4
Storage Type       raft
HA Enabled         true

##Third Time
$ kubectl exec -ti vault-4 -n vault -- vault operator unseal

Unseal Key (will be hidden): <Enter Unseal Key 3>
Key                Value
---                -----
Seal Type          shamir
Initialized        true
Sealed             true
Total Shares       5
Threshold          3
Unseal Progress    0/3
Unseal Nonce       n/a
Version            1.8.4
Storage Type       raft
HA Enabled         true

Now lets check our pods

$ kubectl get pods -n vault
NAME                                    READY   STATUS    RESTARTS   AGE
vault-0                                 1/1     Running   0          32m
vault-1                                 1/1     Running   0          32m
vault-2                                 1/1     Running   0          32m
vault-3                                 1/1     Running   0          32m
vault-4                                 1/1     Running   0          32m
vault-agent-injector-5c8f78854d-twllz   1/1     Running   0          32m

Beautiful. You can see that all of them are successfully ready. And we are finally done with our vault ha cluster setup in GKE platform. Next, we shall cover how to authenticate to Kubernetes/GKE, create secrets then launch a sample app and inject secrets to the pod via sidecar model.

We hope the document provides insight and has been helpful for your use case. In case you have an idea of how to auto-unseal, kindly point us in the right direction as well.

Lastly, the tremendous support and messages we continue to receive is a blessing and we pray that you continue to prosper in your various endeavours as you change the world. Have an amazing end year season and keep at it, keep the safety and may your hard work yield the fruits you desire 🙂.

Other guides you will enjoy include:

.tdi_4.td-a-rec{text-align:center}.tdi_4 .td-element-style{z-index:-1}.tdi_4.td-a-rec-img{text-align:left}.tdi_4.td-a-rec-img img{margin:0 auto 0 0}@media(max-width:767px){.tdi_4.td-a-rec-img{text-align:center}}

RELATED ARTICLES

Most Popular

Recent Comments