<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0" xmlns:cc="http://cyber.law.harvard.edu/rss/creativeCommonsRssModule.html">
    <channel>
        <title><![CDATA[Stories by Samuel Michael on Medium]]></title>
        <description><![CDATA[Stories by Samuel Michael on Medium]]></description>
        <link>https://medium.com/@micsamkid500?source=rss-5bd4345bedb5------2</link>
        <image>
            <url>https://cdn-images-1.medium.com/fit/c/150/150/0*-ThtPYIa9yxK7E1G.jpg</url>
            <title>Stories by Samuel Michael on Medium</title>
            <link>https://medium.com/@micsamkid500?source=rss-5bd4345bedb5------2</link>
        </image>
        <generator>Medium</generator>
        <lastBuildDate>Sun, 15 Nov 2026 12:17:14 GMT</lastBuildDate>
        <atom:link href="https://medium.com/@micsamkid500/feed" rel="self" type="application/rss+xml"/>
        <webMaster><![CDATA[yourfriends@medium.com]]></webMaster>
        <atom:link href="http://medium.superfeedr.com/" rel="hub"/>
        <item>
            <title><![CDATA[Using Terraform and Ansible roles to deploy and configure SQL Server on EC2]]></title>
            <link>https://medium.com/@micsamkid500/using-terraform-and-ansible-roles-to-deploy-and-configure-sql-server-on-ec2-5eed58e4c9db?source=rss-5bd4345bedb5------2</link>
            <guid isPermaLink="false">https://medium.com/p/5eed58e4c9db</guid>
            <category><![CDATA[aws-ec2]]></category>
            <category><![CDATA[mysql]]></category>
            <category><![CDATA[ansible]]></category>
            <category><![CDATA[ansible-roles]]></category>
            <category><![CDATA[terraform]]></category>
            <dc:creator><![CDATA[Samuel Michael]]></dc:creator>
            <pubDate>Thu, 12 Nov 2026 19:01:34 GMT</pubDate>
            <atom:updated>2026-11-12T19:01:34.543Z</atom:updated>
            <content:encoded><![CDATA[<p>GitHub Link — <a href="https://github.com/Michael2921/ansible-exercises/tree/ansible-web-database">https://github.com/Michael2921/ansible-exercises/tree/ansible-web-database</a></p><p><strong>Introduction</strong></p><p>This project uses Terraform and Ansible roles to deploy and configure a MYSQL server, then tests connections to the server using a custom Java application. The database server can only be accessed within the VPC where the web and database servers are deployed. However, the web server has to be publicly accessible on port 8080 to access the web application.</p><p>Checklist</p><p><strong>Ansible server playbook</strong></p><ul><li>Create a Terraform configuration file that meets the project requirements and includes an inventory template file that dynamically generates an Ansible inventory file.</li><li>Configure an Ansible server to deploy database (MySQL) and web (Java) applications.</li><li>Install required packages on the Ansible server</li><li>Copy necessary playbook files to the Ansible server</li><li>Copy application files to the Ansible server</li><li>Copy MySQL role variable files to the Ansible server</li><li>Copy the private key to access the web and database servers</li></ul><p><strong>Database server Playbook</strong></p><ul><li>Use the database playbook to set custom variables for the MySQL role</li></ul><p><strong>Web server playbook</strong></p><ul><li>Install necessary packages</li><li>Copy application files to the server</li><li>Copy inventory file</li><li>Build the application</li><li>Set the environment variable needed to connect to the database server and execute the application.</li></ul><p><strong>Terraform configuration for the database server</strong></p><p>The Terraform configuration provisions 3 servers. It includes an Ansible server, a database server, and a web server. The web server can be accessed externally to access the web application, but the database server cannot. It is only accessible from the web server.</p><p>To do this, create two separate subnets: a public subnet and a private subnet.</p><pre>resource &quot;aws_subnet&quot; &quot;myapp-subnet-1&quot; {<br>  vpc_id = aws_vpc.myapp-vpc.id<br>  cidr_block = var.subnet_cidr_block<br>  availability_zone = var.avail_zone<br>    tags = {<br>    Name: &quot;${var.env_prefix}-subnet-1&quot;<br>  }<br>}<br><br><br>resource &quot;aws_subnet&quot; &quot;myapp-subnet-2&quot; { # private subnet for database server<br>  vpc_id = aws_vpc.myapp-vpc.id<br>  cidr_block = var.subnet2_cidr_block<br>  availability_zone = var.avail_zone<br>    tags = {<br>    Name: &quot;${var.env_prefix}-subnet-private&quot;<br>  }<br>}</pre><p>Create an Elastic IP for the NAT gateway.</p><pre>resource &quot;aws_eip&quot; &quot;nat_eip&quot; { # creates elastic ip for nat gateway to access internet<br>    domain = &quot;vpc&quot;<br>}</pre><p>Create an internet gateway, and associate the Elastic IP and public subnet with it.</p><pre> resource &quot;aws_nat_gateway&quot; &quot;nat_gateway&quot; { # creates a nat gateway and allocates the elastic ip to it<br>    allocation_id =  aws_eip.nat_eip.id<br>    subnet_id = aws_subnet.myapp-subnet-1.id<br><br>}</pre><p>Create a private route table and add a route that includes the CIDR block and NAT gateway route.</p><pre>resource &quot;aws_route_table&quot; &quot;private-rtb&quot; { # creates a route table for the private subnet and associates with the VPC<br>  vpc_id = aws_vpc.myapp-vpc.id<br><br>  route {<br>      cidr_block = &quot;0.0.0.0/0&quot;<br>      nat_gateway_id = aws_nat_gateway.nat_gateway.id<br>  }</pre><p>Create a route table association resource to associate the private route table with the private subnet.</p><pre>resource &quot;aws_route_table_association&quot; &quot;private-rtb-association&quot; {<br>    subnet_id = aws_subnet.myapp-subnet-2.id<br>    route_table_id = aws_route_table.private-rtb.id<br>}</pre><p>Create a private security group that only accepts ingress connections from within the VPC</p><pre>resource &quot;aws_security_group&quot; &quot;private-sg&quot; {<br>  vpc_id = aws_vpc.myapp-vpc.id<br><br>  ingress {<br>    from_port = 22<br>    to_port = 22<br>    protocol = &quot;TCP&quot;<br>    cidr_blocks = [var.vpc_cidr_block]<br>  }<br><br>  ingress {<br>    from_port = 3306<br>    to_port = 3306<br>    protocol = &quot;TCP&quot;<br>    cidr_blocks = [var.vpc_cidr_block]<br>  }<br><br>  egress {<br>    from_port = 0<br>    to_port = 0<br>    protocol = &quot;-1&quot;<br>    cidr_blocks = [&quot;0.0.0.0/0&quot;]<br>    prefix_list_ids = []<br>  }<br><br>  tags = {<br>    Name: &quot;${var.env_prefix}-private-sg&quot;<br>  }<br>}</pre><p>In the &quot;aws_instance&quot; resource for the database server, include the private security group and subnet.</p><pre>resource &quot;aws_instance&quot; &quot;database-server&quot; {<br>  ami = data.aws_ami.latest-amazon-linux-image.id<br>  instance_type = var.instance_type<br><br>  subnet_id = aws_subnet.myapp-subnet-2.id<br>  vpc_security_group_ids = [aws_security_group.private-sg.id]<br>  availability_zone = var.avail_zone<br><br>  key_name = aws_key_pair.ssh-key.key_name<br><br>  tags = {<br>    Name: &quot;${var.env_prefix}-database-server&quot;<br><br>  }<br>}</pre><p><strong>Terraform template files</strong></p><p>Since the IP addresses of the provisioned servers cannot be known in advance, the Ansible hosts cannot be hardcoded. Dynamic inventory files can be created using a Terraform template file. After Terraform completes creating the infrastructure, it uses the template file to create and populate the inventory file.</p><p>A &quot;local file&quot; resource reads the template file and creates the inventory.ini file. It uses the content parameter to load the template file, and uses the filename parameter to specify the path where the inventory file will be created. The &quot;local_file&quot; resource also includes variables assigned the server&#39;s IP address resource attribute. The template uses these variables to populate the inventory file.</p><p>&quot;local_file&quot; resource</p><pre>resource &quot;local_file&quot; &quot;ansible_inventory&quot; {<br>  filename = &quot;../ansible/ansible-config/inventory.ini&quot;<br><br>  content = templatefile(&quot;inventory.ini.tftpl&quot;, {<br><br>    ip_one = aws_instance.ansible-server.public_ip<br>    ip_two = aws_instance.database-server.private_ip<br>    ip_three = aws_instance.web-server.public_ip<br>    ip_three_private = aws_instance.web-server.private_ip<br><br><br>  }<br>  <br>  )<br><br><br>}</pre><p>Inventory file template</p><pre>[ansible-server]<br>server1 ansible_host=${ip_one} ansible_user=ec2-user<br><br>[database-server]<br>server2 ansible_host=${ip_two} ansible_user=ec2-user ansible_python_interpreter=/usr/bin/python3.9<br><br>[web-server]<br>server3 ansible_host=${ip_three_private} ansible_user=ec2-user<br><br><br>[web-server:vars]<br>db_server=${ip_two}<br><br><br>[database-server:vars]<br>web_server=${ip_three_private}</pre><p>Example inventory file</p><pre>[ansible-server]<br>server1 ansible_host=13.221.43.107 ansible_user=ec2-user<br><br>[database-server]<br>server2 ansible_host=10.0.20.230 ansible_user=ec2-user ansible_python_interpreter=/usr/bin/python3.9<br><br>[web-server]<br>server3 ansible_host=10.0.10.105 ansible_user=ec2-user<br><br><br>[web-server:vars]<br>db_server=10.0.20.230<br><br><br>[database-server:vars]<br>web_server=10.0.10.105</pre><p><strong>Setting up the Ansible server</strong></p><p>Install required packages.</p><pre>- name: Install required packages<br>  become: yes<br>  hosts: ansible-server<br>  tasks:<br>    - name: Update packages<br>      dnf:<br>        update_cache: yes<br><br>    - name: Install ansible<br>      dnf:<br>        name: ansible<br>        state: present<br><br><br>    - name: Install geerlingguy.mysql role<br>      community.general.ansible_galaxy_install:<br>        type: role<br>        name: geerlingguy.mysql<br>        dest: /home/ec2-user/.ansible/roles</pre><p>This play installs and updates the cache with dnf <a href="https://docs.ansible.com/projects/ansible/latest/collections/ansible/builtin/dnf_module.html">using the &quot;dnf&quot; module.</a> The next task installs Ansible on the server, and the last task installs <a href="https://galaxy.ansible.com/ui/standalone/roles/geerlingguy/mysql/documentation/">geerlingguy&#39;s MySQL role</a> to configure MySQL on the database server. The project&#39;s GitHub repository is available here: <a href="https://github.com/geerlingguy/ansible-role-mysql">https://github.com/geerlingguy/ansible-role-mysql</a>.</p><p><strong>Copy playbooks to the Ansible server.</strong></p><pre>- name: Copy database config files to Ansible server<br>  hosts: ansible-server<br>  vars_files:<br>    - project-vars<br>  tasks:<br>    - name: Copy database playbook files<br>      copy:<br>        src: &quot;{{ src_path_database }}&quot;<br>        dest: /home/ec2-user/<br>    - name: Copy inventory file<br>      copy:<br>        src: &quot;{{ src_path_inventory }}&quot;<br>        dest: /home/ec2-user/database-config<br><br><br><br>- name: Copy web config files to Ansible server<br>  hosts: ansible-server<br>  vars_files:<br>    - project-vars<br>  tasks:<br>    - name: Copy web playbook files<br>      copy:<br>        src: &quot;{{ src_path_web }}&quot;<br>        dest: /home/ec2-user/<br>    - name: Copy inventory file<br>      copy:<br>        src: &quot;{{ src_path_inventory }}&quot;<br>        dest: /home/ec2-user/web-config</pre><p>These plays <a href="https://docs.ansible.com/projects/ansible/latest/collections/ansible/builtin/copy_module.html">use the &quot;copy&quot; module</a> to copy the playbooks and inventory files to the Ansible server. The inventory files are dynamically generated from the Terraform template file and contain IP addresses for the web server and database servers.</p><p><strong>Copy application files to the Ansible server.</strong></p><pre>- name: Copy java app to Ansible server<br>  hosts: ansible-server<br>  vars_files:<br>    - project-vars<br>  tasks:<br>    - name: Copy java files<br>      copy:<br>        src: &quot;{{ src_path_java_app }}&quot;<br>        dest: /home/ec2-user/</pre><p>This play also uses the &quot;copy&quot; module to copy the application files to the Ansible server.</p><p><strong>Copy modified Geerlingguy&#39;s MySQL vars file to the Ansible server.r</strong></p><pre>- name: Copy RedHat vars file to MySQL roles folder<br>  hosts: ansible-server<br>  become: yes<br>  vars_files:<br>    - project-vars<br>  tasks:<br>    - name: Copy vars file to ansible server<br>      copy: <br>        src: &quot;{{ src_path_redhat2023 }}&quot;<br>        dest: /home/ec2-user/.ansible/roles/geerlingguy.mysql/vars/</pre><p>Loading the Ansible role fails because the code block below searches for the OS-specific variable &quot;{{ ansible_facts.os_family }}-{{ ansible_facts.distribution_major_version }}.yml&quot; in the vars folder. The image name specified in the &quot;terraform.tfvars&quot; file uses Amazon 2023. The OS family does display &quot;RedHat&quot;, but the major version is &quot;2023&quot;, so the full name is &quot;RedHat-2023&quot;. This file is not available in the project&#39;s &quot;vars&quot; section.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*XN3fn4Kk_9Ksd0xAB4CnBg.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*yBboodR5Gm_xTrkOj9uHuw.png" /></figure><p>To work around this, create a &quot;RedHat-2023.yml&quot; file and copy it to the vars section in the user&#39;s home folder at &quot;/home/ec2-user/.ansible/roles/geerlingguy.mysql/vars/&quot;. This allows the tasks section to find &quot;RedHat-2023.yml&quot; and execute code from that configuration file instead.</p><p>Original RedHat-10.yml file</p><pre>---<br>__mysql_daemon: mariadb<br>__mysql_packages:<br>  - mariadb<br>  - mariadb-server<br>  - mariadb-connector-c<br>  - python3-PyMySQL<br>  - perl-DBD-MySQL<br>__mysql_slow_query_log_file: /var/log/mysql-slow.log<br>__mysql_log_error: /var/log/mariadb/mariadb.log<br>__mysql_syslog_tag: mariadb<br>__mysql_pid_file: /var/run/mariadb/mariadb.pid<br>__mysql_config_file: /etc/my.cnf<br>__mysql_config_include_dir: /etc/my.cnf.d<br>__mysql_socket: /var/lib/mysql/mysql.sock<br># The entries controlled by this value should not be used with MariaDB &gt;= 10.2.2<br># See https://github.com/frappe/bench/issues/681#issuecomment-398984706<br>__mysql_supports_innodb_large_prefix: false</pre><p>RedHat-2023.yml (modified to work on Amazon Red Hat Linux)</p><pre>- mariadb123-client-utils.noarch<br>  - mariadb123-server-utils.noarch<br>  - mariadb-connector-c</pre><p>The RedHat-2023.yml file was updated to include packages specific to Amazon Linux, like mariadb123-client-utils.noarch, mariadb123-server-utils.noarch, and mariadb-connector-c</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*8BW6WwZd49ulIQeDvQ2K7Q.png" /></figure><p><strong>Copy the private key for the database and web server to the Ansible server</strong></p><pre>- name: Copy private key for database and web server to Ansible server<br>  hosts: ansible-server<br>  vars_files:<br>    - project-vars<br>  tasks:<br>  - name: Copy private key<br>    copy:<br>      src: &quot;{{ src_path_private_key }}&quot;<br>      dest: /home/ec2-user/.ssh/<br>      mode: &#39;0600&#39;</pre><p>The private key was copied to the ec2-user home directory and set to mode 600, so Amazon accepts it as a valid key file for accessing the database server and web server.</p><p><strong>Configure Database server</strong></p><figure><img alt="" src="https://cdn-images-1.medium.com/max/852/1*WHskLwWLF0gDUoSK6Oa7PA.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*pOfHyNIgdDTzmdrEPlgTfw.png" /></figure><p><strong>Install packages required to run MySQL.</strong></p><pre>- name: Install MYSQL packages<br>  hosts: database-server<br>  become: yes<br>  tasks:<br>    - name: Install pip<br>      dnf:<br>        name: pip<br>        state: present<br><br>    - name: Install PyMySQL<br>      pip:<br>        name: PyMySQL</pre><p>This play installs pip, and then uses the &quot;pip&quot; module to install PyMySQL, an important component for running MySQL on the server.</p><p><strong>Configure MySQL server</strong></p><pre>- name: Configure MSQL Database server<br>  hosts: database-server<br>  become: yes<br>  vars_files:<br>    - project-vars<br><br>    # default root user does intial setup and creates sql_users<br><br>  vars:<br><br>    mysql_port: &quot;3306&quot;<br>    mysql_bind_address: &quot;0.0.0.0&quot;<br><br>    mysql_databases:<br>      - name: example_db<br><br>    mysql_users:<br>      - name: appuser<br>        password: &quot;{{ sql_password }}&quot;<br>        host: &quot;{{ web_server }}&quot;     #&quot;%&quot; from web_server vars in inventory.ini<br>        priv: &quot;example_db.*:ALL&quot;<br><br><br>  roles:<br>    - geerlingguy.mysql</pre><p>The default values for running the MySQL server can be found here-<a href="https://github.com/geerlingguy/ansible-role-mysql/blob/master/defaults/main.yml">https://github.com/geerlingguy/ansible-role-mysql/blob/master/defaults/main.yml</a>, but the values can be customized or overridden for specific purposes. That is what the play above does. It sets the mysql_port and the bind address. It also provides the database to create on initialization. By default, the root user creates the SQL server, creates the user defined in &quot;mysql_users&quot; using the password in &quot;project-vars&quot;, and uses the database variable “web_server” defined in the inventory.ini file.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*f9ICElMG0pzypZm5fC8X1Q.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*2-bkHp9Nq3ITNhmXrQWp1w.png" /></figure><p><strong>Configure web server</strong></p><p><strong>Install required packages</strong></p><pre>- name: Install required packages<br>  become: yes<br>  hosts: web-server<br>  tasks:<br>    - name: Update packages<br>      dnf:<br>        update_cache: yes<br><br>    - name: Install java<br>      dnf:<br>        name: java-17-amazon-corretto-devel<br>        state: present<br><br>- name: Install Gradle<br>  hosts: web-server<br>  tasks:<br>    - name: Install SDK<br>      shell: &#39;curl -s &quot;https://get.sdkman.io&quot; | bash&#39;<br><br>    - name: Install gradle<br>      shell: |<br>        source /home/ec2-user/.sdkman/bin/sdkman-init.sh<br>        sdk install gradle  </pre><p>This play uses the &quot;dnf&quot; module to update the cache, install the JDK, SDK, and Gradle.</p><p><strong>Copy the application folder and inventory file to the web server</strong></p><pre>- name: Copy Java files to web-server<br>  hosts: web-server<br>  vars_files:<br>    - project-vars<br>  tasks:<br>    - name: Copy java files to web-server<br>      copy:<br>        src: /home/ec2-user/java-app<br>        dest: /home/ec2-user/<br><br><br><br><br>- name: Copy inventory file # to get ip of database server<br>  hosts: web-server<br>  tasks:<br>    - name: Copy .ini file<br>      copy:<br>        src: /home/ec2-user/web-config/inventory.ini<br>        dest: /home/ec2-user/java-app/</pre><p>These plays copy the application files and the inventory file to the web server; the inventory file variables are used to access the database server.</p><p><strong>Build Java file</strong></p><pre>- name: Build the java file<br>  hosts: web-server<br>  tasks:<br><br>    - name: change directory and run gradle build<br>      command: &quot;gradle build -p /home/ec2-user/java-app/&quot;<br>      register: build_output<br><br>    - name: Display build build output<br>      ansible.builtin.debug:<br>        var: build_output.stdout_lines</pre><p><strong>Set environment variables and run the JAR file.</strong></p><pre>- name: Set env variables for MYSQL connection<br>  hosts: web-server<br>  vars_files:<br>    - project-vars<br><br>  environment:<br>    DB_SERVER: &quot;{{ db_server }}&quot;<br>    DB_NAME: example_db<br>    DB_USER: appuser<br>    DB_PWD: &quot;{{ sql_password }}&quot;<br>  <br>  tasks:<br><br>    - name: Execute java file<br>      shell: &quot;java -jar /home/ec2-user/java-app/build/libs/build-tools-exercises-1.0-SNAPSHOT.jar&quot;</pre><p>The environment block sets DB_SERVER, DB_PWD, DB_USER, and DB_NAME. DB_NAME and DB_USER were already created on the SQL server by deploying the database playbook using the Ansible MySQL role. DB_SERVER comes from inventory.ini, and DB_PWD comes from project-vars.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*NaATxo3-043ePzeOLM44Nw.png" /></figure><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=5eed58e4c9db" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Using Terraform and Ansible to provision and configure a Nexus server.]]></title>
            <link>https://medium.com/@micsamkid500/using-terraform-and-ansible-to-provision-and-configure-a-nexus-server-34810f1b113a?source=rss-5bd4345bedb5------2</link>
            <guid isPermaLink="false">https://medium.com/p/34810f1b113a</guid>
            <category><![CDATA[automation]]></category>
            <category><![CDATA[api]]></category>
            <category><![CDATA[nexus]]></category>
            <category><![CDATA[ansible]]></category>
            <category><![CDATA[terraform]]></category>
            <dc:creator><![CDATA[Samuel Michael]]></dc:creator>
            <pubDate>Tue, 10 Nov 2026 19:49:34 GMT</pubDate>
            <atom:updated>2026-11-10T19:49:34.429Z</atom:updated>
            <content:encoded><![CDATA[<p>GitHub Link — <a href="https://github.com/Michael2921/ansible-exercises.git">https://github.com/Michael2921/ansible-exercises.git</a></p><p><strong>Introduction</strong></p><p>This project uses Terraform to provision an EC2 instance and Ansible to set up a Nexus server by installing required packages, automating setup via the Nexus REST APIs, and deploying a custom Java application to the Maven snapshot repository.</p><p><strong>Checklist</strong></p><ul><li>Use Terraform to provision an EC2 instance on AWS</li><li>Install tools required to build the application</li><li>Move the Java application to the Nexus server and build it</li><li>Download and unpack the Nexus installer</li><li>Create a Nexus user and group, and make the Nexus user the owner of both Nexus folders.</li><li>Start Nexus and verify it runs</li><li>Get the Nexus server&#39;s external IP</li><li>Change the initial admin password</li><li>Accept Nexus EULA</li><li>Create Nexus role</li><li>Create Nexus user</li><li>Push JAR file to the repo</li></ul><p><strong>Provision an EC2 instance with Terraform</strong></p><pre>provider &quot;aws&quot; {<br>  region = &quot;us-east-1&quot;<br>}<br><br>variable vpc_cidr_block {}<br>variable subnet_cidr_block {}<br>variable avail_zone {}<br>variable env_prefix {}<br>variable my_ip {}<br>variable instance_type {}<br>variable public_key_location {}<br>variable image_name {}<br>variable ssh_key_private {}<br><br>resource &quot;aws_vpc&quot; &quot;myapp-vpc&quot; {<br>  cidr_block = var.vpc_cidr_block<br>  enable_dns_hostnames = true<br>  tags = {<br>    Name: &quot;${var.env_prefix}-vpc&quot;<br>  }<br>}<br><br>resource &quot;aws_subnet&quot; &quot;myapp-subnet-1&quot; {<br>  vpc_id = aws_vpc.myapp-vpc.id<br>  cidr_block = var.subnet_cidr_block<br>  availability_zone = var.avail_zone<br>    tags = {<br>    Name: &quot;${var.env_prefix}-subnet-1&quot;<br>  }<br>}<br><br>resource &quot;aws_internet_gateway&quot; &quot;myapp-igw&quot; {<br>  vpc_id = aws_vpc.myapp-vpc.id<br>  tags = {<br>    Name: &quot;${var.env_prefix}-igw&quot;<br>  }<br>}<br><br>resource &quot;aws_default_route_table&quot; &quot;main-rtb&quot; {<br>  default_route_table_id = aws_vpc.myapp-vpc.default_route_table_id<br><br>  route {<br>    cidr_block = &quot;0.0.0.0/0&quot;<br>    gateway_id = aws_internet_gateway.myapp-igw.id<br>  }<br>  tags = {<br>    Name: &quot;${var.env_prefix}-main-rtb&quot;<br>  }<br>}<br><br>resource &quot;aws_default_security_group&quot; &quot;default-sg&quot; {<br>  vpc_id = aws_vpc.myapp-vpc.id<br><br>  ingress {<br>    from_port = 22<br>    to_port = 22<br>    protocol = &quot;TCP&quot;<br>    cidr_blocks = [var.my_ip]<br>  }<br><br>  ingress {<br>    from_port = 8080<br>    to_port = 8080<br>    protocol = &quot;TCP&quot;<br>    cidr_blocks = [&quot;0.0.0.0/0&quot;]<br>  }<br><br>  ingress {<br>    from_port = 8081<br>    to_port = 8081<br>    protocol = &quot;TCP&quot;<br>    cidr_blocks = [&quot;0.0.0.0/0&quot;]<br>  }<br><br>  egress {<br>    from_port = 0<br>    to_port = 0<br>    protocol = &quot;-1&quot;<br>    cidr_blocks = [&quot;0.0.0.0/0&quot;]<br>    prefix_list_ids = []<br>  }<br><br>  tags = {<br>    Name: &quot;${var.env_prefix}-default-sg&quot;<br>  }<br>}<br><br>data &quot;aws_ami&quot; &quot;latest-amazon-linux-image&quot; {<br>  most_recent = true<br>  owners = [&quot;amazon&quot;]<br>  filter {<br>    name = &quot;name&quot; <br>    values = [var.image_name]<br>  }<br>  filter {<br>    name = &quot;virtualization-type&quot;<br>    values = [&quot;hvm&quot;]<br>  }<br>}<br><br>output &quot;aws-ami_id&quot; {<br>  value = data.aws_ami.latest-amazon-linux-image.id<br>}<br><br>output &quot;ec2-public_ip_1&quot; {<br>  value = aws_instance.myapp-server-one.public_ip<br>}<br><br><br>resource &quot;aws_key_pair&quot; &quot;ssh-key&quot; {<br>  key_name = &quot;server-key&quot;<br>  public_key = file(var.public_key_location)<br>}<br><br>resource &quot;aws_instance&quot; &quot;myapp-server-one&quot; {<br>  ami = data.aws_ami.latest-amazon-linux-image.id<br>  instance_type = var.instance_type<br><br>  subnet_id = aws_subnet.myapp-subnet-1.id<br>  vpc_security_group_ids = [aws_default_security_group.default-sg.id]<br>  availability_zone = var.avail_zone<br><br>  associate_public_ip_address = true<br>  key_name = aws_key_pair.ssh-key.key_name<br><br>  tags = {<br>    Name: &quot;${var.env_prefix}-server-1&quot;<br>  }<br><br>  <br><br>}<br><br>resource &quot;null_resource&quot; &quot;configure_server&quot; {<br><br>    triggers = {<br>        trigger = aws_instance.myapp-server-one.public_ip<br>    }<br><br>    provisioner &quot;local-exec&quot; {<br>        working_dir = &quot;../ansible&quot;<br>        command = &quot;ansible-playbook --inventory ${aws_instance.myapp-server-one.public_ip}, --private-key ${var.ssh_key_private} --user ec2-user ../ansible/deploy_nexus.yaml&quot;<br>    }<br><br><br><br>}<br><br><br><br></pre><p>The Terraform configuration creates an Amazon Linux image and uses a null resource with a &quot;local-exec&quot; provisioner to run the &quot;ansible-playbook&quot; command after the instance is created. An ingress rule for port 8081 is added to the security group because the Nexus web server is accessible on port 8081 by default. The inventory parameter in &quot;ansible-playbook&quot; is set to the instance&#39;s public IP, and the null_resource is set to trigger when the public IP changes.</p><p><strong>Install tools required to build the application.</strong></p><pre>- name: Install Java and Net-tools<br>  hosts: all<br>  become: yes<br>  tasks:<br>    - name: Update dnf repo<br>      dnf:<br>        update_cache: true<br>    - name: Install Java<br>      dnf:<br>        name: java-17-amazon-corretto-devel.x86_64<br>        state: present<br>    - name: Install Net-tools<br>      dnf:<br>        name: net-tools.x86_64<br>        state: present<br><br><br>- name: Install Gradle<br>  hosts: all<br>  tasks:<br>    - name: Install SDK<br>      shell: &#39;curl -s &quot;https://get.sdkman.io&quot; | bash&#39;<br><br>    - name: Install gradle<br>      shell: |<br>        source /home/ec2-user/.sdkman/bin/sdkman-init.sh<br>        sdk install gradle  </pre><p>Gradle is required to build the application and run Sonatype Nexus. A JDK (Java Development Kit) must be installed over a JRE, as the JDK compiler is needed to build the Java application with Gradle.</p><p>The first task from the first play uses <a href="https://docs.ansible.com/projects/ansible/latest/collections/ansible/builtin/dnf_module.html">the ansible dnf module</a> to update the cache and install the Java 17 JDK. Net-tools is also installed to make the &quot;netstat&quot; command available, so network connections can be checked to determine what port Nexus is running on.</p><p>The second play <a href="https://docs.gradle.org/current/userguide/installation.html">uses SDKMAN to install Gradle on Amazon Linux</a>, a method recommended in the Gradle user manual. SDKMAN is installed using curl, and the second task loads and initializes the SDKMAN environment into the current shell session so the sdk commands are available.</p><p><strong>Move the Java application to the Nexus server and build it</strong></p><pre>- name: Copy Java files to web-server<br>  hosts: all<br>  tasks:<br>    - name: Copy java files to web-server<br>      copy:<br>        src: ../java-app # without trailing /, it creates the folder and copies contents into it. with / it copies contents without creating the folder<br>        dest: /home/ec2-user/<br><br><br>- name: Build the java file<br>  hosts: all<br>  tasks:<br><br>    - name: Run gradle build<br>      command: &quot;gradle build -p /home/ec2-user/java-app/&quot;<br>      register: build_output<br><br>    - name: Display build build output<br>      ansible.builtin.debug:<br>        var: build_output.stdout_lines</pre><p>The first play <a href="https://docs.ansible.com/projects/ansible/latest/collections/ansible/builtin/copy_module.html">uses the ansible copy module</a> to transfer files from one location to another. In this case, it transfers the application folder from the Ansible server to the Nexus server at &quot;/home/ec2-user&quot;.</p><p>The second play <a href="https://docs.ansible.com/projects/ansible/latest/collections/ansible/builtin/command_module.html">uses the Ansible command module</a> to build the application, then uses the debug module to output the build result.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*GGybtEcWa4uz2kEbDdNcIQ.png" /></figure><p><strong>Download and unpack the Nexus installer.</strong></p><pre>- name: Download and unpack Nexus installer<br>  hosts: all<br>  become: yes<br>  tasks:<br><br>    - name: Get nexus folder stats<br>      stat:<br>        path: /opt/nexus<br>      register: stat_result<br><br>    - name: Download Nexus<br>      get_url:<br>        url: https://download.sonatype.com/nexus/3/latest-linux-x86_64.tar.gz<br>        dest: /opt/<br>      register: download_result<br><br>    - name: Display download destination<br>      ansible.builtin.debug:<br>        var: download_result.dest<br><br>    - name: Untar downloaded file<br>      unarchive:<br>        src: &quot;{{ download_result.dest }}&quot;<br>        dest: /opt/<br>        remote_src: yes<br>      when: not stat_result.stat.exists<br><br>    - name: Find nexus folder<br>      find:<br>        paths: /opt/<br>        pattern: &quot;nexus-*&quot;<br>        file_type: directory<br>      register: find_result<br><br><br>    - name: Rename nexus folder<br>      shell: mv {{ find_result.files[0].path }} /opt/nexus<br>      when: not stat_result.stat.exists</pre><p>The <a href="https://docs.ansible.com/projects/ansible/latest/collections/ansible/builtin/set_stats_module.html">Ansible stat module</a> is used to get details about a file or folder. Nexus will be downloaded in the &quot;/opt&quot; directory, so details of that path need to be collected and registered to create conditionals, so rerunning the playbook doesn&#39;t overwrite the downloaded or unarchived folder, which may have unintended consequences and negatively affect the Nexus installation and configuration. Downloading files into the &quot;/opt&quot; directory requires root privileges, so &quot;become:yes&quot; switches from the regular &quot;ec2-user&quot; to root.</p><p>The second task <a href="https://docs.ansible.com/projects/ansible/latest/collections/ansible/builtin/get_url_module.html">uses the Ansible &quot;get_url&quot; module</a> to download a file, using &quot;dest&quot; as a parameter for the URL, into a destination folder.</p><p>The fourth task untars the downloaded tar file <a href="https://docs.ansible.com/projects/ansible/latest/collections/ansible/builtin/unarchive_module.html">using the Ansible &quot;unarchive&quot; module</a>. It uses the &quot;src&quot; and &quot;dest&quot; parameters, and the &quot;remote_src&quot; parameter specifies that the source and destination are on the same remote server, instead of searching for the file on the local filesystem. The results from the &quot;get_url&quot; module were stored in a variable, &quot;download_result&quot;. An attribute from that variable is then assigned to &quot;src&quot;. Setting this value dynamically is more reliable than hardcoding the path because it ensures that if the path changes, the untar command can use the new file path. A conditional at the end of the tasks prevents the task from running if the Nexus path already exists.</p><p>When the Nexus tar file is downloaded, it is named like &quot;nexus-xxxx&quot; where xxxx is the version number. Since the latest version is being downloaded, the exact number is likely to change over time. The fifth task <a href="https://docs.ansible.com/projects/ansible/latest/collections/ansible/builtin/find_module.html">uses the Ansible &quot;find&quot; module</a> to find the Nexus folder name, registers it into a variable, and <a href="https://docs.ansible.com/projects/ansible/latest/collections/ansible/builtin/shell_module.html">uses the Ansible &quot;shell&quot; module</a> to rename it to &quot;/opt/nexus&quot; so subsequent operations use the same path to refer to the same Nexus folder.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*W-FJS2JkM-ZkIdWKdes8dQ.png" /></figure><p><strong>Create a Nexus user and group, and make the Nexus user the owner of both Nexus folders.</strong></p><pre>- name: Make nexus user owner of nexus folders<br>  hosts: all<br>  become: yes<br>  tasks:<br><br>    - name: Create nexus group<br>      group:<br>        name: nexus<br>        state: present<br><br>    - name: Create nexus user<br>      user:<br>        name: nexus<br>        group: nexus<br><br>    - name: Make nexus user owner of nexus folder<br>      file:<br>        path: /opt/nexus<br>        state: directory<br>        owner: nexus<br>        group: nexus<br>        recurse: yes<br><br>    - name: Make nexus user owner of sonatype-work folder<br>      file:<br>        path: /opt/sonatype-work<br>        state: directory<br>        owner: nexus<br>        group: nexus<br>        recurse: yes</pre><p>The first task <a href="https://docs.ansible.com/projects/ansible/latest/collections/ansible/builtin/group_module.html">uses the Ansible &quot;group&quot; module</a> to create a new group on the server, using the &quot;name&quot; and &quot;state&quot; parameters. Since root permissions are needed to create a group and a user, &quot;become: yes&quot; is added at the play level.</p><p>The second task <a href="https://docs.ansible.com/projects/ansible/latest/collections/ansible/builtin/user_module.html">uses the Ansible &quot;user&quot; module</a> to create a new user &quot;nexus&quot; and assigns the user to the nexus group.</p><p>The third and fourth tasks <a href="https://docs.ansible.com/projects/ansible/latest/collections/ansible/builtin/file_module.html">use the Ansible &quot;file&quot; module</a> to set the newly created nexus user as owner of the &quot;nexus&quot; and &quot;sonatype-work&quot; folders. &quot;recurse: yes&quot; applies this to the parent directory and all subdirectories and files.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*hGx7_Hkqs8MEI-Fx1peULw.png" /></figure><p><strong>Start Nexus and verify it runs.</strong></p><pre>- name: Start nexus with nexus user<br>  hosts: all<br>  become: yes<br>  become_user: nexus<br>  tasks:<br><br>    - name: Set run_as_user nexus<br>      blockinfile:<br>        path: /opt/nexus/bin/nexus.rc<br>        create: yes<br>        block: |<br>          run_as_user=&quot;nexus&quot;<br><br>    - name: Start nexus<br>      command: /opt/nexus/bin/nexus start<br><br><br><br>- name: Verify nexus is running<br>  hosts: all<br>  tasks:<br><br><br>    - name: Check nexus process<br>      shell: ps aux | grep nexus<br>      register: nexus_process_status<br>    - debug: msg={{ nexus_process_status.stdout_lines }}<br>    <br>    - name: Wait 30 seconds<br>      pause:<br>        seconds: 30<br><br>    - name: Check with netstat<br>      command: netstat -plnt<br>      register: nexus_network_status<br>    - debug: msg={{ nexus_network_status.stdout_lines }}</pre><p>For security reasons, the Nexus configuration file must be modified so the Nexus process runs as the nexus user, and not the root user. <a href="https://docs.ansible.com/projects/ansible/latest/collections/ansible/builtin/blockinfile_module.html">This can be done with the Ansible &quot;blockinfile&quot; module</a>. In newer versions of Nexus, the &quot;nexus.rc&quot; file is no longer available in &quot;/opt/nexus/bin&quot; and must be created. This is done with the &quot;create: yes&quot; parameter. The &quot;block parameter&quot; specifies what values are written into the file. &quot;run_as_user=nexus&quot; is used to make sure the Nexus service runs under the &quot;nexus&quot; user.</p><p>The next task uses the command module to start Nexus</p><p>The shell and command modules run the &quot;ps&quot; and &quot;netstat&quot; commands to verify that the Nexus service started successfully. The results of both commands are printed to the screen.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*991NIfxO6BUG7daCSRiPwQ.png" /></figure><p><strong>Get Nexus server external IP</strong></p><pre>- name: Get external IP<br>  hosts: all<br>  tasks:<br><br>    - name: Get external public IP<br>      ansible.builtin.uri:<br>        url: https://api.ipify.org<br>        return_content: true<br>      register: public_ip<br>    - debug: msg={{ public_ip.content }}<br><br>    - name: Save nexus URL as a fact<br>      ansible.builtin.set_fact:<br>        nexus_url: &quot;http://{{ public_ip.content | trim }}:8081&quot;<br>    - debug: msg={{ nexus_url }}</pre><p>Since the public IP will most likely change after running Terraform apply, the instance IP must be retrieved dynamically to allow subsequent Nexus configurations. This can be done using the ipify api. This works by <a href="https://docs.ansible.com/projects/ansible/latest/collections/ansible/builtin/uri_module.html">using the Ansible &quot;uri&quot; module</a> to access the &quot;ipify&quot; api, return its contents, and register it to a variable. The second task then sets the &quot;public_ip&quot; variable as an Ansible fact so it can be used in later plays.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*61Sw3NxIPtN37HkyRigYww.png" /></figure><p><strong>Change initial admin password.</strong></p><p>The initial Nexus admin password must be changed before proceeding with further configuration. Since the whole process will be automated, the REST API will be used to configure Nexus. The API reference link can be found here — <a href="https://help.sonatype.com/en/api-reference.html">https://help.sonatype.com/en/api-reference.html</a>.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*nVa9NxuyXMVKnRgORCEFcw.png" /></figure><pre>- name: Change initial admin password<br>  hosts: all<br>  vars_files:<br>    - project-vars<br>  tasks:<br><br>    - name: Retrieve initial admin password<br>      command: cat /opt/sonatype-work/nexus3/admin.password<br>      register: initial_password<br><br>    - name: Change admin password<br>      ansible.builtin.uri:<br>        url: &quot;{{nexus_url}}/service/rest/v1/security/users/admin/change-password&quot;<br>        method: PUT<br>        url_username: admin<br>        url_password: &quot;{{initial_password.stdout}}&quot;<br>        force_basic_auth: true<br>        headers:<br>          Content-Type: text/plain<br>        body: &quot;{{ nexus_adminpass }}&quot;<br>        status_code: 204<br>      no_log: false</pre><p>The first task retrieves the admin password from &quot;/opt/sonatype-work/nexus3/admin.password&quot; and registers it to the &quot;initial_password&quot; variable.</p><p>The next task uses the &quot;uri&quot; module to access the &quot;change-password&quot; API, enters the initial password, and uses the request body to set a new password.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*f1WUFKRhnRRaQ6GddPkyEA.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*FirsL_lm4fmsEHX1QbcIbw.png" /></figure><h4>Accept Nexus EULA</h4><pre>- name: Accept Nexus EULA<br>  hosts: all<br>  vars_files:<br>    - project-vars<br>  tasks:<br><br>    - name: Retrieve Disclaimer<br>      ansible.builtin.uri:<br>        url: &quot;{{ nexus_url }}/service/rest/v1/system/eula&quot;<br>        method: GET<br>        url_username: admin<br>        url_password: &quot;{{ nexus_adminpass }}&quot;<br>        force_basic_auth: true<br>        return_content: true<br>        status_code: 200<br>      register: nexus_eula<br><br>    - name: Accept EULA<br>      ansible.builtin.uri:<br>        url: &quot;{{ nexus_url }}/service/rest/v1/system/eula&quot;<br>        method: POST<br>        url_username: admin<br>        url_password: &quot;{{ nexus_adminpass }}&quot;<br>        force_basic_auth: true<br>        body_format: json<br>        body:<br>          disclaimer: &quot;{{ nexus_eula.json.disclaimer }}&quot;<br>          accepted: true<br>        status_code: [200 ,204]</pre><p>Before applications can be pushed to the repository, the EULA needs to be accepted by the admin user. If this isn&#39;t done, repo pushes will fail with very little detail provided in the output, so accepting the EULA is important. This can also be done with the REST API. The first task retrieves the EULA from the endpoint &quot;/service/rest/v1/system/eula&quot;, returns the content, and registers it as a variable.</p><p>The next task then retrieves the variable, filters the disclaimer from the variable, and sends it back using the REST API with the &quot;accepted&quot; parameter set to &quot;true&quot;. Getting the EULA uses a GET request, and accepting it uses a POST request.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*8CmJ51UxEhwBI2IuXhwH4Q.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*mjx_Ig1pvCMOPpx-kwi6VA.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*06dO-K0C5zFB9g3vs2_zMw.png" /></figure><p><strong>Create Nexus role</strong></p><pre>- name: Create nexus role<br>  hosts: all<br>  vars_files:<br>    - project-vars<br>  tasks:<br><br>    - name:<br>      ansible.builtin.uri:<br>        url: &quot;{{nexus_url}}/service/rest/v1/security/roles&quot;<br>        method: POST<br>        url_username: admin<br>        url_password: &quot;{{nexus_adminpass}}&quot;<br>        force_basic_auth: true<br>        headers:<br>          Content-Type: application/json<br>        body_format: json<br>        body:<br>          id: &quot;maven-snapshots-admin-role&quot;<br>          name: &quot;Maven Snapshots Admin Role&quot;<br>          description: &quot;Full access to Maven Snapshots&quot;<br>          privileges:<br>            -  &quot;nx-repository-view-maven2-maven-snapshots-*&quot;<br>          roles: []<br>        status_code: 200<br>      no_log: false</pre><p>This play uses the &quot;/service/rest/v1/security/roles&quot; API to create a role and assign a privilege to it. Roles must be created, but privileges are built into Nexus and must be assigned to the created roles. Since a Java snapshot will be pushed to the repo, the created role uses the &quot;nx-repository-view-maven2-maven-snapshots-*&quot; which assigns all repo view maven snapshot privileges to the user.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*Gr9lAWRqWl1POXoFjEyXlA.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*KkGmK5zWc0jAgWJ4jMEMrA.png" /></figure><p><strong>Create Nexus user</strong></p><pre>- name: Create new nexus user<br>  hosts: all<br>  vars_files:<br>    - project-vars<br>  tasks:<br><br>    - name:<br>      ansible.builtin.uri:<br>        url: &quot;{{nexus_url}}/service/rest/v1/security/users&quot;<br>        method: POST<br>        url_username: admin<br>        url_password: &quot;{{nexus_adminpass}}&quot;<br>        force_basic_auth: true<br>        headers:<br>          Content-Type: application/json<br>        body_format: json<br>        body:<br>          userId: &quot;nexususer&quot;<br>          firstName: &quot;nexus&quot;<br>          lastName: &quot;user&quot;<br>          emailAddress: &quot;nexuser@nexus.user&quot;<br>          password: &quot;{{nexus_userpass}}&quot;<br>          status: &quot;active&quot;<br>          roles:<br>            - &quot;maven-snapshots-admin-role&quot;<br>        status_code: 200<br>      no_log: false</pre><p>This play uses the &quot;/service/rest/v1/security/users&quot; API to create a new user. The request body includes &quot;userId&quot;, &quot;firstName&quot;, &quot;lastName&quot;, &quot;emailAddress&quot;, &quot;password&quot;, &quot;status&quot;, and &quot;roles&quot;.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*KW2d0Xijv1PViyqfyrgWtQ.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*YaGytgz1JKxxMT0VDy4SPQ.png" /></figure><p><strong>Push JAR file to the repo.</strong></p><pre>- name: Push JAR file to repo<br>  hosts: all<br>  vars_files:<br>    - project-vars<br>  tasks:<br><br># rest API does not support uploads to snapshot repos<br># /&lt;groupId&gt;/&lt;artifactId&gt;/&lt;version&gt;/&lt;artifactId&gt;-&lt;version&gt;.&lt;extension&gt;<br># body_format: json helps with the request size<br># EULA must be accepted before pushing to repo<br><br>    - name: Push JAR file <br>      ansible.builtin.uri:<br>        url: &quot;{{nexus_url}}/repository/maven-snapshots/com/example/{{ artifact_name }}/{{ artifact_version }}/{{ artifact_name }}-{{ artifact_version }}.jar&quot;<br>        method: PUT<br>        url_username: nexususer<br>        url_password: &quot;{{nexus_userpass}}&quot;<br>        force_basic_auth: true<br>        src: &quot;{{ jar_path }}&quot;<br>        body_format: json<br>        remote_src: true<br>        status_code: 201<br>      no_log: false</pre><p>This play uploads the JAR file to the Nexus repository. The REST API does not support pushing to snapshot repos, so a direct request has to be made to the URL using the format &quot;/repository/maven-snapshots/com/example/{{ artifact_name }}/{{ artifact_version }}/{{ artifact_name }}-{{ artifact_version }}.jar&quot;, using the PUT method. The &quot;src&quot; parameter contains the path to the JAR file, and &quot;body_format: json&quot; works for pushing large files.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*w2wYJnoV8QiFh6vHLIK45A.png" /></figure><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=34810f1b113a" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Using Terraform and Ansible to provision an EKS cluster, configure, and deploy applications]]></title>
            <link>https://medium.com/@micsamkid500/using-terraform-and-ansible-to-provision-an-eks-cluster-configure-and-deploy-applications-156ac52477d1?source=rss-5bd4345bedb5------2</link>
            <guid isPermaLink="false">https://medium.com/p/156ac52477d1</guid>
            <category><![CDATA[ansible]]></category>
            <category><![CDATA[aws-eks]]></category>
            <category><![CDATA[kubernetes]]></category>
            <category><![CDATA[terraform]]></category>
            <category><![CDATA[ansible-playbook]]></category>
            <dc:creator><![CDATA[Samuel Michael]]></dc:creator>
            <pubDate>Mon, 09 Nov 2026 20:12:24 GMT</pubDate>
            <atom:updated>2026-11-10T19:10:07.379Z</atom:updated>
            <content:encoded><![CDATA[<p><strong>GitHub Link </strong>— <a href="https://github.com/Michael2921/ansible-exercises.git">https://github.com/Michael2921/ansible-exercises.git</a></p><h4>Introduction</h4><p>This project uses Terraform to provision an EKS cluster, uses Ansible to configure the cluster environment, deploys MYSQL using Helm, and deploys a test Java application that connects to the SQL server.</p><h4>Checklist</h4><ul><li>Use Terraform to provision an EKS cluster</li><li>Use Ansible and Helm to provision and deploy MySQL to the cluster</li><li>Use Ansible to deploy and connect the Java application to the MYSQL server</li></ul><h4>Deploy to EKS cluster with Terraform</h4><p><strong>vpc.tf — Networking configuration</strong></p><pre>provider &quot;aws&quot; {<br>    region = &quot;us-east-1&quot;<br>}<br><br>variable vpc_cidr_block {}<br>variable private_subnet_cidr_blocks {}<br>variable public_subnet_cidr_blocks {}<br>data &quot;aws_availability_zones&quot; &quot;azs&quot; {}<br><br>module &quot;myapp-vpc&quot; {<br>  source  = &quot;terraform-aws-modules/vpc/aws&quot;<br>  version = &quot;6.7.2&quot;<br><br>  name = &quot;myapp-vpc&quot;<br>  cidr = var.vpc_cidr_block<br>  private_subnets = var.private_subnet_cidr_blocks<br>  public_subnets = var.public_subnet_cidr_blocks<br>  azs = data.aws_availability_zones.azs.names<br><br>  enable_nat_gateway = true<br>  single_nat_gateway = true<br>  enable_dns_hostnames = true<br><br>  tags = {<br>    &quot;kubernetes.io/cluster/myapp-eks-cluster&quot; = &quot;shared&quot;<br>  }<br><br>  public_subnet_tags = {<br>    &quot;kubernetes.io/cluster/myapp-eks-cluster&quot; = &quot;shared&quot;<br>    &quot;kubernetes.io/role/elb&quot; = 1<br><br><br>  }<br><br>  private_subnet_tags = {<br>    &quot;kubernetes.io/cluster/myapp-eks-cluster&quot; = &quot;shared&quot;<br>    &quot;kubernetes.io/role/internal-elb&quot; = 1<br><br>}<br>}</pre><p>The networking configuration creates a vpc, and within the VPC specifies a cidr block, private and public subnets..</p><p><strong>eks.tf — Cluster configuration</strong></p><pre>module &quot;eks&quot; {<br>  source  = &quot;terraform-aws-modules/eks/aws&quot;<br>  version = &quot;21.25.0&quot;<br><br>   addons = {<br>    coredns                = {}<br>    eks-pod-identity-agent = {<br>      before_compute = true<br>    }<br>    kube-proxy             = {}<br>    vpc-cni                = {<br>      before_compute = true<br>    }<br>    aws-ebs-csi-driver = {}<br>  }<br>  <br>  name = &quot;myapp-eks-cluster&quot;<br>  kubernetes_version = &quot;1.33&quot;<br><br>  subnet_ids = module.myapp-vpc.private_subnets // outputs<br>  vpc_id = module.myapp-vpc.vpc_id # outputs<br><br>  endpoint_public_access = true<br>  enable_cluster_creator_admin_permissions = true<br><br><br>  eks_managed_node_groups = {<br>        dev = {  # name of the group<br>        ami_type       = &quot;AL2023_x86_64_STANDARD&quot;<br>        instance_types = [&quot;t2.small&quot;]<br><br>        min_size     = 1<br>        max_size     = 3<br>        desired_size = 3<br>        }<br>    }  <br><br>  tags = {<br>    environment = &quot;development&quot;<br>    applicaton = &quot;myapp&quot;<br>  }<br>}<br><br><br>module &quot;aws_ebs_csi_pod_identity&quot; {<br>source = &quot;terraform-aws-modules/eks-pod-identity/aws&quot;<br><br>name = &quot;aws-ebs-csi&quot;<br><br>attach_aws_ebs_csi_policy = true<br><br>associations = {<br>  this = {<br>    cluster_name = module.eks.cluster_name<br>    namespace = &quot;kube-system&quot;<br>    service_account = &quot;ebs-csi-controller-sa&quot;<br><br>  }<br><br>}<br><br>}<br><br><br>resource &quot;null_resource&quot; &quot;configure_cluster&quot; {<br><br>    triggers = {<br>        trigger_one = module.eks.cluster_name<br><br>    }<br><br>    provisioner &quot;local-exec&quot; {<br>        working_dir = &quot;../ansible&quot;<br>        command = &quot;ansible-playbook ../ansible/deploy-kubernetes.yaml&quot;<br>    }<br><br><br>}</pre><p>In the eks.tf configuration file, the EKS cluster is assigned to a private subnet, endpoint public access is enabled, and a managed node group is created with a desired size of 3 nodes. A pod identity association is needed to allow the EBS CSI driver to create resources in AWS. It assigns EBS permissions via the “aws_ebs_csi” policy to the Kubernetes service account, which is created when the EBS CSI driver is installed in the cluster.</p><p>At the end of the configuration, a null resource is used along with a “local-exec” provisioner to run the Ansible playbook. The provisioner is set to trigger whenever the cluster name changes. Therefore, the provisioner will be triggered whenever the cluster is created with Terraform.</p><h4>Using Ansible and Helm to deploy MYSQL to the cluster.</h4><p><strong>Update kubeconfig</strong></p><pre>- name: Update Kubeconfig<br>  hosts: localhost<br>  gather_facts: false<br>  vars_files:<br>    - project-vars<br>  tasks:<br><br>    - name: update kubeconfig<br>      ansible.builtin.shell: &gt;<br>        aws eks update-kubeconfig<br>        --region us-east-1<br>        --name myapp-eks-cluster<br>        --kubeconfig &quot;{{ kubeconfig_path }}&quot;</pre><p>This play uses <a href="https://docs.ansible.com/projects/ansible/latest/collections/ansible/builtin/shell_module.html">the Ansible “shell” module</a> to update kubeconfig once the cluster has been created, so subsequent deployments are made into the newly created cluster.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*EI8_5uZVNVHyy5rShmMiXg.png" /></figure><h4>Install necessary packages</h4><pre>- name: Install Helm<br>  hosts: localhost<br>  tasks:<br>    <br>    - name: install Helm<br>      community.general.snap:<br>        name: helm<br>        state: present<br>        classic: true</pre><p>The <a href="https://docs.ansible.com/projects/ansible/latest/collections/community/general/snap_module.html">Ansible community snap module</a> is used to install the Helm package using the snap package manager, so the Helm command is available to add repositories and install releases.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*yE9f6LOsX7T08zeRPl21kg.png" /></figure><p><strong>Deploy prerequisite manifest files to the cluster.</strong></p><pre>- name: Add configmap, secret, and storageclass to cluster<br>  hosts: localhost<br>  tasks:<br><br>    - name: Add configmap<br>      kubernetes.core.k8s:<br>        state: present<br>        src: ../kubernetes/configmap.yaml<br><br>    - name: Add mysql secret<br>      kubernetes.core.k8s:<br>        state: present<br>        src: ../kubernetes/secret.yaml<br><br>    - name: Add storageclass<br>      kubernetes.core.k8s:<br>        state: present<br>        src: ../kubernetes/gp3-storageclass.yaml<br><br>    - name: Create kubernetes Docker secret<br>      kubernetes.core.k8s:<br>        state: present<br>        src: ../kubernetes/secret-docker.yaml</pre><p>The ConfigMap, Secret, StorageClass, and Docker secret resources need to be deployed so the correct configuration is available in the cluster before other resources like the MySQL server and Java application are deployed. <a href="https://docs.ansible.com/projects/ansible/latest/collections/kubernetes/core/k8s_module.html">The Kubernetes k8s module</a> is used to deploy manifest files using the “state” and “src” parameters.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*L73OziC4LEYpp7RBsnMvFA.png" /></figure><p><strong>Helm tasks — Add MySQL repo, Nginx repo, and install MySQL release and ingress controller.</strong></p><pre>- name: Add Bitnami MySQL repo, Nginx Repo, install MySQL release and Nginx ingress controller<br>  hosts: localhost<br>  tasks:<br><br>    - name: Add Bitnami repo<br>      kubernetes.core.helm_repository:<br>        name: bitnami<br>        repo_url: https://charts.bitnami.com/bitnami<br><br>    - name: Add Nginx repo<br>      kubernetes.core.helm_repository:<br>        name: ingress-nginx<br>        repo_url: https://kubernetes.github.io/ingress-nginx<br><br>    - name: Install mysql release<br>      kubernetes.core.helm:<br>        name: mysql-release<br>        chart_ref: bitnami/mysql<br>        release_namespace: default<br>        values_files:<br>          - ../kubernetes/mysql-values.yaml<br><br>    - name: Install Nginx controller<br>      kubernetes.core.helm:<br>        name: ingress-nginx-controller<br>        chart_ref: ingress-nginx/ingress-nginx<br>        release_namespace: default</pre><p>The <a href="https://docs.ansible.com/projects/ansible/latest/collections/kubernetes/core/helm_repository_module.html">Ansible “helm_repository” module</a> is used to add the Bitnami MySQL repo and Nginx repo to the cluster, using the “name” and “repo_url” parameters. The <a href="https://docs.ansible.com/projects/ansible/latest/collections/kubernetes/core/helm_module.html">Helm module</a> is used to install the release using the name, chart reference, namespace, and a values file if needed.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*sPnnPzuFeXqWFYsfwd-SBA.png" /></figure><p><strong>Build the app, log in, and push to a private repository.</strong></p><pre>- name: Build the app and image<br>  hosts: localhost<br>  tasks: <br><br>    - name: Build application<br>      command: &quot;gradle build -p ../java-app&quot;<br><br> <br><br>- name: Login, build and push image to repo<br>  hosts: localhost<br>  vars_files:<br>    - project-vars<br>  tasks:<br><br>    - name: Authenticate to Docker<br>      community.docker.docker_login:<br>        registry_url: https://index.docker.io/v1/<br>        username: &quot;{{ docker_user }}&quot;<br>        password: &quot;{{ docker_PAT }}&quot;<br><br><br>    - name: Build an image and push it to a private repo<br>      community.docker.docker_image:<br>        build:<br>          path: ../java-app<br>        name: michael101/java-maven-app<br>        tag: kubernetes-app<br>        push: true<br>        source: build</pre><p>The Ansible built-in<a href="https://docs.ansible.com/projects/ansible/latest/collections/ansible/builtin/command_module.html"> command module</a> is used to execute “gradle build” on the app. A JDK must first be installed, and for Amazon Linux, the SDK must be installed to install Gradle. Once the application is built, <a href="https://docs.ansible.com/projects/ansible/latest/collections/community/docker/docker_login_module.html#ansible-collections-community-docker-docker-login-module">the community.</a><a href="https://docs.ansible.com/projects/ansible/latest/collections/community/docker/docker_login_module.html#ansible-collections-community-docker-docker-login-module">docker docker_login module</a> is used to log in to the private Docker repository, using the “registry_url”, “username”, and “password” parameters. <a href="https://docs.ansible.com/projects/ansible/latest/collections/community/docker/docker_image_module.html">The docker_image module</a> is then used to build and push the image, using the “build,” “name”, “tag”, “push”, and “source” parameters.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*1TFShBQ1yVRZqwzsaV19hQ.png" /></figure><p><strong>Deploy Java app</strong></p><pre>- name: Deploy java app<br>  hosts: localhost<br>  tasks:<br><br>    - name: Deploy java app<br>      kubernetes.core.k8s:<br>        state: present<br>        src: ../kubernetes/java-deployment.yaml</pre><p>The Java app manifest file is deployed into the cluster. The manifest file is configured with the required environment variables to connect to the SQL server obtained from the ConfigMap, and secrets that were deployed earlier.</p><pre>apiVersion: apps/v1<br>kind: Deployment<br>metadata:<br>  name: custom-app<br>  namespace: default<br>  labels:<br>    app: custom-app<br>spec: <br>  replicas: 2<br>  selector:<br>    matchLabels:<br>      app: custom-app<br>  template:<br>    metadata:<br>      labels:<br>        app: custom-app<br>    spec:<br>      imagePullSecrets:<br>      - name: docker-secret<br>      containers:<br>      - name: kubernetes-app<br>        image: michael101/java-maven-app:kubernetes-app<br>        imagePullPolicy: Always<br>        ports:<br>        - containerPort: 8080<br>        env:<br>          - name: DB_USER<br>            valueFrom:<br>              configMapKeyRef:<br>                name: customapp-configmap<br>                key: DB_USER<br>          - name: DB_PWD<br>            valueFrom:<br>              secretKeyRef:<br>                name: mysql-secret<br>                key: mysql-password<br>          - name: DB_NAME<br>            valueFrom:<br>              configMapKeyRef: <br>                name: customapp-configmap<br>                key: DB_NAME<br>          - name: DB_SERVER<br>            valueFrom:<br>              configMapKeyRef:<br>                name: customapp-configmap<br>                key: DB_SERVER<br><br>---<br><br>apiVersion: v1<br>kind: Service<br>metadata:<br>  name: customapp-service<br>  namespace: default<br>spec:<br>  selector:<br>    app: custom-app<br>  ports:<br>  - protocol: TCP<br>    port: 8080<br>    targetPort: 8080</pre><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*6Bq9jy54CO6ZLC6V1Czo6g.png" /></figure><p><strong>Deploy ingress controller</strong></p><pre>- name: Get external IP for load balancer and apply to ingress<br>  hosts: localhost<br>  tasks:<br><br>    - name: Get hostname for load balancer<br>      kubernetes.core.k8s_info:<br>        api_version: v1<br>        kind: Service<br>        namespace: default<br>        name: ingress-nginx-controller-controller <br>      register: loadbalancer_hostname<br><br>    - name: Set ELB hostname<br>      ansible.builtin.set_fact:<br>        elb_hostname: &quot;{{ loadbalancer_hostname[&#39;resources&#39;][0][&#39;status&#39;][&#39;loadBalancer&#39;][&#39;ingress&#39;][0][&#39;hostname&#39;] }}&quot;<br><br>    <br>    - name: Deploy ingress<br>      kubernetes.core.k8s:<br>        state: present<br>        definition: &quot;{{ lookup(&#39;template&#39;, &#39;../kubernetes/ingress-values.yaml&#39;) | from_yaml}}&quot;</pre><p><a href="https://docs.ansible.com/projects/ansible/latest/collections/kubernetes/core/k8s_info_module.html">The kubernetes k8s_info module</a> is used to get more information about a specified Kubernetes resource, using parameters like the “api_version”, “kind”, “namespace”, and “name”. In the above example, the module is used to gather information about the installed nginx controller and is registered to the “loadbalancer_hostname” variable. The hostname is then retrieved from that variable and set as an Ansible fact, so the information is available to other tasks in the play. In the “deploy ingress” task, the definition is used to dynamically set the load balancer hostname as a value in the ingress manifest file.</p><pre>apiVersion: networking.k8s.io/v1<br>kind: Ingress<br>metadata:<br>    name: customapp-ingress<br>    namespace: default<br>spec:<br>  ingressClassName: nginx<br>  rules:<br>  - host: &quot;{{ elb_hostname }}&quot;<br>    http:<br>      paths:<br>        - path: /<br>          pathType: Prefix<br>          backend:<br>            service:<br>              name: customapp-service<br>              port:<br>                number: 8080</pre><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*JJ85wnJxDubiaXuqhuVsFA.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*F1ldcuvgRPSroHlzrEBzCg.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*IH39aRhUq6_7QuED8Ajrrg.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*IVTOyk9ua7MVbiYjAhtHIA.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*S-HqEB8hOdzPCQh0iAgy4A.png" /></figure><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=156ac52477d1" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Create an EC2 Instance, install applications, and run Docker containers with Boto3 and Python.]]></title>
            <link>https://medium.com/@micsamkid500/create-an-ec2-instance-install-applications-and-run-docker-containers-with-boto3-and-python-f46f87a5ce7b?source=rss-5bd4345bedb5------2</link>
            <guid isPermaLink="false">https://medium.com/p/f46f87a5ce7b</guid>
            <category><![CDATA[monitoring]]></category>
            <category><![CDATA[python]]></category>
            <category><![CDATA[boto3]]></category>
            <category><![CDATA[docker]]></category>
            <category><![CDATA[aws]]></category>
            <dc:creator><![CDATA[Samuel Michael]]></dc:creator>
            <pubDate>Tue, 13 Oct 2026 20:21:02 GMT</pubDate>
            <atom:updated>2026-10-14T06:00:20.764Z</atom:updated>
            <content:encoded><![CDATA[<h4>Introduction</h4><p>This project aims to create an EC2 instance, install and configure Docker, deploy an Nginx container, and set up a scheduled task to monitor application health.</p><p><strong>GitHub Link</strong> — <a href="https://github.com/Michael2921/Automate-EC2-Docker-Boto3/">https://github.com/Michael2921/Automate-EC2-Docker-Boto3/</a></p><h4>Requirements</h4><ul><li>EC2 instance must be accessible externally. It must have a public IP.</li><li>EC2 instance should allow SSH access for Docker installation and configuration</li><li>EC2 instance should have a port open to access Nginx externally</li><li>Import the key pair into AWS, and add it at instance creation</li><li>Retrieve the instance ID and Public IP that will be used in later SSH connections.</li><li>Install Docker</li><li>Deploy and run Nginx</li><li>Check the status of the Nginx container</li><li>Monitor the application health using a scheduled task</li></ul><p>Boto3 documentation for EC2 client — <a href="https://docs.aws.amazon.com/boto3/latest/reference/services/ec2.html">https://docs.aws.amazon.com/boto3/latest/reference/services/ec2.html</a></p><p>The Boto3 EC2 client has many methods (approximately 800). The methods that will be used for this project include:</p><p>run_instances() — creates the EC2 instance</p><p>describe_instances()-provides more details on instances</p><p>describe_instance_status()- provides details specifically on the status of instances.</p><p>import_key_pair()-imports a key pair from the local machine to AWS</p><p>authorize_security_group_ingress() — creates inbound security rules in the security group</p><h4>Access EC2 instance externally.</h4><p>The EC2 instance must be accessible externally, as there will be an external SSH connection to set up Docker and Nginx, and to access the web server later.</p><ul><li>The EC2 instance must be assigned a public IP address. This is already set by default on the default VPC.</li><li>The VPC must have an internet gateway attached, and the subnet the EC2 instance runs in must have a route to the internet gateway (making it public). This allows traffic from the instance to the internet.</li></ul><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*HRil1ZEJVzsudVezQVx90A.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*YI_D2-E5gWr7TwI4Uu-2lg.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*ZgaphmOy0TST1h-jXYckLw.png" /></figure><p>This is set up by default in the default VPC. This means an instance created in the default VPC gets a public IP address and can reach the internet via an internet gateway attached to the VPC.</p><ul><li>The security group associated with the VPC must allow inbound connections to ports that will be used (in this case, ports 22 and 8080)</li></ul><p>This can be done using the “authorize_security_group_ingress()” method</p><pre>create_security_group = ec2.authorize_security_group_ingress(<br>    GroupId=&#39;sg-0066ecb57ae1d22a3&#39;,<br>    IpPermissions=[<br>        {<br>            &#39;IpProtocol&#39;: &#39;tcp&#39;,<br>            &#39;FromPort&#39;: 22,<br>            &#39;ToPort&#39;: 22,<br>            &#39;IpRanges&#39;: [<br>                {<br>                    &#39;Description&#39;: &#39;Security group created with Boto for inbound SSH access&#39;,<br>                    &#39;CidrIp&#39;: &#39;12.34.56.78/32&#39;<br><br>                }<br>            ]<br>        },<br><br>{<br>            &#39;IpProtocol&#39;: &#39;tcp&#39;,<br>            &#39;FromPort&#39;: 8080,<br>            &#39;ToPort&#39;: 8080,<br>            &#39;IpRanges&#39;: [<br>                {<br>                    &#39;Description&#39;: &#39;Security group created with Boto for inbound HTTP access&#39;,<br>                    &#39;CidrIp&#39;: &#39;12.34.56.78/32&#39;<br><br>                }<br>            ]<br>        }<br>    ]<br>)</pre><p>The method requires the security group ID, and the inbound rules are defined as dictionaries in the “IpPermissions” list. Running this adds the rules to the security group.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*Rr2e18TbWX_aXySYm983gA.png" /></figure><h4>Import Key Pair.</h4><p>A key pair needs to be loaded into the instance at the time of creation. For this to happen, the public key needs to be imported into the Key Pair section in AWS using “import_key_pair()”</p><pre>ec2 = boto3.client(&quot;ec2&quot;)<br><br><br>file_location = &#39;C:\\Users\\user\\.ssh\\id_rsa.pub&#39;<br><br>with open(file_location, &#39;r&#39;) as file:<br>    public_key_contents = file.read()<br><br>import_public_key = ec2.import_key_pair(<br>    KeyName=&#39;PublicKeyforEC2&#39;,<br>    PublicKeyMaterial=public_key_contents<br>)<br><br>print(&quot;Key pair is created&quot;)</pre><p>An EC2 object is created. The file location on the local machine is specified with the “file_location” variable. “import_public_key()” reads the public key as a series of bytes. But instead of copying and pasting the public key, the public key contents can be read with file.read() and assigned to a variable. That variable is then assigned to “PublicKeyMaterial”. When executed, the public key pair is imported into AWS.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/874/1*wcJzWy99E3iSY0uF6E6tZA.png" /></figure><p>When creating the EC2 instance, the KeyName is specified as a parameter to add the public key.</p><pre>created_instances = ec2.run_instances(<br>        ImageId = &quot;ami-0d27e0fb3bac4d724&quot;,<br>        MinCount = 1,<br>        MaxCount = 1,<br>        KeyName=&#39;PublicKeyforEC2&#39;,<br>        InstanceType=&#39;t3.small&#39;<br>)</pre><h4>Retrieve the Instance ID and Public IP address.</h4><p>The Instance ID and Public IP address will be needed to perform further operations later. The instance ID will be used to retrieve the public IP, and the public IP will be needed for SSH access to install and configure Docker.</p><p>In Boto3, requests to the client have to be made using the syntax specified in the documentation. The response syntax is also specified in the documentation. The values in the request and response syntax usually have dictionaries in lists in dictionaries, and so on. So, learning how to access elements from lists or dictionaries is required.</p><p>Example of a request syntax in Boto3 “revoke_security_group_ingress()”</p><pre>response = client.revoke_security_group_ingress(<br>    CidrIp=&#39;string&#39;,<br>    FromPort=123,<br>    GroupId=&#39;string&#39;,<br>    GroupName=&#39;string&#39;,<br>    IpPermissions=[<br>        {<br>            &#39;IpProtocol&#39;: &#39;string&#39;,<br>            &#39;FromPort&#39;: 123,<br>            &#39;ToPort&#39;: 123,<br>            &#39;UserIdGroupPairs&#39;: [<br>                {<br>                    &#39;Description&#39;: &#39;string&#39;,<br>                    &#39;UserId&#39;: &#39;string&#39;,<br>                    &#39;GroupName&#39;: &#39;string&#39;,<br>                    &#39;GroupId&#39;: &#39;string&#39;,<br>                    &#39;VpcId&#39;: &#39;string&#39;,<br>                    &#39;VpcPeeringConnectionId&#39;: &#39;string&#39;,<br>                    &#39;PeeringStatus&#39;: &#39;string&#39;<br>                },<br>            ],<br>            &#39;IpRanges&#39;: [<br>                {<br>                    &#39;Description&#39;: &#39;string&#39;,<br>                    &#39;CidrIp&#39;: &#39;string&#39;<br>                },<br>            ],<br>            &#39;Ipv6Ranges&#39;: [<br>                {<br>                    &#39;Description&#39;: &#39;string&#39;,<br>                    &#39;CidrIpv6&#39;: &#39;string&#39;<br>                },<br>            ],<br>            &#39;PrefixListIds&#39;: [<br>                {<br>                    &#39;Description&#39;: &#39;string&#39;,<br>                    &#39;PrefixListId&#39;: &#39;string&#39;<br>                },<br>            ]<br>        },<br>    ],<br>    IpProtocol=&#39;string&#39;,<br>    SourceSecurityGroupName=&#39;string&#39;,<br>    SourceSecurityGroupOwnerId=&#39;string&#39;,<br>    ToPort=123,<br>    SecurityGroupRuleIds=[<br>        &#39;string&#39;,<br>    ],<br>    DryRun=True|False<br>)</pre><p>Example of a response syntax in Boto3 “revoke_security_group_ingress()”</p><pre>{<br>    &#39;Return&#39;: True|False,<br>    &#39;UnknownIpPermissions&#39;: [<br>        {<br>            &#39;IpProtocol&#39;: &#39;string&#39;,<br>            &#39;FromPort&#39;: 123,<br>            &#39;ToPort&#39;: 123,<br>            &#39;UserIdGroupPairs&#39;: [<br>                {<br>                    &#39;Description&#39;: &#39;string&#39;,<br>                    &#39;UserId&#39;: &#39;string&#39;,<br>                    &#39;GroupName&#39;: &#39;string&#39;,<br>                    &#39;GroupId&#39;: &#39;string&#39;,<br>                    &#39;VpcId&#39;: &#39;string&#39;,<br>                    &#39;VpcPeeringConnectionId&#39;: &#39;string&#39;,<br>                    &#39;PeeringStatus&#39;: &#39;string&#39;<br>                },<br>            ],<br>            &#39;IpRanges&#39;: [<br>                {<br>                    &#39;Description&#39;: &#39;string&#39;,<br>                    &#39;CidrIp&#39;: &#39;string&#39;<br>                },<br>            ],<br>            &#39;Ipv6Ranges&#39;: [<br>                {<br>                    &#39;Description&#39;: &#39;string&#39;,<br>                    &#39;CidrIpv6&#39;: &#39;string&#39;<br>                },<br>            ],<br>            &#39;PrefixListIds&#39;: [<br>                {<br>                    &#39;Description&#39;: &#39;string&#39;,<br>                    &#39;PrefixListId&#39;: &#39;string&#39;<br>                },<br>            ]<br>        },<br>    ],<br>    &#39;RevokedSecurityGroupRules&#39;: [<br>        {<br>            &#39;SecurityGroupRuleId&#39;: &#39;string&#39;,<br>            &#39;GroupId&#39;: &#39;string&#39;,<br>            &#39;IsEgress&#39;: True|False,<br>            &#39;IpProtocol&#39;: &#39;string&#39;,<br>            &#39;FromPort&#39;: 123,<br>            &#39;ToPort&#39;: 123,<br>            &#39;CidrIpv4&#39;: &#39;string&#39;,<br>            &#39;CidrIpv6&#39;: &#39;string&#39;,<br>            &#39;PrefixListId&#39;: &#39;string&#39;,<br>            &#39;ReferencedGroupId&#39;: &#39;string&#39;,<br>            &#39;Description&#39;: &#39;string&#39;<br>        },<br>    ]<br>}</pre><p>The Instance ID can be accessed using the “created_instances” variable from earlier</p><pre>created_instances = ec2.run_instances(<br>        ImageId = &quot;ami-0d27e0fb3bac4d724&quot;,<br>        MinCount = 1,<br>        MaxCount = 1,<br>        KeyName=&#39;PublicKeyforEC2&#39;,<br>        InstanceType=&#39;t3.small&#39;<br>)</pre><p>The variable is used to further filter down lists and dictionaries until arriving at the instance ID.</p><pre>instance_id = created_instances[&#39;Instances&#39;][0][&#39;InstanceId&#39;]</pre><p>The public IP can be accessed by running “describe_instances()”, while specifying the instance_id. The result of that is then filtered by the “Reservations”, “Instances”, and “PublicIpAddress” keys.</p><pre>describe_instances_by_id = ec2.describe_instances(InstanceIds=[instance_id])<br>instance = describe_instances_by_id[&#39;Reservations&#39;][0][&#39;Instances&#39;][0]<br>public_ipv4 = instance.get(&#39;PublicIpAddress&#39;)</pre><pre>Key pair is created<br>Security group is created<br>Instance is created, waiting for public ip<br>Instance ID is i-0270feb0d51f82b74<br>Public IP address is 44.195.42.215</pre><h4>Install Docker on the EC2 instance.</h4><p>Now that inbound connections to the instance are confirmed and the necessary rules are created in the security group, Docker can be installed via SSH using the Paramiko Python library.</p><pre>def ssh_access():<br><br>    ssh = paramiko.SSHClient()<br><br>    ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())<br><br>    ssh.connect(public_ipv4, username=&#39;ec2-user&#39;, key_filename=&#39;C:\\Users\\user\\.ssh\\id_rsa&#39;)<br><br>    return ssh<br><br><br><br>def install_docker():<br><br>    ssh = ssh_access()<br><br>    print(&quot;Connecting to the server&quot;)<br><br>    print(&quot;Executing docker install commands...&quot;)<br><br>    commands_install_docker = [<br>        &#39;sudo dnf install -y docker&#39;,<br>        &#39;sudo systemctl start docker&#39;,<br>        &#39;sudo usermod -aG docker ec2-user&#39;, # docker group is automatically created on install<br>        # docker group permissions will not be active in the existing SSH session. Close session and start a new one.<br><br><br><br>    ]<br><br>    for cmd in commands_install_docker:<br>        stdin, stdout, stderr = ssh.exec_command(cmd)<br>        stdout.channel.recv_exit_status()<br><br>        print(f&quot;Command: {cmd}&quot;)<br>        print(stdout.read().decode())<br>        print(stderr.read().decode())<br><br>    ssh.close()</pre><p>The function “ssh_access()” is created to abstract the code used to connect to the SSH client, add the host key, and connect to the EC2 instance using the private key.</p><p>The “install_docker()” method connects to the EC2 instance via SSH, runs the Docker installation commands, adds the default user to the group, and closes the SSH connection. The for loop executes each command, waits for it to finish, prints the command and its stdout or stderr, then moves to the next command. Once the user is added to the Docker group, the changes don’t take effect in the existing SSH session. The session must be closed and restarted before “ec2-user” can run Docker commands.</p><pre>hecking status of instance...<br>Instance is running<br>Connecting to the server<br>Executing docker install commands...<br>Command: sudo dnf install -y docker<br>Waiting for process with pid 2000 to finish.<br>Amazon Linux 2023 Kernel Livepatch repository   435 kB/s |  89 kB     00:00    <br>Last metadata expiration check: 0:00:01 ago on Mon Oct 12 23:33:55 2026.<br>Dependencies resolved.<br>================================================================================<br> Package                  Arch     Version                  Repository     Size<br>================================================================================<br>Installing:<br> docker                   x86_64   25.0.16-1.amzn2023.0.4   amazonlinux    46 M<br>Installing dependencies:<br> container-selinux        noarch   4:2.245.0-1.amzn2023     amazonlinux    58 k<br> containerd               x86_64   2.2.7-1.amzn2023.0.1     amazonlinux    25 M<br> iptables-libs            x86_64   1.8.8-3.amzn2023.0.2     amazonlinux   401 k<br> iptables-nft             x86_64   1.8.8-3.amzn2023.0.2     amazonlinux   183 k<br> libcgroup                x86_64   3.0-1.amzn2023.0.1       amazonlinux    75 k<br> libnetfilter_conntrack   x86_64   1.0.8-2.amzn2023.0.2     amazonlinux    58 k<br> libnfnetlink             x86_64   1.0.1-19.amzn2023.0.2    amazonlinux    30 k<br> libnftnl                 x86_64   1.2.2-2.amzn2023.0.2     amazonlinux    84 k<br> pigz                     x86_64   2.5-1.amzn2023.0.4       amazonlinux    83 k<br> runc                     x86_64   1.3.6-1.amzn2023.0.1     amazonlinux   4.0 M</pre><h4>Deploy Nginx container.</h4><p>The “install_and_run_nginx()” function initiates a connection to the EC2 instance via SSH, then pulls and runs an Nginx container in the background.</p><pre>def install_and_run_nginx():<br><br>    ssh = ssh_access()<br><br>    print(&quot;Connecting to the server&quot;)<br><br>    print(&quot;Executing install and run nginx commands...&quot;)<br><br>    commands_install_nginx = [<br>        &#39;docker run -p 8080:80 -d nginx:1.30.5&#39;,<br>        &#39;docker ps&#39;<br>    ]<br><br>    for cmd in commands_install_nginx:<br>        stdin, stdout, stderr = ssh.exec_command(cmd)<br>        stdout.channel.recv_exit_status()<br><br>        print(f&quot;Command: {cmd}&quot;)<br>        print(stdout.read().decode())<br>        print(stderr.read().decode())<br><br>    ssh.close()</pre><pre>Connecting to the server<br>Executing install and run nginx commands...<br>Command: docker run -p 8080:80 -d nginx:1.30.5<br>0ca11cb46c56d38c2e461ec1ed32fe79f89d9411287c32e5d8a1c182761cdead<br><br>Unable to find image &#39;nginx:1.30.5&#39; locally<br>1.30.5: Pulling from library/nginx<br>ecc510c1e359: Pulling fs layer<br>f4616bb1be1c: Pulling fs layer<br>f1a6a629b824: Pulling fs layer<br>3d774efa34e3: Pulling fs layer<br>cb44c3fe542b: Pulling fs layer<br>121907e00c1c: Pulling fs layer<br>220a6ac36368: Pulling fs layer<br>3d774efa34e3: Waiting<br>cb44c3fe542b: Waiting<br>121907e00c1c: Waiting<br>220a6ac36368: Waiting<br>f1a6a629b824: Verifying Checksum<br>f1a6a629b824: Download complete<br>3d774efa34e3: Verifying Checksum<br>3d774efa34e3: Download complete<br>ecc510c1e359: Verifying Checksum<br>ecc510c1e359: Download complete<br>f4616bb1be1c: Verifying Checksum<br>f4616bb1be1c: Download complete<br>cb44c3fe542b: Verifying Checksum<br>cb44c3fe542b: Download complete<br>220a6ac36368: Verifying Checksum<br>220a6ac36368: Download complete<br>121907e00c1c: Verifying Checksum<br>121907e00c1c: Download complete<br>ecc510c1e359: Pull complete<br>f4616bb1be1c: Pull complete<br>f1a6a629b824: Pull complete<br>3d774efa34e3: Pull complete<br>cb44c3fe542b: Pull complete<br>121907e00c1c: Pull complete<br>220a6ac36368: Pull complete<br>Digest: sha256:9bf97bd7714f5e24c1ccd545ecb9eb5435cb6d109c97cebb15e7e455e0239edb<br>Status: Downloaded newer image for nginx:1.30.5<br><br>Command: docker ps<br>CONTAINER ID   IMAGE          COMMAND                  CREATED        STATUS                  PORTS                                   NAMES<br>0ca11cb46c56   nginx:1.30.5   &quot;/docker-entrypoint.…&quot;   1 second ago   Up Less than a second   0.0.0.0:8080-&gt;80/tcp, :::8080-&gt;80/tcp   jovial_maxwell</pre><pre>Connecting to the server<br>Executing install and run nginx commands...<br>Command: docker run -p 8080:80 -d nginx:1.30.5<br>0ca11cb46c56d38c2e461ec1ed32fe79f89d9411287c32e5d8a1c182761cdead<br><br>Unable to find image &#39;nginx:1.30.5&#39; locally<br>1.30.5: Pulling from library/nginx<br>ecc510c1e359: Pulling fs layer<br>f4616bb1be1c: Pulling fs layer<br>f1a6a629b824: Pulling fs layer<br>3d774efa34e3: Pulling fs layer<br>cb44c3fe542b: Pulling fs layer<br>121907e00c1c: Pulling fs layer<br>220a6ac36368: Pulling fs layer<br>3d774efa34e3: Waiting<br>cb44c3fe542b: Waiting<br>121907e00c1c: Waiting<br>220a6ac36368: Waiting<br>f1a6a629b824: Verifying Checksum<br>f1a6a629b824: Download complete<br>3d774efa34e3: Verifying Checksum<br>3d774efa34e3: Download complete<br>ecc510c1e359: Verifying Checksum<br>ecc510c1e359: Download complete<br>f4616bb1be1c: Verifying Checksum<br>f4616bb1be1c: Download complete<br>cb44c3fe542b: Verifying Checksum<br>cb44c3fe542b: Download complete<br>220a6ac36368: Verifying Checksum<br>220a6ac36368: Download complete<br>121907e00c1c: Verifying Checksum<br>121907e00c1c: Download complete<br>ecc510c1e359: Pull complete<br>f4616bb1be1c: Pull complete<br>f1a6a629b824: Pull complete<br>3d774efa34e3: Pull complete<br>cb44c3fe542b: Pull complete<br>121907e00c1c: Pull complete<br>220a6ac36368: Pull complete<br>Digest: sha256:9bf97bd7714f5e24c1ccd545ecb9eb5435cb6d109c97cebb15e7e455e0239edb<br>Status: Downloaded newer image for nginx:1.30.5<br><br>Command: docker ps<br>CONTAINER ID   IMAGE          COMMAND                  CREATED        STATUS                  PORTS                                   NAMES<br>0ca11cb46c56   nginx:1.30.5   &quot;/docker-entrypoint.…&quot;   1 second ago   Up Less than a second   0.0.0.0:8080-&gt;80/tcp, :::8080-&gt;80/tcp   jovial_maxwell</pre><h4>Status checks.</h4><p>The EC2 instance must be running before these commands run; otherwise, they won’t work. The “check_instance_status()” function checks the status of the created instance. Then the while loop runs the “check_instance_function” and only executes the “install_docker” and “install_nginx” functions when the ec2 instance status == “running”</p><pre>def check_instance_status():<br><br>    ec2_status = ec2.describe_instance_status(InstanceIds=instance_id)<br>    status = ec2_status[&#39;InstanceStatuses&#39;][0][&#39;InstanceState&#39;][&#39;Name&#39;]<br>    return status<br><br><br>while True:<br>    print(&quot;Checking status of instance...&quot;)<br>    if check_instance_status() == &quot;running&quot;:<br>        print(&quot;Instance is running&quot;)<br>        install_docker()<br>        install_and_run_nginx()<br>        break<br>    time.sleep(5)</pre><h4>Monitoring the application as a scheduled task.</h4><p>A scheduled task checks the app&#39;s health every 5 seconds (response code == 200). A counter is declared. If the application is unhealthy (response code != 200) during a check, the counter increments by 1. If the counter reaches 5, the application is considered unhealthy, and the Nginx container is restarted.</p><pre><br>def restart_container():<br><br>    ssh = ssh_access()<br><br>    stdin, stdout, stderr = ssh.exec_command(&quot;docker ps -q | head -n 1&quot;) # dynamically get the container ID<br>    container_id = stdout.read().decode().strip()<br><br>    stdin, stdout, stderr = ssh.exec_command(f&quot;docker restart {container_id}&quot;) # starts container with dynamically retrieved container ID<br>    print(stdout.read().decode())<br>    print(stderr.read().decode())<br><br>    ssh.close()<br><br><br>check_counter = 0<br><br># every 5 seconds, it checks for the status, for a total of 5 times (25 seconds) if 5 checks fail, it restarts the application.<br><br><br>def monitor_application():<br><br>    global check_counter<br><br>    response = requests.get(f&#39;http://{public_ipv4}:8080&#39;)<br><br>    print(&quot;Monitoring application health...&quot;)<br>    if response.status_code != 200:<br>        print(&quot;Site is not reachable&quot;)<br>        check_counter += 1<br>        print(f&quot;failed counter is {check_counter}&quot;)<br>        if check_counter == 5:<br>            print(f&quot;failed counter is {check_counter}, restarting the container&quot;)<br>            restart_container()<br>            check_counter = 0<br><br><br>schedule.every(5).seconds.do(monitor_application)<br><br><br>while True:<br>    schedule.run_pending()</pre><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=f46f87a5ce7b" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Using Terraform to provision an EKS cluster and deploy applications in a CI/CD pipeline.]]></title>
            <link>https://medium.com/@micsamkid500/using-terraform-to-provision-an-eks-cluster-and-deploy-applications-in-a-ci-cd-pipeline-9ca6c8749057?source=rss-5bd4345bedb5------2</link>
            <guid isPermaLink="false">https://medium.com/p/9ca6c8749057</guid>
            <category><![CDATA[terraform-provider]]></category>
            <category><![CDATA[terraform-remote-state]]></category>
            <category><![CDATA[ek]]></category>
            <category><![CDATA[terraform-modules]]></category>
            <category><![CDATA[terraform]]></category>
            <dc:creator><![CDATA[Samuel Michael]]></dc:creator>
            <pubDate>Sun, 20 Sep 2026 00:43:58 GMT</pubDate>
            <atom:updated>2026-09-20T00:43:58.252Z</atom:updated>
            <content:encoded><![CDATA[<h3>Introduction</h3><p>This project uses Terraform modules to provision an EKS cluster with a managed node group and Fargate profile. It deploys MYSQL, phpMyAdmin, and a custom Java application using Terraform and Jenkins in a CI/CD pipeline. It also creates a remote backend to store local Terraform state files.</p><p><a href="https://github.com/Michael2921/Terraform-EKS/tree/master">GitHub Link — https://github.com/Michael2921/Terraform-EKS/tree/master</a></p><p>Create a Terraform folder in the project with the required files. Starting files include:</p><ul><li>vpc.tf — for the networking configuration</li><li>providers.tf- includes provider information</li><li>eks-cluster.tf — for the cluster configuration</li></ul><figure><img alt="" src="https://cdn-images-1.medium.com/max/265/1*RNyNE0mX7Jmi9Am-JHYsAw.png" /></figure><h4>Terraform modules</h4><h4>Using Terraform to deploy the VPC using the VPC Module</h4><p>Terraform modules are reusable collections of Terraform resources and, like libraries or packages in programming languages, abstract complexity and extend Terraform provider features.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/724/1*_Rri2EQkG5YVLG9GWxYiaQ.png" /></figure><p>name, cidr, private_subnets, and public_subnets, enable_nat_gateway, and others are inputs in the Terraform EKS module. The enable_nat_gateway option is set to true to create a NAT gateway, so private subnets can reach the internet while the subnets themselves can&#39;t be reached from the internet.</p><p>The tags indicate that the private and public subnet resources can be shared among other Kubernetes clusters/resources and enable the public and private subnets to be used as internal and external load balancers.</p><p><a href="https://registry.terraform.io/modules/terraform-aws-modules/vpc/aws/latest?tab=inputs">Terraform Registry</a></p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*6aBFy8DiaO1XJpIjy8g5Ow.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/658/1*zMTvYv2K8StFQ4mQp7MT7w.png" /></figure><p>The terraform. tfvars file assigns values to the variables defined in vpc.tf. It assigns values to the vpc_cidr_block, private_subnet_cidr_blocks, and public_subnet_cidr_blocks variables.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/970/1*hYdhQ5LjPJI7JmdcbXNwMA.png" /></figure><h4>Using Terraform to deploy EKS using the EKS Module</h4><p>The Terraform EKS module can be found here</p><p><a href="https://registry.terraform.io/modules/terraform-aws-modules/eks/aws/latest?tab=inputs">Terraform Registry</a></p><p>Configuration:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1005/1*M3oQbsE4cjJZmeWUPqBDUg.png" /></figure><p>The module installs needed add-ons like CoreDNS, the EKS Pod Identity agent, kube-proxy, and VPC CNI. “before_compute” means the add-ons are created before the compute resources are provisioned.</p><p>Endpoint public access is set to true so the cluster can be accessed locally (i.e with kubectl). &quot;enable_cluster_creator_admin_permissions&quot; sets the cluster creator as an admin.</p><p>The &quot;eks_managed_node_groups&quot; input sets the node group name, ami_type, instance_type, and node sizes.</p><h4>Building the application, Docker image, and pushing to repo</h4><h4>EBS CSI Driver and MYSQL storage</h4><p>To provision storage for the MySQL pods in AWS, the EBS CSI driver (Elastic Block Store Container Storage Interface) is required. This driver handles EBS storage tasks on AWS. To provision storage for MySQL on EKS, install the EBS CSI driver as an add-on and assign the appropriate policy.</p><p>Add the add-on to the Terraform configuration.</p><pre>addons = {<br>    coredns = {}<br>    eks-pod-identity-agent = {<br>        before_compute = true<br>    }<br>    kube-proxy = {}<br>    vpc-cni = {<br>        before_compute = true<br>    }<br>    aws-ebs-csi-driver = {}<br><br>  }</pre><p>Add the required policy to the node group.</p><pre>iam_role_additional_policies = {<br>    AmazonEBSCSIDriverPolicy = &quot;arn:aws:iam::aws:policy/service-role/AmazonEBSCSIDriverPolicy&quot;<br>}</pre><h4>Deploying MYSQL with Terraform</h4><p>The Kubernetes and Helm providers will deploy MySQL with Terraform.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*IqebfUFS3exFkLgqR-9v-g.png" /></figure><p>Two data sources are defined: &quot;aws_eks_cluster&quot; and &quot;aws_eks_cluster_auth&quot;. The former retrieves the cluster endpoint, certificate authority, cluster name, and ARN. The other retrieves the auth token. The Kubernetes and Helm providers use these values to perform their functions.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*7EEfSZ6s8cirVR2YK6Znsg.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*mABIqS0dWQFAw4g1U24SRw.png" /></figure><p>Create a Kubernetes storage class for the MySQL pods to store their data; you can do this with Terraform using the &quot;kubernetes_storage_class_v1&quot; resource from the Kubernetes provider.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/739/1*VgLUmdBcFuz2Lijb3en8Sw.png" /></figure><p>The &quot;helm_release&quot; resource from the Helm provider provides the information needed for Helm deployments.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/843/1*P92KRWLgaebprPB6Q8pJuQ.png" /></figure><p>Use &quot;existingsecret&quot; instead of hardcoding credentials in the Helm SQL values file. Create the root password, user password, and replication password in Jenkins.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*g5Sj-6-XkdBMqL8bS6Qp5Q.png" /></figure><p>And use it in the Jenkinsfile. &quot;TF_VAR_ is used to define variables in Terraform&quot;</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*mfKYQEWr37ETp2ILOHMMXw.png" /></figure><p>Add AWS credentials as environment variables in the Jenkinsfile so Terraform can connect to AWS.</p><p>Add AWS credentials to Jenkins.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*6TgKWY5LRgC5DMD1hXppXg.png" /></figure><p>And reference them in the Jenkinsfile.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*gV1uySNb-jm8p8GaOFcWug.png" /></figure><h4>Terraform</h4><p>Terraform init</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/721/1*J5KWpfl_BAOV8WWgEumY1w.png" /></figure><p>Terraform plan</p><p>Use &quot;terraform plan&quot; to see a plan of what resources will be created/modified/destroyed.</p><p>The storage class will be created.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/610/1*18_EZ5BATDPUtar9S3RL6g.png" /></figure><p>The VPC and all associated resources</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/919/1*44nz2g6cRXFO8W0s8jZZxg.png" /></figure><p>Subnets</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*85qsmKPZESZWQRXxAyf_Ow.png" /></figure><p>Add-ons</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*gcHycBvKFePkI_eKiEk7rg.png" /></figure><p>Nodegroup</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*XkZzvyceT_irkpPOL5YmAA.png" /></figure><p>Fargate profile</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*z5kNHH2TruboRHyrvIw37Q.png" /></figure><h4>Deploying MYSQL pods with Terraform</h4><p>Issues after running Terraform apply.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*y2aJV9AGnfNS2Y5S72_sWQ.png" /></figure><p>There were two issues according to the error output</p><ul><li>User cannot create storage classes</li><li>Timeout while waiting for the EBS-CSI add-on to become active</li></ul><h4>Issues creating storage class</h4><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*XC30XCugAWciUkh5P0gE7A.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/727/1*ClwvMMn0x6m1uAl1yc5bag.png" /></figure><p>Running</p><pre>kubectl auth can-i create storageclass</pre><p>Outputs</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/949/1*3-5Aj4dWVq346JbMymlzTA.png" /></figure><p>So the user should be able to create storage classes.</p><p>This appeared to be due to a dependency issue and was resolved by adding a dependency in the storage class resource in mysql.tf to ensure the EKS cluster is created before the storage class is provisioned.</p><pre>depends_on = [module.eks]</pre><pre>resource &quot;kubernetes_storage_class_v1&quot; &quot;gp3&quot; {<br>    depends_on = [module.eks] <br>    metadata {<br>        name = &quot;gp3&quot;<br>    }<br><br>    storage_provisioner = &quot;ebs.csi.aws.com&quot;<br>    volume_binding_mode = &quot;WaitForFirstConsumer&quot;<br>    allow_volume_expansion = true<br>    reclaim_policy = &quot;Delete&quot;<br><br>    parameters = {<br>        type = &quot;gp3&quot;<br>        encrypted = &quot;true&quot;<br>        fsType = &quot;ext4&quot;<br>    }<br>}</pre><p>After adding this dependency, &quot;terraform apply&quot; runs without issues.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/691/1*EzXwFIBiOmjN9xuHQGR1Fg.png" /></figure><p><strong>EBS-CSI add-on unhealthy</strong></p><p>Use</p><pre>kubectl get pods -n kube-system </pre><p>To get a list of add-on pods</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/973/1*B7i-MlhjtmXwXnmjwMc-ww.png" /></figure><p>The ebs-csi-controllers have failed with a &quot;CrashLoopBackOff&quot; status</p><p>Running</p><pre>kubectl logs ebs-csi-controller-799df95cb7-5pvbb -n kube-system</pre><p>Gives output</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*XuggohkJDMvvUZrMFokKCQ.png" /></figure><p>This suggests there are no permissions, or permissions are insufficient, to start the EBS CSI add-on.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/565/1*o1InWtQIJ-yofdeLd2vu6A.png" /></figure><p>Terraform creates the aws-ebs-csi-driver add-on but doesn&#39;t automatically create the IAM role and pod identity association. Those can be created with the module &quot;aws_ebs_csi_pod_identity&quot;</p><pre>  module &quot;aws_ebs_csi_pod_identity&quot; {<br>    source = &quot;terraform-aws-modules/eks-pod-identity/aws&quot;<br><br>    name = &quot;aws-ebs-csi&quot;<br><br>    attach_aws_ebs_csi_policy = true<br><br>    associations = {<br>      this = {<br>        cluster_name = module.eks.cluster_name<br>        namespace = &quot;kube-system&quot;<br>        service_account = &quot;ebs-csi-controller-sa&quot;<br>      }<br>    }<br>  }</pre><p>After adding this module to the EKS cluster Terraform file and recreating the cluster, the EBS CSI drivers appear to run without issues.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/898/1*pJuJgAKc0DWF2_Zd-bdyNg.png" /></figure><h4>Creating Kubernetes secrets with the Terraform Kubernetes Provider</h4><p>Instead of creating the MySQL credentials in the Jenkinsfile with</p><pre>kubectl create secret generic mysql-creds - from-literal=mysql-root-password=[root_password] - from-literal=mysql-password=[mysql_password] - from-literal=mysql-replication-password=[mysql_replication_password]</pre><p>The credentials can be created during infrastructure provisioning with the Terraform Kubernetes provider</p><pre>resource &quot;kubernetes_secret_v1&quot; &quot;mysql_creds&quot; {<br>    depends_on = [module.eks]<br>    metadata {<br>        name = &quot;mysql-creds&quot;<br>    }<br><br>    data = {<br>        mysql-root-password = var.mysql_root_password<br>        mysql-password = var.mysql_user_password<br>        mysql-replication-password = var.mysql_replication_password<br>    }<br><br>    type = &quot;Opaque&quot;<br>}</pre><p>Volume permissions in the mysql helm_release resource give the MySQL pod the necessary read/write permissions when writing to the MySQL directory.</p><p>Volume permissions require the os-shell init container, which must be included in the mysql values file.</p><pre>resource &quot;helm_release&quot; &quot;mysql&quot; {<br>    name = &quot;mysql-release&quot;<br>    repository = &quot;https://charts.bitnami.com/bitnami&quot;<br>    chart = &quot;mysql&quot;<br>    version = &quot;14.0.3&quot; <br>    timeout = 120<br><br>    // add values file here<br><br>    values = [&quot;${file(&quot;helmvalues-mysql.yaml&quot;)}&quot;]<br><br>    set = [{<br>        name = &quot;volumePermissions.enabled&quot;<br>        value = true<br>    }]<br><br>    depends_on = [<br>        kubernetes_storage_class_v1.gp3,<br>        kubernetes_secret_v1.mysql_creds<br>    ]<br><br><br>}</pre><pre>volumePermissions:<br>  enabled: true<br>  image:<br>    registry: docker.io<br>    repository: bitnamilegacy/os-shell<br>    tag: 12-debian-12-r50</pre><p>Helm can install the mysql-release, but the pods fail</p><pre>helm ls</pre><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*s4gghVwWi4k9ZfkJFuaZzg.png" /></figure><pre>kubectl get pod</pre><figure><img alt="" src="https://cdn-images-1.medium.com/max/858/1*3S-j0y8ezoeiRfw93sK74A.png" /></figure><pre>kubectl describe pod mysql-release-primary-0</pre><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*ONmU5s8J82JDjnQgVtpsIg.png" /></figure><pre>kubectl get pvc</pre><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*tTHObrwNs0fbFMziqP7arw.png" /></figure><p>The secondary MySQL pod has a PVC assigned and bound, but the primary pod does not.</p><pre>kubectl describe pvc data-mysql-release-primary-0</pre><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*cvFZuti9gl_18XVX8FU6zw.png" /></figure><p>The primary PVC does not have a storage class set.</p><p>Existing MySQL Helm custom values file</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/502/1*ES5Q7h34f11yaZvk4T1N1w.png" /></figure><p>There is no storage class assigned to the primary</p><p>Making the necessary changes</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/568/1*KGb83ZOGvVnAsU2Oxiks3Q.png" /></figure><p>After deleting the existing PVCs, uninstalling the release, and running &quot;terraform apply&quot; again, the issue is resolved.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*y3P7EOjIEK96n4zd1Xwx3w.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/784/1*y5OXCSB0_4V1dkU-4lnPdA.png" /></figure><h4>Deploying PhpMyAdmin with the Helm provider in Terraform</h4><p>Since the mysql.tf file already has the Helm provider and data source &quot;data &quot;aws_eks_cluster_auth&quot; is declared, they don&#39;t have to be declared again in the phpmyadmin.tf file. The Terraform config file for phpmyadmin then looks like this. The “depends_on” block makes sure the storage class and mysql_creds secret are provisioned before phpmyadmin. The storage class is needed to deploy MySQL successfully, and the secret is needed for phpMyAdmin to connect to MySQL.</p><pre>resource &quot;helm_release&quot; &quot;phpmyadmin&quot; {<br>    name = &quot;phpmyadmin-release&quot;<br>    repository = &quot;https://charts.bitnami.com/bitnami&quot;<br>    chart = &quot;phpmyadmin&quot;<br>    version = &quot;20.0.0&quot; <br>    timeout = 120<br><br><br>    values = [&quot;${file(&quot;helmvalues-phpmyadmin.yaml&quot;)}&quot;]<br><br>    depends_on = [<br>        kubernetes_storage_class_v1.gp3,<br>        kubernetes_secret_v1.mysql_creds<br>    ]<br><br><br>}</pre><p>And the values file</p><pre>db:<br>  host: &quot;mysql-release-primary&quot;<br>  port: 3306<br><br><br>service:<br>  type: ClusterIP<br>  ports:<br>    http: 80<br><br><br>global:<br>  security:<br>    allowInsecureImages: True<br><br>image: <br>  registry: docker.io<br>  tag: latest<br>  repository: bitnamilegacy/phpmyadmin</pre><p>When &quot;terraform apply&quot; runs from Jenkins, it creates the phpMyAdmin pod and service.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1017/1*NpUQQN3OOMZjs8kHcjFtjg.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/871/1*D-EnUkCfFdOuVuhL37EjRg.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*p5z6k4a-QrpoST8epXwz5g.png" /></figure><p>phpMyAdmin is running with a ClusterIP service, so it cannot be accessed externally. Port-forward the service to access it locally.</p><pre>kubectl port-forward svc/phpmyadmin-release 8080:80</pre><figure><img alt="" src="https://cdn-images-1.medium.com/max/460/1*kXbvnF5eCkY7NecQj0coDw.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*nPlSu8jn5ZOfJERPGP56kw.png" /></figure><p>Log in with the &quot;existing creds&quot; secret type created with the resource &quot;kubernetes_secret_v1&quot; in mysql.tf and provided by Jenkins.</p><pre>resource &quot;kubernetes_secret_v1&quot; &quot;mysql_creds&quot; {<br>    depends_on = [module.eks]<br>    metadata {<br>        name = &quot;mysql-creds&quot;<br>    }<br><br>    data = {<br>        mysql-root-password = var.mysql_root_password<br>        mysql-password = var.mysql_user_password<br>        mysql-replication-password = var.mysql_replication_password<br>    }<br><br>    type = &quot;Opaque&quot;<br>}</pre><figure><img alt="" src="https://cdn-images-1.medium.com/max/781/1*z4iUQruZvhcf38owd2o5Qw.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*HVIgZnB9zfEV230R6X67Mw.png" /></figure><p><strong>Deploying the Java app to a different namespace using Fargate</strong></p><p>Pods will deploy to Fargate only if namespace=fargate, according to the eks-cluster.tf Terraform code</p><pre>  fargate_profiles = {<br>    company-fargate = {<br>        selectors = [<br>            {<br>            namespace = &quot;fargate&quot;<br>        }<br>        ]<br><br><br>    }<br><br>  }</pre><p>Create a new namespace resource with Terraform in the eks-cluster.tf file.</p><pre>  resource &quot;kubernetes_namespace&quot; &quot;fargate_namespace&quot; {<br>    metadata {<br>      name = &quot;fargate&quot;<br>    }<br>  }</pre><figure><img alt="" src="https://cdn-images-1.medium.com/max/406/1*HB_fJPM5Xd6HcQQ3dEeLPg.png" /></figure><p>Edit the custom app deployment file to add the Fargate namespace so it deploys to Fargate.</p><pre>apiVersion: apps/v1<br>kind: Deployment<br>metadata:<br>  name: company-app<br>  namespace: fargate<br>  labels:<br>    app: company-app</pre><p>Create a Docker secret in the companyapp.tf file so that Kubernetes can pull from the private Docker repository.</p><pre>resource &quot;kubernetes_secret_v1&quot; &quot;docker-creds&quot; {<br>    depends_on = [module.eks]<br>    metadata {<br>        name = &quot;docker-creds&quot;<br>        namespace = &quot;kubernetes_namespace.fargate_namespace.metadata[0].name&quot;<br>    }<br><br>    type = &quot;kubernetes.io/dockerconfigjson&quot;<br><br>    data = {<br>        &quot;.dockerconfigjson&quot; = jsonencode({<br>            auths = {<br>                &quot;https://index.docker.io/v1/&quot; = {<br>                    username= var.docker_username<br>                    password = var.docker_PAT<br>                    auth = base64encode(&quot;${var.docker_username}:${var.docker_PAT}&quot;)<br>                }<br>            }<br>        })<br>    }<br><br><br>    <br>}</pre><p>Create Docker credentials in Jenkins and add them to the Jenkinsfile.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1015/1*0t6C1-keFo2N_eWOyZLCHQ.png" /></figure><p>Add the Fargate namespace, the secrets to pull from the repo, and the image name.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/753/1*OYAPQZH6w1fDkgeDeIqwmw.png" /></figure><p>The &quot;mysql-creds&quot; secret also needs to be created in the Fargate namespace so the Fargate pods can use the MySQL credentials to connect to the database. &quot;for_each&quot; and &quot;namespace&quot; in the metadata block create the credentials in different namespaces.</p><pre>resource &quot;kubernetes_secret_v1&quot; &quot;mysql_creds&quot; {<br>    depends_on = [module.eks, kubernetes_namespace_v1.fargate_namespace]<br>    for_each =  toset([&quot;default&quot;, &quot;fargate&quot;])<br>    metadata {<br>        name = &quot;mysql-creds&quot;<br>        namespace = each.value<br>    }<br><br>    data = {<br>        mysql-root-password = var.mysql_root_password<br>        mysql-password = var.mysql_user_password<br>        mysql-replication-password = var.mysql_replication_password<br>    }<br><br>    type = &quot;Opaque&quot;<br>}</pre><p>The Java application will use the ConfigMap to connect to the MYSQL Server in a different &quot;default&quot; namespace. The namespace must be specified for the PMA_HOST and DB_SERVER values. &quot;mysql-release-primary&quot; is the name of the service, &quot;default&quot; is the name of the namespace, and &quot;svc.cluster.local&quot; is appended at the end.</p><pre>apiVersion: v1<br>kind: ConfigMap<br>metadata:<br>  name: companyapp-configmap<br>  namespace: fargate<br>data:<br>  PMA_HOST: mysql-release-primary.default.svc.cluster.local<br>  PMA_PORT: &quot;3306&quot;<br>  DB_USER: myuser<br>  DB_NAME: mydb<br>  DB_SERVER: mysql-release-primary.default.svc.cluster.local</pre><h4>Issues connecting Fargate Java pod to MySQL</h4><p>After running &quot;terraform apply&quot; with the new configurations, the Fargate pods failed to connect to the MYSQL pods in the default namespace and ultimately failed.</p><p>Further troubleshooting revealed the MySQL security group (or worker node security group) did not allow connections to 3306 from the Fargate security group.</p><pre>aws ec2 describe-security-groups --group-ids sg-03b6aed8c740303dd sg-0f663b0ae5b61b91b</pre><p>Resolved this by adding a rule to allow connections to 3306 from the Fargate security group</p><pre>aws ec2 authorize-security-group-ingress --group-id sg-03b6aed8c740303dd --protocol tcp --port 3306 --source-group sg-0f663b0ae5b61b91b</pre><p>Fargate pods can now connect to MySQL pods using their IP address.</p><h4>DNS issue</h4><p>The Fargate pods can connect to the MYSQL pods with their IP address, but fail with DNS. This is important because the ConfigMap DB_SERVER references the MYSQL server in the default namespace with its hostname (mysql-release-primary.default.svc.cluster.local).</p><p>This issue is resolved by adding ingress rules to the worker node security group that allow UDP and TCP traffic on port 53 from the Fargate security group.</p><pre>aws ec2 authorize-security-group-ingress --group-id sg-03b6aed8c740303dd --protocol udp --port 53 --source-group sg-0f663b0ae5b61b91b</pre><pre>aws ec2 authorize-security-group-ingress --group-id sg-03b6aed8c740303dd --protocol tcp --port 53 --source-group sg-0f663b0ae5b61b91b</pre><p>Add these changes to the EKS Terraform configuration.</p><pre>  resource &quot;aws_vpc_security_group_ingress_rule&quot; &quot;fargate_to_mysql&quot; { # allows fargate pods to access MySQL<br>  security_group_id            = module.eks.node_security_group_id # node security group<br>  referenced_security_group_id = module.eks.cluster_primary_security_group_id # cluster security group<br><br>  ip_protocol = &quot;tcp&quot;<br>  from_port   = 3306<br>  to_port     = 3306<br><br>   <br>}<br><br><br>resource &quot;aws_vpc_security_group_ingress_rule&quot; &quot;fargate_to_node_dns_tcp&quot; { #  allows fargate pods to access DNS over TCP<br>  security_group_id            = module.eks.node_security_group_id<br>  referenced_security_group_id = module.eks.cluster_primary_security_group_id<br><br>  ip_protocol = &quot;tcp&quot;<br>  from_port   = 53<br>  to_port     = 53<br><br> <br>}<br><br>resource &quot;aws_vpc_security_group_ingress_rule&quot; &quot;fargate_to_node_dns_udp&quot; { #  allows fargate pods to access DNS over UDP<br>  security_group_id            = module.eks.node_security_group_id<br>  referenced_security_group_id = module.eks.cluster_primary_security_group_id<br><br>  ip_protocol = &quot;udp&quot;<br>  from_port   = 53<br>  to_port     = 53<br><br>  <br>}</pre><p>After running &quot;terraform apply&quot; to apply the changes, the values inserted into the database from the Java app can be seen through phpMyAdmin</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*pQ5MA7nXiXtEKzEWOfxmPg.png" /></figure><h4>Splitting Terraform config files into different directories and creating a remote state.</h4><p>I experienced many dependency issues while building this project. Terraform does not always know the order of creation (i.e., which resources should be created first), which can lead to a build failure. A way to resolve this is to create separate folders for the Terraform files that create the infrastructure, the files that provision the EKS cluster, and the pods that run in it. Then create a remote state in S3, and pass the data outputs from the infrastructure.tfstate to create the kubernetes resources.</p><p><strong>Create the remote state.</strong></p><p>Create an S3 bucket and reference it in the folder containing the infrastructure Terraform files.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*j5j-7CXMiy3OWgQ9F7v89Q.png" /></figure><pre>terraform {<br>  required_version = &quot;&gt;= 0.12&quot;<br>  backend &quot;s3&quot; {<br>    bucket = &quot;terraform-eks20&quot;<br>    key = &quot;terraform/infrastructure.tfstate&quot;<br>    region = &quot;us-east-1&quot;<br>  }<br>}</pre><p>Project structure</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/280/1*xeJKaMiysUQaYqUTXDvkKA.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/250/1*bVoyZhsCRO8SWkui4PsUKQ.png" /></figure><h4>Infrastructure folder</h4><p>This contains all the Terraform code for provisioning the networking and EKS infrastructure.</p><p>vpc.tf</p><p>This file declares the remote state created in the S3 backend, and the &quot;terraform.tf&quot; state generated locally in the folder is sent to the remote backend.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/738/1*ffulqhxBHU84jxHtFyTEHw.png" /></figure><p>“outputs.tf” creates outputs for values used when Kubernetes resources are created in the cluster.</p><pre>output &quot;cluster_name&quot; {<br>    value = module.eks.cluster_name<br>}<br><br>output &quot;cluster_endpoint&quot; {<br>    value = module.eks.cluster_endpoint<br>}<br><br>output &quot;cluster_certificate_authority_data&quot; {<br>    value = module.eks.cluster_certificate_authority_data<br>    sensitive = true<br>}</pre><p>eks-cluster.tf contains all the Terraform code for provisioning the EKS cluster.</p><p>Here is what the Jenkinsfile stage looks like</p><pre> stage(&#39;Provision Infrastructure (Networking + EKS)&#39;) { <br><br>        environment {<br><br>            AWS_ACCESS_KEY_ID = credentials(&#39;jenkins_aws_access_key_id&#39;)<br>            AWS_SECRET_ACCESS_KEY = credentials (&#39;jenkins_aws_secret_access_key&#39;)<br><br>        }<br>            steps {<br><br>                dir(&#39;terraform/infrastructure&#39;) {<br>               <br>                    echo &quot;Provisioning Infrastructie&quot;<br>                    sh &#39;terraform init&#39;<br>                 //   sh &#39;terraform plan&#39;<br>                    sh &#39;terraform apply --auto-approve&#39;<br><br>                <br><br>                }<br>                <br>            }<br>        }</pre><p><strong>Kubernetes Folder</strong></p><p>Contains the Terraform code for creating necessary Kubernetes resources that later pods will rely on. i,e storage class, secrets, namespaces</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/411/1*8DfFE_mm2kxCvIbvqfQ8CA.png" /></figure><p><strong>Remote-state.tf</strong></p><p>Contains a call to the remote backend that stores the Terraform state from the infrastructure folder. Outputs in this state are used to create the Kubernetes resources with information from the already created infrastructure.</p><pre>data &quot;terraform_remote_state&quot; &quot;infrastructure&quot; {<br>    backend = &quot;s3&quot;<br>    config = {<br>        bucket = &quot;terraform-eks20&quot;<br>        key = &quot;terraform/infrastructure.tfstate&quot;<br>        region = &quot;us-east-1&quot;<br>    }<br><br>}</pre><p><strong>Provider.tf</strong></p><p>This contains the providers and data sources used to create resources in Kubernetes.</p><pre>provider &quot;aws&quot; {<br>    region = &quot;us-east-1&quot;<br>}<br><br>provider &quot;kubernetes&quot; {<br>    host = data.terraform_remote_state.infrastructure.outputs.cluster_endpoint<br>    token = data.aws_eks_cluster_auth.cluster.token<br>    cluster_ca_certificate = base64decode(data.terraform_remote_state.infrastructure.outputs.cluster_certificate_authority_data)<br><br>}<br><br>data &quot;aws_eks_cluster_auth&quot; &quot;cluster&quot; {<br>    name = data.terraform_remote_state.infrastructure.outputs.cluster_name<br>}<br><br><br>}</pre><p>The providers use the outputs from outputs.tf in the infrastructure folder to retrieve the cluster_endpoint and certification information. The token isn&#39;t generated from the remote state; it must be retrieved dynamically from the &quot;aws_eks_cluster_auth&quot; data source.</p><p>Here is what the Jenkinsfile stage looks like</p><pre>stage(&#39;Provision Infrastructure (Networking + EKS)&#39;) { <br><br>        environment {<br><br>            AWS_ACCESS_KEY_ID = credentials(&#39;jenkins_aws_access_key_id&#39;)<br>            AWS_SECRET_ACCESS_KEY = credentials (&#39;jenkins_aws_secret_access_key&#39;)<br><br>        }<br>            steps {<br><br>                dir(&#39;terraform/infrastructure&#39;) {<br>               <br>                    echo &quot;Provisioning Infrastructie&quot;<br>                    sh &#39;terraform init&#39;<br>                 //   sh &#39;terraform plan&#39;<br>                    sh &#39;terraform apply --auto-approve&#39;<br><br>                <br><br>                }<br>                <br>            }<br>        }</pre><p>It reads the infrastructure remote state and data source</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/720/1*yDgvADTxXt-QIK55J1g2Vw.png" /></figure><p>And creates the resources</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/709/1*joUKIfKcx8END7MtDzpy6g.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/573/1*VessjyUfSE66ki1N6g_a4Q.png" /></figure><pre>kubectl get secret</pre><figure><img alt="" src="https://cdn-images-1.medium.com/max/430/1*FMeehTjMhot2yK6XCdfxSg.png" /></figure><pre>kubectl get storageclass</pre><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*0ReTaHBM5Lonx1vqbOXseA.png" /></figure><pre>kubectl get ns</pre><figure><img alt="" src="https://cdn-images-1.medium.com/max/400/1*qdhd1KvnJDZHKQRJCCZJ1w.png" /></figure><pre>kubectl get secret -n fargate</pre><figure><img alt="" src="https://cdn-images-1.medium.com/max/421/1*jMzdlGdTL9N6YmeHh5jPoA.png" /></figure><p><strong>Helm folder</strong></p><p>Configure a remote state for “kubernetes.tfstate” and reference it in the Helm folder to create MYSQL and phpMyAdmin pods.</p><p>In the Kubernetes folder, create a remote state with the key &quot;terraform/kubernetes.tfstate&quot; so the state file is stored there; in the Helm folder, create a remote-state.tf file that references the remote state created in the Kubernetes folder.</p><p>Kubernetes/kubernetes.tf file</p><pre>terraform {<br>  required_version = &quot;&gt;= 0.12&quot;<br>  backend &quot;s3&quot; {<br>    bucket = &quot;terraform-eks20&quot;<br>    key = &quot;terraform/kubernetes.tfstate&quot;<br>    region = &quot;us-east-1&quot;<br>  }<br>}</pre><p>Helm/remote-state.tf file</p><pre>data &quot;terraform_remote_state&quot; &quot;kubernetes&quot; {<br>    backend = &quot;s3&quot;<br>    config = {<br>        bucket = &quot;terraform-eks20&quot;<br>        key = &quot;terraform/kubernetes.tfstate&quot;<br>        region = &quot;us-east-1&quot;<br>    }<br><br>}</pre><p>References the state file created in the Kubernetes folder</p><p>Run &quot;terraform init&quot; in Jenkins. It will ask if you want to copy the existing state to the new S3 backend.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/646/1*yc42yupCvzlpg6XFv2J8UQ.png" /></figure><p>Copy the Kubernetes folder local state file into the new remote backend. The following command is used to do that:</p><pre>terraform init -migrate-state -force-copy</pre><ul><li>migrate-state tells Terraform to move the local Kubernetes state into the new S3 backend</li><li>force-copy answers &quot;yes&quot; to the Terraform prompt</li></ul><figure><img alt="" src="https://cdn-images-1.medium.com/max/685/1*IwltWQrPC7-EzIZx3KO_tg.png" /></figure><p>Use &quot;terraform state list&quot; to see what resources will be copied to the remote backend.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/438/1*De-5ftOAvPMidwC8V6sBdg.png" /></figure><p>The S3 bucket in AWS now contains the infrastructure state and the Kubernetes state.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*i2NCUvbIlGnuwM7VLZSnRQ.png" /></figure><p>Create an outputs.tf file in the Kubernetes folder to output the resources needed to deploy the MySQL pods successfully, i.e., mysql_creds and the storage class.</p><pre>output &quot;gp3_storageclass&quot; {<br>    value = kubernetes_storage_class_v1.gp3.metadata[0].name<br><br>}<br><br>output &quot;mysql_secrets&quot; {<br>    value = {<br>        for namespace, secret in kubernetes_secret_v1.mysql_creds :<br>        namespace =&gt; secret.metadata[0].name<br>    }<br><br>}</pre><p>The Helm folder would then use the newly created output values to retrieve the storage class and mysql_creds values.</p><p>But there is already a custom values YAML file for MySQL and phpMyAdmin. All the needed values are defined there instead.</p><pre>architecture: replication<br>auth:<br>  database: mydb<br>  username: myuser<br>  existingSecret: mysql-creds<br><br>primary:<br>  persistence:<br>    enabled: true<br>    storageClass: gp3<br>    accessModes: [&quot;ReadWriteOnce&quot;]<br>    size: 10Gi<br><br><br>secondary:<br>  replicaCount: 2<br>  persistence:<br>    accessModes: [&quot;ReadWriteOnce&quot;]<br>    enabled: true<br>    storageClass: gp3<br>    size: 10Gi<br><br><br>global:<br>  security:<br>    allowInsecureImages: True<br><br>image: <br>  registry: docker.io<br>  tag: latest<br>  repository: bitnamilegacy/mysql<br><br>volumePermissions:<br>  enabled: true<br>  image:<br>    registry: docker.io<br>    repository: bitnamilegacy/os-shell<br>    tag: 12-debian-12-r50</pre><pre>resource &quot;helm_release&quot; &quot;phpmyadmin&quot; {<br>    name = &quot;phpmyadmin-release&quot;<br>    repository = &quot;https://charts.bitnami.com/bitnami&quot;<br>    chart = &quot;phpmyadmin&quot;<br>    version = &quot;20.0.0&quot; <br>    timeout = 120<br><br><br>    values = [&quot;${file(&quot;helmvalues-phpmyadmin.yaml&quot;)}&quot;]<br><br><br>}</pre><p>So the mysql.tf file will look like this</p><pre>resource &quot;helm_release&quot; &quot;mysql&quot; {<br>    name = &quot;mysql-release&quot;<br>    repository = &quot;https://charts.bitnami.com/bitnami&quot;<br>    chart = &quot;mysql&quot;<br>    version = &quot;14.0.3&quot; <br>    timeout = 120<br><br>    // add values file here<br><br>    values = [&quot;${file(&quot;helmvalues-mysql.yaml&quot;)}&quot;]<br><br>    set = [{<br>        name = &quot;volumePermissions.enabled&quot;<br>        value = true<br>    }]<br><br>    <br><br><br>}</pre><p>and phpmyadmin.tf will look like this</p><pre>resource &quot;helm_release&quot; &quot;phpmyadmin&quot; {<br>    name = &quot;phpmyadmin-release&quot;<br>    repository = &quot;https://charts.bitnami.com/bitnami&quot;<br>    chart = &quot;phpmyadmin&quot;<br>    version = &quot;20.0.0&quot; <br>    timeout = 120<br><br><br>    values = [&quot;${file(&quot;helmvalues-phpmyadmin.yaml&quot;)}&quot;]<br><br><br>}</pre><p>After running &quot;terraform apply&quot; and deploying the two releases</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*hCVmqvPnO3gm7FVMFnrD0g.png" /></figure><p>Run</p><pre>helm ls</pre><p>To view the releases</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*FN9aQwIfVmX89vjuS7dwVA.png" /></figure><pre>kubectl get pod</pre><p>To view the pods</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/958/1*z060C9Uh4_R_GAQdacdBaQ.png" /></figure><p>All pods appear to be running without issues</p><p>Test access to MYSQL through phpMyAdmin</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*kq7EFXTt_IIR-Ti_g-m_rw.png" /></figure><p>phpMyAdmin can connect to MySQL and uses the configured credentials</p><pre>kubectl get pvc</pre><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*9iMKEr_KPy3p03vTBCYsiA.png" /></figure><p>All MYSQL pods have PVCs bound to them with the correct storage class</p><p>To save the Helm Terraform state file to the S3 backend, include the code in one of the Terraform files in the Helm folder, and use a key that doesn’t conflict with other remote states.</p><pre>terraform {<br>  required_version = &quot;&gt;= 0.12&quot;<br>  backend &quot;s3&quot; {<br>    bucket = &quot;terraform-eks20&quot;<br>    key = &quot;terraform/helm.tfstate&quot;<br>    region = &quot;us-east-1&quot;<br>  }<br>}</pre><p>After running &quot;terraform init -migrate-state -force-copy&quot;, a new remote backend will be created in S3 with the Helm Terraform local state file.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/567/1*Zyvwwn5gH0of4UjckDRAbA.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/403/1*cxYt7xassNRM3rbk2OUJCg.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*mInEwgNFAxU5Zj_u6nTZgA.png" /></figure><p><strong>Deploy custom app to Fargate in a different namespace</strong></p><p>Project structure</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/363/1*cQcPo8ip70ll4C46g1m6cQ.png" /></figure><p>The necessary providers should be added in provider.tf</p><pre>provider &quot;aws&quot; {<br>    region = &quot;us-east-1&quot;<br>}<br><br>provider &quot;kubernetes&quot; {<br>    host = data.terraform_remote_state.infrastructure.outputs.cluster_endpoint<br>    token = data.aws_eks_cluster_auth.cluster.token<br>    cluster_ca_certificate = base64decode(data.terraform_remote_state.infrastructure.outputs.cluster_certificate_authority_data)<br><br>}<br><br>data &quot;aws_eks_cluster_auth&quot; &quot;cluster&quot; {<br>    name = data.terraform_remote_state.infrastructure.outputs.cluster_name<br>}<br><br></pre><p>remote-state.tf file should import the necessary state file from the remote backend</p><pre>data &quot;terraform_remote_state&quot; &quot;infrastructure&quot; {<br>    backend = &quot;s3&quot;<br>    config = {<br>        bucket = &quot;terraform-eks20&quot;<br>        key = &quot;terraform/infrastructure.tfstate&quot;<br>        region = &quot;us-east-1&quot;<br>    }<br><br>}</pre><p>companyapp.tf creates the Docker creds secret, then deploys the configmap, pods, and service using their manifest files</p><pre>variable &quot;docker_username&quot; {<br>  type = string<br>}<br><br>variable &quot;docker_PAT&quot; {<br>  type = string<br>}<br><br><br>resource &quot;kubernetes_secret_v1&quot; &quot;docker-creds&quot; {<br>    metadata {<br>        name = &quot;docker-creds&quot;<br>        namespace = &quot;fargate&quot;<br>    }<br><br>    type = &quot;kubernetes.io/dockerconfigjson&quot;<br><br>    data = {<br>        &quot;.dockerconfigjson&quot; = jsonencode({<br>            auths = {<br>                &quot;https://index.docker.io/v1/&quot; = {<br>                    username = var.docker_username<br>                    password = var.docker_PAT<br>                    auth = base64encode(&quot;${var.docker_username}:${var.docker_PAT}&quot;)<br>                }<br>            }<br>        })<br>    }<br><br><br>    <br>}<br><br><br>resource &quot;kubernetes_manifest&quot; &quot;companyapp_configmap&quot; {<br>  manifest = yamldecode(<br>    file(&quot;companyapp-configmap.yaml&quot;)<br>  )<br>}<br><br><br>resource &quot;kubernetes_manifest&quot; &quot;companyapp_deployment&quot; {<br>  depends_on = [kubernetes_manifest.companyapp_configmap]<br>  manifest = yamldecode(<br>    file(&quot;companyapp-deployment.yaml&quot;)<br>  )<br>}<br><br>resource &quot;kubernetes_manifest&quot; &quot;companyapp_service&quot; {<br>  depends_on = [kubernetes_manifest.companyapp_configmap]<br>  manifest = yamldecode(<br>    file(&quot;companyapp-service.yaml&quot;)<br>  )<br>}</pre><p>Configmap manifest file</p><pre>apiVersion: v1<br>kind: ConfigMap<br>metadata:<br>  name: companyapp-configmap<br>  namespace: fargate<br>data:<br>  PMA_HOST: mysql-release-primary.default.svc.cluster.local<br>  PMA_PORT: &quot;3306&quot;<br>  DB_USER: myuser<br>  DB_NAME: mydb<br>  DB_SERVER: mysql-release-primary.default.svc.cluster.local</pre><p>Deployment manifest file</p><pre>apiVersion: apps/v1<br>kind: Deployment<br>metadata:<br>  name: company-app<br>  namespace: fargate<br>  labels:<br>    app: company-app<br>spec: <br>  replicas: 2<br>  selector:<br>    matchLabels:<br>      app: company-app<br>  template:<br>    metadata:<br>      labels:<br>        app: company-app<br>    spec:<br>      imagePullSecrets:<br>      - name: docker-creds<br>      containers:<br>      - name: company-app<br>        image: michael101/java-maven-app:company-app-1.0<br>        imagePullPolicy: Always<br>        ports:<br>        - containerPort: 8080<br>        env:<br>          - name: DB_USER<br>            valueFrom:<br>              configMapKeyRef:<br>                name: companyapp-configmap<br>                key: DB_USER<br>          - name: DB_PWD<br>            valueFrom:<br>              secretKeyRef:<br>                name: mysql-creds<br>                key: mysql-password<br>          - name: DB_NAME<br>            valueFrom:<br>              configMapKeyRef: <br>                name: companyapp-configmap<br>                key: DB_NAME<br>          - name: DB_SERVER<br>            valueFrom:<br>              configMapKeyRef:<br>                name: companyapp-configmap<br>                key: DB_SERVER </pre><p>Service manifest file</p><pre>apiVersion: v1<br>kind: Service<br>metadata:<br>  name: companyapp-service<br>  namespace: fargate<br>spec:<br>  type: LoadBalancer<br>  selector:<br>    app: company-app<br>  ports:<br>  - protocol: TCP<br>    port: 8080<br>    targetPort: 8080</pre><p>After applying the configuration with &quot;terraform apply&quot;</p><pre>kubectl get configmap -n fargate</pre><figure><img alt="" src="https://cdn-images-1.medium.com/max/480/1*Cv7T9YS2mtEFsAa5KgvU0Q.png" /></figure><pre>kubectl get svc -n fargate</pre><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*KMj2J3gN0lhuZDVYlLKRpA.png" /></figure><pre>kubectl get secret -n fargate</pre><figure><img alt="" src="https://cdn-images-1.medium.com/max/747/1*L1g72KV8t5iuK2JpmvFC_w.png" /></figure><pre>kubectl get pod -n fargate</pre><figure><img alt="" src="https://cdn-images-1.medium.com/max/768/1*Z8oTVgZz7O-FBgAXTzYUdQ.png" /></figure><pre>kubectl get pods -A -o wide</pre><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*S8EQYpAGkgZZbSgQw5MS6Q.png" /></figure><p>phpMyAdmin shows the app inserting test data into the database, meaning the company application deployed in Fargate at a different namespace can communicate with the MYSQL pods in the default namespace</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*XNU0UzlDzEPD5vhcePMgpw.png" /></figure><p><strong>Create a new remote backend with the companyapp local state file</strong></p><p>In the companyapp.tf file, add the remote backend configuration to the file</p><pre>terraform {<br>  required_version = &quot;&gt;= 0.12&quot;<br>  backend &quot;s3&quot; {<br>    bucket = &quot;terraform-eks20&quot;<br>    key = &quot;terraform/companyapp.tfstate&quot;<br>    region = &quot;us-east-1&quot;<br>  }<br>}</pre><p>Then, in the Jenkinsfile, run</p><pre>terraform init -migrate-state -force-copy</pre><figure><img alt="" src="https://cdn-images-1.medium.com/max/574/1*SUzphFGWwpnHjEk-GJZ5dw.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*Apct1ER0s0-_x2mtTYMAqg.png" /></figure><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=9ca6c8749057" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Deploying an application to EKS using eksctl, cluster-autoscaler, and Fargate.]]></title>
            <link>https://medium.com/@micsamkid500/deploying-an-application-to-eks-using-eksctl-cluster-autoscaler-and-fargate-fc9bfeec51ad?source=rss-5bd4345bedb5------2</link>
            <guid isPermaLink="false">https://medium.com/p/fc9bfeec51ad</guid>
            <category><![CDATA[aws-eks]]></category>
            <category><![CDATA[cluster-autoscaler]]></category>
            <category><![CDATA[aws-ebs]]></category>
            <category><![CDATA[aws-fargate]]></category>
            <category><![CDATA[kubernetes]]></category>
            <dc:creator><![CDATA[Samuel Michael]]></dc:creator>
            <pubDate>Fri, 04 Sep 2026 06:45:55 GMT</pubDate>
            <atom:updated>2026-09-04T06:45:55.000Z</atom:updated>
            <content:encoded><![CDATA[<h4>Introduction</h4><p>This project demonstrates how to deploy multiple applications (phpmyadmin, MySQL, Java app) using a continuous deployment pipeline with Jenkins. It also covers role creation, autoscaling, and Fargate for AWS-managed nodes.</p><h4>Tools used</h4><p>eksctl, Amazon EKS, Kubernetes, Amazon ECR, Kubernetes, Fargate, Docker, Jenkins, Git</p><h4>Create an EKS cluster</h4><pre>eksctl create cluster --name company-cluster --region us-east-1 --nodegroup-name company-nodegroup --node-type t2.micro --nodes 2 --nodes-min 2 --nodes-max 2</pre><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*Y7dOJ4NyAsp_3447QbA_1A.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/652/1*NbgbAYTDGoParTrmsV0TxA.png" /></figure><p>eksctl creates 2 availability zones, us-east-1a and us-east-1f, and 4 subnets: 2 public subnets and 2 private subnets. Each availability zone is assigned a public and private subnet. It also creates add-ons like vpc-cni, kube-proxy, and coredns.</p><h4>EKS cluster in the AWS Management Console</h4><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*udo14uZehrYrqdqIdvFBNg.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*YySzAJW755GzthEH3b6iqA.png" /></figure><p>Here is alist of add-ons that have been installed. They can be viewed from the kube-system namespace.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/907/1*W1F_G3RSd7euPOP39d1zVA.png" /></figure><p>The Compute tab shows the EC2 instances that have been created and the node group.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*2cwSwYN-1lbVR4oQY14-jA.png" /></figure><p>The Networking tab shows the created VPC and subnets</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*ZjlkP2Z-ONdnv_Sbdp4cOQ.png" /></figure><p>The Add-ons tab shows the list of add-ons that have been installed</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*p-kxvyzo-b6c79Wv_xhj6A.png" /></figure><h4>IAM</h4><p>eksctl creates certain roles when the create cluster command is executed.</p><ul><li>EKS cluster IAM roles</li><li>EC2 node IAM roles</li><li>EKS service-linked roles</li></ul><h4>EKS cluster IAM roles</h4><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*7i2eOyRNhQ--sN6oLIBBjQ.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*tWgKiocEQdy-6dcoLNKCEQ.png" /></figure><p>EKS assumes this role to interact with AWS resources and perform actions on services defined in the policy (AmazonEKSClusterPolicy) above.</p><h4>EC2 node IAM roles</h4><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*nvth5IlCjlqINR02mPpFGQ.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*kpGgcUf2nSwWGJK9Hv5x8g.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*5Mh6MfESulPyu-8vKShEGw.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*_xrcvg4LEQbkgi-WzueOKw.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*ALKx8NTVSxyzZRbXKCIo1A.png" /></figure><p>EC2 worker nodes assume this role to pull containers out of ECR, assign private IP addresses, attach, create, and delete network interfaces, and describe instances, instance types, and route tables, and so on.</p><h4>EKS serivce-linked roles</h4><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*tKdkldgEuKakkBbGmNLw_w.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*nG08JJMEPKlcTMOt5L0xFg.png" /></figure><p>These service-linked roles are created by AWS, with various policies attached. For example, the AWSServiceRoleForAmazonEKS role is attached with a policy that gives it the permissions to create security groups, create network interface permissions, and modify network interface attributes.</p><p>Other service-linked roles created by AWSinclude AWSServiceRoleForAmazonEKSNodegroup (for the eks-nodegroup), and AWSServiceRoleForAutoScaling (for the autoscaling service)</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*dB5ghdk9EGyG6D4cyCK_PQ.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*fqoktUWuZx3RL0Mk1i8teQ.png" /></figure><h4>Deployment</h4><p>Three containers need to be deployed. Java app, phpMyAdmin, and 2 MYSQL replicas.</p><p>The deployment feature will be tested with phpMyAdmin, as it’s the easiest to deploy and track. This can be used to eliminate and resolve easy-to-fix issues (e.g., access issues), so the more difficult ones (like MySQL deployment) can be dealt with a little easier.</p><p>External access is tested with the Elastic load balancer to see if phpMyAdmin is deployed correctly, and can be accessed externally.</p><p>Using Jenkins, kubectl needs</p><ul><li>The kubeconfig file</li><li>AWS secret ID and key</li></ul><p>To connect to EKS</p><p>The Jenkins container usually will not have a file editor (like nano or vi/m), so a config file has to be created from the host and copied into the container.</p><pre>apiVersion: v1<br>kind: Config<br>clusters:<br>- cluster:<br>    certificate-authority-data: &lt;certificate-data&gt;<br>    server: &lt;endpoint-url&gt;<br>  name: kubernetes<br>contexts:<br>- context:<br>    cluster: kubernetes<br>    user: aws<br>  name: aws<br>current-context: aws<br>users:<br>- name: aws<br>  user:<br>    exec:<br>      apiVersion: client.authentication.k8s.io/v1beta1<br>      command: /usr/local/bin/aws-iam-authenticator<br>      args:<br>        - &quot;token&quot;<br>        - &quot;-i&quot;<br>        - &lt;cluster-name&gt;</pre><p>The certificate data, cluster-name, and endpoint-url have to be filled out in the config file</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*YGsj6tolxglsvXNWsAkXgw.png" /></figure><p>The Jenkinsfile should already have the AWS secret ID and key set as environment variables.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/787/1*jjVVk3gEXQf21AHYj8g5yw.png" /></figure><p>The file can be copied from the host to the Jenkins container using:</p><pre>docker cp config [container id or name]:/var/jenkins_home/.kube</pre><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*ia3n79YgVeFSCwKIsXOzhA.png" /></figure><p>Kubectl has to be installed on the Jenkins container</p><pre>curl -LO https://storage.googleapis.com/kubernetes-release/release/$(curl -s https://storage.googleapis.com/kubernetes-release/release/stable.txt)/bin/linux/amd64/kubectl; chmod +x ./kubectl; mv ./kubectl /usr/local/bin/kubectl</pre><p>aws-iam-authenticator also needs to be installed on the Jenkins container</p><pre>curl -Lo aws-iam-authenticator https://github.com/kubernetes-sigs/aws-iam-authenticator/releases/download/v0.6.11/aws-iam-authenticator_0.6.11_linux_amd64<br>chmod +x ./aws-iam-authenticator<br>mv ./aws-iam-authenticator /usr/local/bin</pre><h4>Deploying phpmyadmin with Jenkins</h4><p>As explained, a simple phpmyadmin deployment was created to confirm that pods can be deployed and accessed externally.</p><pre>apiVersion: apps/v1<br>kind: Deployment<br>metadata:<br>  name: phpmyadmin<br>  labels:<br>    app: phpmyadmin<br>spec:<br>  replicas: 1<br>  selector:<br>    matchLabels:<br>      app: phpmyadmin<br>  template:<br>    metadata:<br>      labels:<br>        app: phpmyadmin<br>    spec:<br>      containers:<br>      - name: phpmyadmin<br>        image: phpmyadmin<br>        ports:<br>        - containerPort: 80<br>        env:<br>        - name: PMA_HOST<br>          valueFrom:<br>            configMapKeyRef:<br>              name: companyapp-configmap<br>              key: PMA_HOST<br>        - name: PMA_PORT<br>          valueFrom:<br>            configMapKeyRef:<br>              name: companyapp-configmap<br>              key: PMA_PORT<br><br>---<br><br>apiVersion: v1<br>kind: Service<br>metadata:<br>  name: phpmyadmin-service<br>spec:<br>  type: LoadBalancer<br>  selector:<br>    app: phpmyadmin<br>  ports:<br>    - protocol: TCP<br>      port: 8081<br>      targetPort: 80<br><br></pre><p>Deployed phpmyadmin pod and service</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*Df2nt1WSSH2mGfKI5EhtcA.png" /></figure><p>phpmyadmin is accessible externally</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*G7kU_-nrLf1RG0kg4CJRcw.png" /></figure><h4>Deploy stage for phpmyadmin in the Jenkinsfile</h4><pre>stage(&#39;Deploy&#39;) {<br>            environment {<br>                AWS_ACCESS_KEY_ID = credentials(&#39;jenkins_aws_access_key_id&#39;)<br>                AWS_SECRET_ACCESS_KEY = credentials(&#39;jenkins_aws_secret_access_key&#39;)<br>            }<br>            steps {<br>                script {<br>                    echo &#39;Deploying image...&#39;<br>                    sh &#39;envsubst &lt; eks-deployment/phpmyadmin-deployment.yaml | kubectl apply -f -&#39;<br>                }<br>             <br>            }<br>        }</pre><h4>Installing Helm in the Jenkins container</h4><p>Enter the Jenkins container as root with the following command</p><pre>docker exec -it -u 0 [container name or id] /bin/bash</pre><p>Run the following commands to install Helm in the container</p><pre>$ curl -LO https://git.io/get_helm.sh<br>$ chmod 700 get_helm.sh<br>$ ./get_helm.sh</pre><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*JWnz40woLxYjp_XxSMhOcA.png" /></figure><h4>MYSQL persistence and storage</h4><p>Since MySQL will be deployed on AWS, Elastic Block Storage (EBS) will be used to provide persistence for the MySQL pods. The aws-ebs-csi driver needs to be installed as an add-on to use EBS.</p><pre>eksctl create addon --cluster company-cluster --name aws-ebs-csi-driver</pre><p>Verify it is installed with</p><pre>eksctl get addon --cluster company-cluster<br>kubectl get csidriver<br>aws eks list-addons --cluster-name company-cluster</pre><p>I had issues installing this add-on, because there wasn’t enough capacity on the nodes. This made sense, as I was using a t2.micro instance type, and already had a lot of add-ons.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/394/1*AE8x7LzEt_RGZabSntyhtw.png" /></figure><pre>aws eks describe-addon --cluster-name company-cluster --addon-name aws-ebs-csi-driver<br>kubectl get pods -n kube-system -l app.kubernetes.io/name=aws-ebs-csi-driver -o wide</pre><pre>{<br>    &quot;addon&quot;: {<br>        &quot;addonName&quot;: &quot;aws-ebs-csi-driver&quot;,<br>        &quot;clusterName&quot;: &quot;company-cluster&quot;,<br>        &quot;status&quot;: &quot;UPDATING&quot;,<br>        &quot;addonVersion&quot;: &quot;v1.65.0-eksbuild.1&quot;,<br>        &quot;health&quot;: {<br>            &quot;issues&quot;: [<br>                {<br>                    &quot;code&quot;: &quot;InsufficientNumberOfReplicas&quot;,<br>                    &quot;message&quot;: &quot;The add-on is unhealthy because all deployments have all pods unscheduled 0/3 nodes are available: 3 Too many pods. no new claims to deallocate, preemption: 0/3 nodes are available: 3 No preemption victims found for incoming pod.&quot;<br>                }<br>            ]<br>        },<br>        &quot;addonArn&quot;: &quot;arn:aws:eks:us-east-1:420392929933:addon/company-cluster/aws-ebs-csi-driver/56d02efe-8bb8-c62f-c11e-ff71fdec99b1&quot;,<br>        &quot;createdAt&quot;: &quot;2026-09-01T12:21:02.974000-05:00&quot;,<br>        &quot;modifiedAt&quot;: &quot;2026-09-01T14:01:18.794000-05:00&quot;,<br>        &quot;tags&quot;: {},<br>        &quot;namespaceConfig&quot;: {<br>            &quot;namespace&quot;: &quot;kube-system&quot;<br>        }<br>    }<br>}</pre><pre>Events:<br>  Type     Reason            Age                  From               Message<br>  ----     ------            ----                 ----               -------<br>  Warning  FailedScheduling  16m                  default-scheduler  0/4 nodes are available: 1 Insufficient memory, 4 Too many pods. no new claims to deallocate, preemption: 0/4 nodes are available: 4 No preemption victims found for incoming pod.<br>  Warning  FailedScheduling  100s (x11 over 16m)  default-scheduler  0/4 nodes are available: 4 Too many pods. no new claims to deallocate, preemption: 0/4 nodes are available: 4 No preemption victims found for incoming pod.</pre><p>4 pods appear to be the maximum capacity for t2.micro</p><pre>kubectl get nodes -o custom-columns=NAME:.metadata.name,TYPE:.metadata.labels.node\.kubernetes\.io/instance-type,PODS:.status.capacity.pods</pre><pre>NAME                             TYPE       PODS<br>ip-192-168-17-215.ec2.internal   t2.micro   4<br>ip-192-168-23-13.ec2.internal    t2.micro   4<br>ip-192-168-37-62.ec2.internal    t2.micro   4<br>ip-192-168-5-48.ec2.internal     t2.micro   4<br>ip-192-168-52-207.ec2.internal   t2.micro   4</pre><h4>Fix 1: Create a new node group with a bigger instance type</h4><pre>eksctl create nodegroup --cluster company-cluster --name bigger-nodegroup --node-type t3.medium --nodes 3 --nodes-min 3 --nodes-max 3</pre><p>The t3.medium instance type has maximum capacity of 17 pods.</p><p>I had 2 major issues after the instance type was switched to t3 medium</p><ul><li>The ebs-csi driver did not have the correct permissions</li><li>The values in the mysql values were not entered in the correct format.</li></ul><h4>Fix 2: Create a pod identity association to associate the ebs-csi service account with an IAM role that has the AmazonEBSCSIDriverPolicy</h4><pre>eksctl create podidentityassociation --cluster company-cluster --namespace kube-system --service-account-name ebs-csi-controller-sa --role-name AmazonEKS_EBS_CSI_DriverRole --permission-policy-arns arn:aws:iam::aws:policy/service-role/AmazonEBSCSIDriverPolicy</pre><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*VmK-lh5GRjHIuLNhi-PTZQ.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*p8qaCw_nrMH1Eo2ZAVE-9w.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*XsRWh0TXCV57b7obYsrOlQ.png" /></figure><p>Create a gp3 StorageClass configuration and apply it</p><pre>apiVersion: storage.k8s.io/v1<br>kind: StorageClass<br>metadata:<br>  name: gp3<br>provisioner: ebs.csi.aws.com<br>volumeBindingMode: WaitForFirstConsumer<br>allowVolumeExpansion: true<br>parameters:<br>  type: gp3<br>  encrypted: &quot;true&quot;<br>  fsType: ext4<br>reclaimPolicy: Delete</pre><p><strong>Fix 3: Filling out the MYSQL values file correctly</strong></p><p>Values were entered in the MYSQL values yaml file in a way that was inconsistent with the MYSQL Bitnami chart. I resolved this by checking the manifest file to determine the exact structure of how values needed to be entered in the file</p><pre>helm get values mysql -n default -a</pre><p>Working StorageClass configuration</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/489/1*p_u3scNqpQzts-yPihS98A.png" /></figure><p>Working values file for MYSQL values file</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/574/1*-kbbJkUrqbeyWhIr-PiHQg.png" /></figure><h4>Connecting phpMyAdmin to MySQL</h4><p>I created a ConfigMap for sharing environment variables that will be used by phpmyadmin and company-app</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/544/1*Z_2Fp17iowrAHljLwWfx1A.png" /></figure><p>Added environment variables to phpMyAdmin using the ConfigMap</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/525/1*LFm95D7UaK3RyrbFWWsnMg.png" /></figure><p>And made changes to the Jenkinsfile</p><pre>stage(&#39;Deploy&#39;) {<br>            environment {<br>                AWS_ACCESS_KEY_ID = credentials(&#39;jenkins_aws_access_key_id&#39;)<br>                AWS_SECRET_ACCESS_KEY = credentials(&#39;jenkins_aws_secret_access_key&#39;)<br>            }<br>            steps {<br>                script {<br>                    echo &#39;Deploying the configmap&#39;<br>                    sh &#39;envsubst &lt; eks-deployment/companyapp-configmap.yaml | kubectl apply -f -&#39;                    <br>                   // echo &#39;Deploying mysql...&#39;<br>                   // sh &#39;helm repo add bitnami https://charts.bitnami.com/bitnami&#39; // add repo first<br>                   // sh &#39;helm install mysql --values eks-deployment/helmvalues-mysql.yaml bitnami/mysql&#39; // install mysql from added repo<br>                    echo &#39;Deploying phpmyadmin&#39;<br>                    sh &#39;envsubst &lt; eks-deployment/phpmyadmin-deployment.yaml | kubectl apply -f -&#39;<br>                }<br>             <br>            }<br>        }</pre><p>Commented out the already deployed MySQL pods and added new lines for deploying the ConfigMap and phpMyAdmin.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*aQDiUAC35jfwLgBnoZoYwg.png" /></figure><p>As can be seen above, the database and phpMyAdmin seem to be communicating with each other.</p><h4>Building the App</h4><p>Now that phpmyadmin is able to communicate with the MYSQL server, the app and docker image will be built sent to ECR and deployed to EKS, so it can talk to other containers in the cluster and perform its functions (creating a table and adding values on the MYSQL server)</p><p>The application is built with “gradle build”. For this to work, gradle and jdk must be setup in the Tools section in Jenkins</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*1no3UpNjHn0rOlsR-HEBjg.png" /></figure><p>Gradle can be installed automatically and the name should be added to the tools section in Jenkins</p><pre> tools {<br>            gradle &#39;gradle-9.8.0&#39;<br>            jdk &#39;jdk-21&#39;<br>     }</pre><p>JDK can be added manually, if it is already installed in the Jenkins container. Use “which java” to find the JDK path in the container, then add it to Jenkins.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/874/1*E-ynG-u2JYcVy3GA7wE1pg.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*w-J0ITL0DqvRw6y-PnWckQ.png" /></figure><h4>Building the Image and pushing to ECR</h4><p>Contents of Dockerfile</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1006/1*3sD6Ck4naXRNk9PeziLgfg.png" /></figure><p>Build the Dockerfile using:</p><pre>docker build -t [IMAGE_NAME].</pre><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*U_K5nyWNwPbBnBXNpxqWLQ.png" /></figure><p>Log in to AWS ECR using the AWS CLI</p><pre>aws ecr get-login-password --region us-east-1 | docker login --username AWS --password-stdin 420392929933.dkr.ecr.us-east-1.amazonaws.com</pre><p>Tag the image with the ECR repo name</p><pre>docker tag company-app:1.0 420392929933.dkr.ecr.us-east-1.amazonaws.com/company-app:1.0</pre><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*W4vH7C6Bqki9b3E2fNrp9g.png" /></figure><p>Push the tagged image to ECR</p><pre>docker push 420392929933.dkr.ecr.us-east-1.amazonaws.com/company-app:1.0</pre><figure><img alt="" src="https://cdn-images-1.medium.com/max/787/1*HZU4K78MEQ6krnShYTjoVg.png" /></figure><p>The image is successfully pushed to ECR</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*2R39Fsup4sccZ0Bov8cAAw.png" /></figure><p>The build section looks like this in the Jenkinsfile</p><pre>stage(&#39;Build Image&#39;) {<br>            steps {<br>                script {<br>                echo &quot;Building the docker image&quot;<br>                withCredentials([usernamePassword(credentialsId: &#39;ecr-credentials&#39;, passwordVariable: &#39;PASS&#39;, usernameVariable: &#39;USER&#39;)]){<br>                   sh &quot;docker build -t ${DOCKER_REPO_SERVER}:${IMAGE_NAME} .&quot;<br>                   sh &quot;echo $PASS | docker login -u $USER --password-stdin ${DOCKER_REPO_SERVER} &quot;<br>                   sh &quot;docker push ${DOCKER_REPO_SERVER}:${IMAGE_NAME} &quot;<br>                }<br>                }<br>       <br>            }<br>        }</pre><p>The Java application is deployed to Kubernetes from the Jenkinsfile</p><pre>stage(&#39;Deploy&#39;) {<br>            environment {<br>                AWS_ACCESS_KEY_ID = credentials(&#39;jenkins_aws_access_key_id&#39;)<br>                AWS_SECRET_ACCESS_KEY = credentials(&#39;jenkins_aws_secret_access_key&#39;)<br>            }<br>            steps {<br>                script {<br>                    echo &#39;Deploying the configmap&#39;<br>                    sh &#39;envsubst &lt; eks-deployment/companyapp-configmap.yaml | kubectl apply -f -&#39;                    <br>                   // echo &#39;Deploying mysql...&#39;<br>                   // sh &#39;helm repo add bitnami https://charts.bitnami.com/bitnami&#39; // add repo first<br>                   // sh &#39;helm install mysql --values eks-deployment/helmvalues-mysql.yaml bitnami/mysql&#39; // install mysql from added repo<br>                    echo &#39;Deploying phpmyadmin&#39;<br>                    sh &#39;envsubst &lt; eks-deployment/phpmyadmin-deployment.yaml | kubectl apply -f -&#39;<br>                    echo &#39;Deploying companyapp&#39;<br>                    sh &#39;envsubst &lt; eks-deployment/companyapp-deployment.yaml | kubectl apply -f -&#39;<br>                }<br>             <br>            }<br>        }</pre><p>The application created a table and inserted values into it.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*IGzJreCtNjS77ClGk4sPFQ.png" /></figure><p>Checking with “kubectl get pod”</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/796/1*Mydm_n6KBr0qlcNRlWUaZw.png" /></figure><p>All pods appear to be running without issues</p><h4>Setting up Autoscaling</h4><p>The autoscaling feature automatically scales the number of nodes up or down based on the resources available to run the pods created.</p><p>The autoscaler needs access to create and delete EC2 instances on AWS</p><p>Create a custom policy on IAM</p><pre>{<br>    &quot;Version&quot;: &quot;2012-10-17&quot;,<br>    &quot;Statement&quot;: [<br>        {<br>            &quot;Action&quot;: [<br>                &quot;autoscaling:DescribeAutoScalingGroups&quot;,<br>                &quot;autoscaling:DescribeAutoScalingInstances&quot;,<br>                &quot;autoscaling:DescribeLaunchConfigurations&quot;,<br>                &quot;autoscaling:DescribeScalingActivities&quot;,<br>                &quot;autoscaling:SetDesiredCapacity&quot;,<br>                &quot;autoscaling:TerminateInstanceInAutoScalingGroup&quot;,<br>                &quot;ec2:DescribeInstanceTypes&quot;,<br>                &quot;ec2:DescribeLaunchTemplateVersions&quot;<br>            ],<br>            &quot;Resource&quot;: &quot;*&quot;,<br>            &quot;Effect&quot;: &quot;Allow&quot;<br>        }<br>    ]<br>}</pre><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*TobKHrIb2UNUdZ6d9NxjuQ.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*23qzc826pOA2AaLr_N9j9w.png" /></figure><p>The autoscaler can use the EC2 service to list resources and use EC2 Auto Scaling to list and write resources.</p><p>Set up a connection between AWS IAM and the cluster</p><p>Create a new identity provider using the OpenID Connect provider URL</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*YGsj6tolxglsvXNWsAkXgw.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*DDUd-NcLTl-ldd1CxZeISA.png" /></figure><p>Create a role from the provider and associate the custom policy to it.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*or2FFqgaZGsdoNAziR4TXw.png" /></figure><p>Add the custom-created ClusterAutoScaler policy and create the role</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*q9UNPtvBoTCJW-4miLybOA.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*7LgEs2V5K3dSJNoIQtDcBw.png" /></figure><p>Editing the cluster autoscaler config file</p><p>The cluster autoscaler components need the tags to detect and communicate with the autoscaling group in AWS.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*0k-fce3pZ3XsDiYwi13URQ.png" /></figure><p>The cluster autoscaler deployment can be found here:</p><p><a href="https://raw.githubusercontent.com/kubernetes/autoscaler/master/cluster-autoscaler/cloudprovider/aws/examples/cluster-autoscaler-autodiscover.yaml">https://raw.githubusercontent.com/kubernetes/autoscaler/master/cluster-autoscaler/cloudprovider/aws/examples/cluster-autoscaler-autodiscover.yaml</a></p><p>The cluster name is added to the “command” section, and the tags can also be seen here</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*wogt5naOfxf3frgvaEOPJQ.png" /></figure><p>Add the created role to the autoscaler service account</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*Mi7n2erF3pYNpQJqeFRo5w.png" /></figure><p>Add the safe-to-evict: “false” tag, so the Autoscaler doesn’t accidentally evict itself when scaling down nodes.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/655/1*imwlAmM5M7j5HOpifW4U2w.png" /></figure><p>Change the container image version to the version in the cluster</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*g4B70ZEVMHvsLFulncJEBw.png" /></figure><p>And the tag corresponding to the version here:</p><p><a href="https://github.com/kubernetes/autoscaler/tags">Tags · kubernetes/autoscaler</a></p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*mFP7KzE9fHN9A-keyC25Cg.png" /></figure><p>Add the region</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/619/1*ec_ouRGvScAG8f5sWfARTg.png" /></figure><p>Apply the config file with</p><pre>kubectl apply -f cluster-autoscaler.yaml</pre><figure><img alt="" src="https://cdn-images-1.medium.com/max/634/1*0X7QWfNRQvi3YDK9ZWYljA.png" /></figure><p>Check the status of the autoscaler</p><pre>kubectl get deployment -n kube-system cluster-autoscaler</pre><figure><img alt="" src="https://cdn-images-1.medium.com/max/700/1*RwJH8UTkb9Qu6k70eo6sMw.png" /></figure><p>List all the pods deployed on kube-system</p><pre>kubectl get pod -n kube-system</pre><figure><img alt="" src="https://cdn-images-1.medium.com/max/864/1*udgPYkBbuJBIRDeAfJ9OsQ.png" /></figure><p>Check out the logs for the autoscaler using:</p><pre>kubectl logs cluster-autoscaler-686d9698f-sc8g4 -n kube-system</pre><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*WfxNQENSOMYcYWZ7oSLI2w.png" /></figure><p>The autoscaler isn’t scaling down any nodes because the minimum, desired, and max nodes are the same: 3</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*L3VLtVs60MtuOEhsPy4BOw.png" /></figure><p>I will change the minimum nodes to 1, desired nodes to 2, and maximum to 3 to test the autoscaler feature.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/982/1*pyYSnhQDu7THec4rxLDTOw.png" /></figure><p>Checking the logs again</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*kQosmClpcijwdCzIaKCIEw.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*yDqoC8jB8aniCyvlXvwPmw.png" /></figure><ul><li>The autoscaler could not evict the first node, as it was running the autoscaler, and we set a flag earlier for the autoscaler not to delete the node it is running on</li><li>It began a removal simulation for a different node and moved existing pods to a different node</li><li>It then removed the node</li><li>It removed another node after moving the pods</li></ul><p>After running “kubectl get node”, there is one node left. 2 nodes have been removed.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/988/1*ai55UAxyMPvvqhpodmiSmQ.png" /></figure><h4>Configuring Fargate</h4><p>Create a Fargate role for the “EKS Fargate pod” service</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*By6fvGWwFgJYS1UbJoOw4Q.png" /></figure><p>The role requires the “AmazonEKSFargatePodExecutionRolePolicy”</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*R0uMPsMH4N1hmrx3cmpQ5A.png" /></figure><h4>Configuring Fargate profile with eksctl</h4><p>Fargate manages pods on the EC2 instances that AWS creates and manages. The instances are not visible in the customer’s AWS account. This feature is best used for deploying stateless applications. Stateful applications are better deployed using a nodegroup.</p><h4>Create a Fargate profile with eksctl</h4><p>To create a Fargate profile, selectors must be chosen to distinguish which pods will be deployed with Fargate.</p><p>Examples of pod selectors include namespaces and labels.</p><p>In our application, the Java app will be deployed using Fargate. It will be deployed in the default namespace, but labels will be added to make them identifiable for Fargate deployment.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/486/1*UNhOsVi2Fl9AKi1UAmCW1w.png" /></figure><p>The eksctl command will look like below:</p><pre>eksctl create fargateprofile --cluster company-cluster --name company-fargate --namespace default --labels profile=fargate</pre><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*jRqpZiUxS7OKWNCtAi3spQ.png" /></figure><p>This command will also create the necessary roles and policies required for Fargate to function.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*NIXNyBBe-lWFKybXdnyYYw.png" /></figure><p>The Fargate profile is created with the appropriate selector</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*EGx2_xmyPNxYT2UGa0in0g.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*jLP7l_EsmVdx4CfCqUm--w.png" /></figure><p>After running “kubectl get node”</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*b_DzdPDYS_f9gP6RhEPBTA.png" /></figure><p>And “kubectl get pod”</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/814/1*YOfLu5-WOt82uSZKPpfSlg.png" /></figure><p>2 Fargate instances were provisioned, one for each pod.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=fc9bfeec51ad" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Deploying a Java application to a Kubernetes cluster using Linode Kubernetes Engine (LKE)]]></title>
            <link>https://medium.com/@micsamkid500/deploying-a-custom-java-application-to-a-kubernetes-cluster-using-linode-kubernetes-engine-lke-1655366c99a7?source=rss-5bd4345bedb5------2</link>
            <guid isPermaLink="false">https://medium.com/p/1655366c99a7</guid>
            <category><![CDATA[linode]]></category>
            <category><![CDATA[helm-chart]]></category>
            <category><![CDATA[kubernetes]]></category>
            <category><![CDATA[aws]]></category>
            <category><![CDATA[helm]]></category>
            <dc:creator><![CDATA[Samuel Michael]]></dc:creator>
            <pubDate>Sun, 23 Aug 2026 05:09:15 GMT</pubDate>
            <atom:updated>2026-08-23T05:21:44.381Z</atom:updated>
            <content:encoded><![CDATA[<h3>Introduction:</h3><p>This project deploys a custom Java application, phpMyAdmin, and MySQL containers to a Kubernetes cluster on Linode Kubernetes Engine (LKE). The application connects to the MySQL container using environment variables, creates a table, and adds test data to it.</p><h3><strong>Tools Used:</strong></h3><p>Kubernetes, Linode Kubernetes Engine, Helm, Helmfile, Helm chart, Docker, Amazon ECR</p><h3><strong>Things to do:</strong></h3><ul><li>Create a Kubernetes cluster on LKE and connect to it from the command line</li><li>Understand the application logic</li><li>Create secrets to pull the image from Amazon ECR, and for MYSQL (includes user password, root password, and replication password)</li><li>Create a ConfigMap to store non-sensitive data so other applications can reuse it</li><li>Create a custom values file for MYSQL, and deploy with Helm</li><li>Create deployment and service manifest files for PHPMyAdmin, and include the necessary environment variables</li><li>Create deployment and service manifest files for the Java app, include necessary environment variables and secrets to access the database.</li><li>Use Helm to deploy an ingress controller.</li><li>Use an ingress rule to set the hostname, path, and service that is exposed externally.</li><li>Create a chart using Helm. Create template files for deployment, service, configmap, and ingress.</li><li>Create a default values file</li><li>Create a custom values file for the application</li><li>Use Helm to deploy the application</li><li>Create a Helmfile and use it to deploy the application</li><li>Port-forward phpMyAdmin for internal access</li></ul><h4>Create a Kubernetes cluster on LKE.</h4><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*ks56gTlrV6moA6U2L38aOg.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*ZxFDYVaoPE2P_AL8cex53Q.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*YQI2SVDYOisnrgBKdFsxKg.png" /></figure><p>Provisioning</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*KngpqQQBhmDdFxx_qj68ZQ.png" /></figure><p>Download the LKE config file.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*RXBFz0sX62aZF8v13L_2cw.png" /></figure><p>Set the KUBECONFIG variable to the LKE config file to connect to the cluster.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*sS9syLPczkxfDdu1ghyhqQ.png" /></figure><h4>2. Understand the application logic</h4><p>Databaseconfig.java</p><pre>package com.example;<br><br>import com.mysql.cj.jdbc.MysqlDataSource;<br>import org.springframework.context.annotation.Bean;<br>import org.springframework.context.annotation.Configuration;<br><br>import java.sql.Connection;<br>import java.sql.SQLException;<br><br><br>@Configuration<br>public class DatabaseConfig {<br><br>    private String user = System.getenv(&quot;DB_USER&quot;);<br>    private String password = System.getenv(&quot;DB_PWD&quot;);<br>    private String serverName = System.getenv(&quot;DB_SERVER&quot;);<br>    private String dbName = System.getenv(&quot;DB_NAME&quot;);<br>    private MysqlDataSource datasource = new MysqlDataSource();<br><br>    public DatabaseConfig() {<br>        datasource.setPassword(password);<br>        datasource.setUser(user);<br>        datasource.setServerName(serverName);<br>        datasource.setDatabaseName(dbName);<br><br><br>    }<br><br>    @Bean<br>    public Connection getConnection() throws SQLException {<br>        return datasource.getConnection();<br>    }<br>}</pre><p>Strings “user”, “password”, “serverName”, and “dbName” are initialized and assigned to environment variables that will have already been set on the system. So the container running the application has to have those environment variables set.</p><pre>package com.example;<br><br>import org.slf4j.Logger;<br>import org.slf4j.LoggerFactory;<br>import org.springframework.boot.SpringApplication;<br>import org.springframework.boot.autoconfigure.SpringBootApplication;<br><br>import javax.annotation.PostConstruct;<br>import java.sql.Connection;<br>import java.sql.ResultSet;<br>import java.sql.SQLException;<br>import java.sql.Statement;<br><br>@SpringBootApplication<br>public class Application {<br><br>    private final Connection dbConnection;<br><br>    public Application(Connection dbConnection) {<br>        this.dbConnection = dbConnection;<br>    }<br><br>    public static void main(String[] args)<br>    {<br>        SpringApplication.run(Application.class, args);<br>    }<br><br>    @PostConstruct<br>    public void init()<br>    {<br>        Logger log = LoggerFactory.getLogger(Application.class);<br>        log.info(&quot;Java app started&quot;);<br><br>        try {<br>            Statement stmt = dbConnection.createStatement();<br>            createTable(stmt);<br>            generateData(stmt);<br>            stmt.close();<br>        } catch (SQLException e) {<br>            e.printStackTrace();<br>        }<br><br>    }<br><br>    private void createTable(Statement stmt) throws SQLException {<br>        String sqlStatement = &quot;CREATE TABLE IF NOT EXISTS team_members(&quot; +<br>                &quot;member_id INT AUTO_INCREMENT PRIMARY KEY,\n&quot; +<br>                &quot;member_name VARCHAR(255),\n&quot; +<br>                &quot;member_role VARCHAR(255),\n&quot; +<br>                &quot;member_projects VARCHAR(255)&quot; +<br>                &quot;)&quot;;<br>        stmt.executeUpdate(sqlStatement);<br>    }<br><br>    private void generateData(Statement stmt) throws SQLException {<br>        String sqlQuery = &quot;SELECT member_name, member_role FROM team_members&quot;;<br>        ResultSet resultSet = stmt.executeQuery(sqlQuery);<br>        if (!resultSet.next()) {<br>            String sqlStatement = &quot;INSERT INTO team_members(member_name, member_role)\n&quot; +<br>                    &quot;VALUES (&#39;Sarah&#39;, &#39;Full stack developer&#39;),\n&quot; +<br>                    &quot;(&#39;Bobby&#39;, &#39;React developer&#39;),\n&quot; +<br>                    &quot;(&#39;Ari&#39;, &#39;Java developer&#39;),\n&quot; +<br>                    &quot;(&#39;Andrea&#39;, &#39;DevOps engineer&#39;),\n&quot; +<br>                    &quot;(&#39;Bruno&#39;, &#39;IT operations&#39;)&quot;;<br>            stmt.executeUpdate(sqlStatement);<br>        }<br>    }<br><br>    public String getStatus() {<br>        return &quot;OK&quot;;<br>    }<br>}</pre><p>The createTable method creates a table named “team_members”, and generateData inserts data into it.</p><p>“gradle test” runs tests</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*MqKoD-Cvnn5IniV9MEWk5A.png" /></figure><p>“gradle build” builds the jar file</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*0X6JGdGw_4Kbq_Y__jd_Jw.png" /></figure><p>The JAR file is built in the build/libs folder.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/598/1*Bi7snEcSBEM_8xQF1cY7jw.png" /></figure><h4>Build the image and push it to Amazon ECR.</h4><ul><li>Run the aws ecr get-login command to log in to AWS from the terminal.</li></ul><pre>aws ecr get-login-password - region us-east-1 | docker login - username AWS - password-stdin [ACCOUNT_ID].dkr.ecr.us-east-1.amazonaws.com</pre><ul><li>Build the Docker image from the Dockerfile</li></ul><pre>docker build -t custom-app:1.0 .</pre><ul><li>Tag it with the AWS account ID, region, and repo name</li></ul><pre>docker tag custom-app:1.0 [ACCOUNT_ID].dkr.ecr.us-east-1.amazonaws.com/custom-app:1.0</pre><ul><li>Push the tagged Docker image to Amazon ECR</li></ul><pre>docker push [ACCOUNT_ID].dkr.ecr.us-east-1.amazonaws.com/custom-app:1.0</pre><p>3. <strong>Create secrets to pull the image from Amazon ECR and to access MYSQL</strong></p><ul><li>aws ecr get-login-password — region us-east-1 | docker login — username AWS — password-stdin [ACCOUNT_ID].dkr.ecr.us-east-1.amazonaws.com [saves creds to .configjson file]</li><li>kubectl create secret docker-registry ecr-secret — from-file=.dockerconfigjson=”%USERPROFILE%\.docker\config.json” [creates a secret of type docker-registry using the config.json file from the previous step]</li><li>kubectl create secret generic mysql-creds — from-literal=mysql-root-password=rootpassword — from-literal=mysql-password=mypassword — from-literal=mysql-replication-password=replicationpassword [creates a MYSQL root, user, and replication passwords]</li></ul><figure><img alt="" src="https://cdn-images-1.medium.com/max/795/1*e9xiUzdMSgDygoFJW9opHg.png" /></figure><h4>4. Create a ConfigMap to store non-sensitive data</h4><pre>apiVersion: v1<br>kind: ConfigMap<br>metadata:<br>  name: customapp-configmap<br>data:<br>  PMA_HOST: mysql-primary<br>  PMA_PORT: &quot;3306&quot;<br>  DB_USER: myuser<br>  DB_NAME: mydb<br>  DB_SERVER: mysql-primary</pre><p>The ConfigMap includes non-sensitive data required for both PHPMYadmin (PMA_HOST, PMA_PORT), and the java app (DB_USER, DB_NAME and DB_SERVER)</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/382/1*E8O3Cz--s3wEeqD6sh1Ikg.png" /></figure><h4>5. Create a custom values file for MySQL and deploy with Helm</h4><pre>architecture: replication<br>replicaCount: 2<br>auth:<br>  database: mydb<br>  username: myuser<br>  existingSecret: mysql-creds<br>persistence:<br>  storageClass: &quot;linode-block-storage&quot;<br>global:<br>  security:<br>    allowInsecureImages: True<br>image: <br>  registry: docker.io<br>  tag: latest<br>  repository: bitnamilegacy/mysql</pre><p>The above file is a basic overwritten-values file using the Bitnami Helm chart. It creates two replicas using the replication architecture, specifies the database, username, and uses existingSecret for the password. For persistence, it uses “linode-block-storage”. This provisions volumes for each replica on LKE.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*Bv8YtT5_Kt91WL_cnYUYBg.png" /><figcaption>Two Persistent volume claims are created in LKE</figcaption></figure><p><a href="https://github.com/bitnami/charts/tree/main/bitnami/mysql">charts/bitnami/mysql at main · bitnami/charts</a></p><p>Install with</p><pre>helm install mysql --values helm-mysql.yaml bitnami/mysql</pre><p>List releases installed using Helm with “helm ls” or “helm list”</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*z2wiu4YoSXzOpviQGBTGjw.png" /></figure><h4>6. Create deployment and service manifest files for phpMyAdmin</h4><pre>apiVersion: apps/v1<br>kind: Deployment<br>metadata:<br>  name: phpmyadmin<br>  labels:<br>    app: phpmyadmin<br>spec:<br>  replicas: 1<br>  selector:<br>    matchLabels:<br>      app: phpmyadmin<br>  template:<br>    metadata:<br>      labels:<br>        app: phpmyadmin<br>    spec:<br>      containers:<br>      - name: phpmyadmin<br>        image: phpmyadmin<br>        ports:<br>        - containerPort: 80<br>        env:<br>        - name: PMA_HOST<br>          valueFrom:<br>            configMapKeyRef:<br>              name: customapp-configmap<br>              key: PMA_HOST<br>        - name: PMA_PORT<br>          valueFrom:<br>            configMapKeyRef:<br>              name: customapp-configmap<br>              key: PMA_PORT<br><br>---<br><br>apiVersion: v1<br>kind: Service<br>metadata:<br>  name: phpmyadmin-service<br>spec:<br>  selector:<br>    app: phpmyadmin<br>  ports:<br>    - protocol: TCP<br>      port: 8081<br>      targetPort: 80<br><br></pre><p>The deployment file contains environment variables needed to connect to MySQL, such as PMA_HOST and PMA_PORT. Values are defined in the ConfigMap.</p><pre>env:<br>        - name: PMA_HOST<br>          valueFrom:<br>            configMapKeyRef:<br>              name: customapp-configmap<br>              key: PMA_HOST<br>        - name: PMA_PORT<br>          valueFrom:<br>            configMapKeyRef:<br>              name: customapp-configmap<br>              key: PMA_PORT</pre><p>Under the “env” field, there is a name “PMA_HOST”; the actual value is gotten from “key: PMA_HOST”, which, according to the ConfigMap above, is “mysql-primary”</p><h4>7. Create deployment and service manifest files for the Java app</h4><pre>apiVersion: apps/v1<br>kind: Deployment<br>metadata:<br>  name: custom-app<br>  labels:<br>    app: custom-app<br>spec: <br>  replicas: 2<br>  selector:<br>    matchLabels:<br>      app: custom-app<br>  template:<br>    metadata:<br>      labels:<br>        app: custom-app<br>    spec:<br>      imagePullSecrets:<br>      - name: ecr-secret<br>      containers:<br>      - name: custom-app<br>        image: 420392929933.dkr.ecr.us-east-1.amazonaws.com/my-app:1.0<br>        imagePullPolicy: Always<br>        ports:<br>        - containerPort: 8080<br>        env:<br>          - name: DB_USER<br>            valueFrom:<br>              configMapKeyRef:<br>                name: customapp-configmap<br>                key: DB_USER<br>          - name: DB_PWD<br>            valueFrom:<br>              secretKeyRef:<br>                name: mysql-creds<br>                key: mysql-password<br>          - name: DB_NAME<br>            valueFrom:<br>              configMapKeyRef: <br>                name: customapp-configmap<br>                key: DB_NAME<br>          - name: DB_SERVER<br>            valueFrom:<br>              configMapKeyRef:<br>                name: customapp-configmap<br>                key: DB_SERVER<br><br>---<br><br>apiVersion: v1<br>kind: Service<br>metadata:<br>  name: customapp-service<br>spec:<br>  selector:<br>    app: custom-app<br>  ports:<br>  - protocol: TCP<br>    port: 8080<br>    targetPort: 8080</pre><h4>8. Use Helm to deploy an ingress controller</h4><pre>helm install nginx-ingress oci://ghcr.io/nginx/charts/nginx-ingress --set controller.reportIngressStatus.enable=true</pre><p>Deploying an Nginx controller with “controller.reportIngressStatus.enable=True” populates the status of the Ingress controller with the IP address of the LKE load balancer (NodeBalancer)</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*DmbRIuT6XuAwHHGOMJ99rw.png" /></figure><p>Using “kubectl get pod”</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/961/1*TyHFqfNXF7MjvTv70qUoVA.png" /></figure><p>Using “kubectl get svc”</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*X9yiBUi5tqtb9ucvS2vaPg.png" /></figure><h4>9. Use an ingress rule to set the hostname, path, and service that is exposed externally</h4><pre>apiVersion: networking.k8s.io/v1<br>kind: Ingress<br>metadata:<br>    name: customapp-ingress<br>spec:<br>  ingressClassName: nginx<br>  rules:<br>  - host: 172-238-162-7.ip.linodeusercontent.com<br>    http:<br>      paths:<br>        - path: /<br>          pathType: Prefix<br>          backend:<br>            service:<br>              name: customapp-service<br>              port:<br>                number: 8080</pre><h4>10. Create a Helm chart and template files for deployment, service, configmap, and ingress</h4><pre>helm create customapp</pre><p>Delete unneeded template files and create what is needed.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/423/1*GgxvAFBx-JtgCSHEPk-wdg.png" /></figure><h4>Deployment template file</h4><pre>apiVersion: apps/v1<br>kind: Deployment<br>metadata:<br>  name: {{ .Values.appName }}<br>  labels:<br>    app: {{ .Values.appName }}<br>spec:<br>  replicas: {{ .Values.appReplicas }}<br>  selector:<br>    matchLabels:<br>      app: {{ .Values.appName }}<br>  template:<br>    metadata:<br>      labels:<br>        app: {{ .Values.appName }}<br>    spec:<br>      imagePullSecrets:<br>      - name: {{ .Values.secretKey }}<br>      containers:<br>      - name: {{ .Values.appName }}<br>        image: &quot;{{ .Values.appImage }}:{{ .Values.appVersion }}&quot;<br>        imagePullPolicy: Always<br>        ports: <br>        - containerPort: {{ .Values.containerPort }}<br>        env:<br>        {{- range .Values.containerEnvVars }}<br>        - name: {{ .name }}<br>          valueFrom:<br>            {{ .keyRef }}:<br>                name: {{ .valueFrom.name }}<br>                key: {{ .valueFrom.key }}<br>        {{- end }}        <br><br></pre><p>In the env field, “{{- range .Values.containerEnvVars }}” allows you to specify any number of environment variables, and {{ .keyRef }} lets you specify what keys to use, whether secretKeyRef or configmapKeyRef</p><h4>Service template file</h4><pre>apiVersion: v1<br>kind: Service<br>metadata:<br>  name: {{ .Values.serviceName }}<br>spec:<br>  type: {{ .Values.serviceType }}<br>  selector:<br>    app: {{ .Values.appName }}<br>  ports:<br>  - protocol: TCP<br>    port: {{ .Values.servicePort }}<br>    targetPort: {{ .Values.containerPort }}</pre><h4>ConfigMap template file</h4><pre>apiVersion: v1<br>kind: ConfigMap<br>metadata:<br>  name: {{ .Values.configmapName }}<br>data:<br>{{- range $key, $value := .Values.data }}<br>    {{ $key }}: {{ $value | quote }}<br>{{- end}}</pre><p>In the ConfigMap template file, you can specify the names of the keys and values. For example, PMA_HOST=[MYSQL_HOST] or DB_USER=[USERNAME]</p><h4>Ingress template file</h4><pre>apiVersion: networking.k8s.io/v1<br>kind: Ingress<br>metadata:<br>    name: {{ .Values.ingressName }}<br>spec:<br>  ingressClassName: {{ .Values.ingressclassName }}<br>  rules:<br>  - host: {{ .Values.hostName }}<br>    http:<br>      paths:<br>        - path: {{ .Values.pathName }}<br>          pathType: {{ .Values.pathType }}<br>          backend:<br>            service:<br>              name: {{ .Values.serviceName }}<br>              port:<br>                number: {{ .Values.servicePort }}</pre><h4>11. Create a default values file</h4><pre>appName: appname<br>appReplicas: 1<br>secretKey: ecr-secret<br>appImage: 12345678.dkr.ecr.12345.amazonaws.com/1234<br>appVersion: 0.0<br>containerPort: 8080<br>containerEnvVars:<br>  - name: VAR_1<br>    keyRef: configMapKeyRef<br>    valueFrom:<br>      name: configmap-name1<br>      key: configmap-key1<br>  - name: VAR_2<br>    keyRef: configMapKeyRef<br>    valueFrom:<br>      name: configmap-name2<br>      key: configmap-key2<br>  - name: VAR_3<br>    keyRef: configMapKeyRef<br>    valueFrom:<br>      name: configmap-name3<br>      key: configmap-key3<br>  - name: VAR_4<br>    keyRef: secretKeyRef<br>    valueFrom:<br>      name: secret-name1<br>      key: secret-key1<br><br><br>serviceName: servicename<br>servicePort: 8080<br>serviceType: ClusterIP<br><br><br>ingressName: ingressname<br>ingressclassName: ingressclassName<br>hostName: custom-app.com<br>pathName: /<br>pathType: Prefix<br><br><br>configmapName: configmapname<br>data:<br>  DB_USER: dbuser<br>  DB_NAME: dbname<br>  DB_SERVER: dbserver<br><br><br><br><br><br><br><br></pre><p>The default values are used if a custom values file isn’t specified when running Helm install. You can also override specific values using the “—— set” keyword.</p><h4>12. Create a custom values file to override the default values file</h4><pre>appName: custom-app<br>appReplicas: 2<br>secretKey: ecr-secret<br>appImage: 420392929933.dkr.ecr.us-east-1.amazonaws.com/my-app<br>appVersion: &quot;1.0&quot;<br>containerPort: 8080<br>containerEnvVars:<br>  - name: DB_USER<br>    keyRef: configMapKeyRef<br>    valueFrom:<br>      name: customapp-configmap<br>      key: DB_USER<br>  - name: DB_NAME<br>    keyRef: configMapKeyRef<br>    valueFrom:<br>      name: customapp-configmap<br>      key: DB_NAME<br>  - name: DB_SERVER<br>    keyRef: configMapKeyRef<br>    valueFrom:<br>      name: customapp-configmap<br>      key: DB_SERVER<br>  - name: DB_PWD<br>    keyRef: secretKeyRef<br>    valueFrom:<br>      name: mysql-creds<br>      key: mysql-password<br><br>serviceName: customapp-service<br>servicePort: 8080<br>serviceType: ClusterIP<br><br>ingressName: customapp-ingress<br>ingressclassName: nginx<br>hostName: 172-238-162-7.ip.linodeusercontent.com<br><br>configmapName: customapp-configmap<br>data:<br>  DB_USER: myuser<br>  DB_NAME: mydb<br>  DB_SERVER: mysql-primary</pre><h4>Validate the chart with “helm template”, “helm lint”, and “helm — dry-run”</h4><p>Use “helm lint” to check if your Helm chart is valid and follows Helm’s expected stucture/conventions.</p><pre>helm lint -f customapp-values.yaml customapp</pre><p>In the above command, “customapp-values.yaml” is the name of the custom values file, and “customapp” is the name of the chart folder, containing the template files.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/438/1*0SbrYEtF4nmm3raAZHkRPw.png" /></figure><p>Use “helm template” to render your Helm template into Kubernetes YAML</p><pre>helm template -f customapp-values.yaml customapp</pre><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*Sga081pBfyjOj5uXZjx1AA.png" /></figure><p>Use “helm install -dry-run” to validate the install against the Kubernetes cluster, and to check for any problems involving the cluster before installing</p><pre>helm install -f values\customapp-values.yaml customapp charts\customapp --dry-run=server --debug</pre><figure><img alt="" src="https://cdn-images-1.medium.com/max/564/1*3yUIuCbHQNa6sqcr987VIg.png" /></figure><h4>13. Use Helm to deploy the chart</h4><pre>helm install -f values\customapp-values.yaml customapp-helm charts\customapp</pre><p>In the above command, “customapp-values.yaml” is the name of the custom values file, “customapp-helm” is the release name, and “charts\customapp” is the folder containing the template files.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/460/1*dqOdWjJ7E-sDiJp_01s4FQ.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*X7dgLk8S5EvxoSJZd-JG_g.png" /></figure><h4>14. Create a Helmfile and use it to deploy the application</h4><p>A Helmfile is used to manage multiple Helm releases simultaneously, rather than installing or uninstalling them with a bunch of commands. A release file for the Java app will look like the following:</p><pre>releases:<br>  - name: customapp-helm<br>    chart: charts/customapp<br>    values:<br>       - values/customapp-values.yaml</pre><p>And can be installed using:</p><pre>helmfile sync</pre><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*snRvDWlsXgdicBW96bzb9w.png" /></figure><p>List releases installed with Helmfile with ”helmfile list”, and uninstall the releases with Helmfile using “helmfile destroy”</p><pre>helmfile list</pre><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*6ELLrFwAk9-aOqV56fyZ2Q.png" /></figure><h4>15. Port-forward phpMyAdmin for internal access</h4><p>The phpMyAdmin service is of type ClusterIP. This means it is not accessible externally, and it shouldn’t be, since phpMyAdmin manages the MYSQL database. We can, however, port-forward the phpMyAdmin service to our local machine so that we can access it internally.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/907/1*uhdTTqS4aRtmqO2jRsYfMg.png" /></figure><pre>kubectl port-forward service/phpmyadmin-service 8081:8081</pre><figure><img alt="" src="https://cdn-images-1.medium.com/max/334/1*I_Y2s91mqg1ePnG4RO29lA.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*yyI358hpIVTMvGure32-2g.png" /></figure><p>As shown in the picture above, the phpMyAdmin webpage is accessible on localhost, and the Java application created a table named “team_members”, added columns, and inserted values into it.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=1655366c99a7" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Creating and testing a CI/CD DevSecOps pipeline with Docker, Trivy, SonarQube, and Jenkins]]></title>
            <link>https://medium.com/@micsamkid500/creating-and-testing-a-ci-cd-devsecops-pipeline-with-docker-trivy-sonarqube-and-jenkins-34ade9b05b09?source=rss-5bd4345bedb5------2</link>
            <guid isPermaLink="false">https://medium.com/p/34ade9b05b09</guid>
            <category><![CDATA[sonarqube]]></category>
            <category><![CDATA[jenkins]]></category>
            <category><![CDATA[devsecops]]></category>
            <category><![CDATA[trivy]]></category>
            <category><![CDATA[cybersecurity]]></category>
            <dc:creator><![CDATA[Samuel Michael]]></dc:creator>
            <pubDate>Mon, 27 Jul 2026 18:49:57 GMT</pubDate>
            <atom:updated>2026-08-23T05:31:36.084Z</atom:updated>
            <content:encoded><![CDATA[<p><strong>Introduction</strong></p><p>The application consists of three containers: MongoDB and MongoExpress images from Docker Hub, and a custom application image written in Python and built with a Dockerfile. All containers are deployed using Docker Compose. Jenkins pulls the source code from the GitHub repository, scans the code and images for security issues using SonarQube and Trivy, and pushes to the master branch when the security scans pass. The secure image is then pushed to a private Docker repository and deployed to an EC2 instance.</p><p>This project demonstrates the use of Git, Jenkins, Docker, SonarQube, Trivy, AWS EC2, and ways to address vulnerabilities in a DevSecOps pipeline.</p><p><strong>Application Overview</strong></p><p>The application uses Docker Compose to build a MongoDB image and a MongoExpress image to access the database. It also uses a custom Python application to create a database, create a collection, and insert values into the collection.</p><p><strong>MongoDB Image</strong></p><p><a href="https://hub.docker.com/_/mongo">mongo - Official Image | Docker Hub</a></p><p>The image requires environmental variables specifying the admin username and password. Adding a volume is recommended so the data is saved if the container goes down.</p><pre>  mongodb:<br>    image: mongo<br>    restart: always<br>    environment:<br>      MONGO_INITDB_ROOT_USERNAME: ${MONGO_INITDB_ROOT_USERNAME}<br>      MONGO_INITDB_ROOT_PASSWORD: ${MONGO_INITDB_ROOT_PASSWORD}<br>    volumes:<br>      - mongo_data:/data/db</pre><p><strong>MongoExpress Image</strong></p><p><a href="https://hub.docker.com/_/mongo-express">mongo-express - Official Image | Docker Hub</a></p><p>The image needs environment variables for the MongoExpress username and password, and a variable that specifies how to access the MongoDB database.</p><pre>  mongo-express:<br>    image: mongo-express<br>    restart: always<br>    ports:<br>      - 8081:8081<br>    environment:<br>      ME_CONFIG_BASICAUTH_ENABLED: true<br>      ME_CONFIG_BASICAUTH_USERNAME: ${MONGO_INITDB_ROOT_USERNAME}<br>      ME_CONFIG_BASICAUTH_PASSWORD: ${MONGO_INITDB_ROOT_PASSWORD}<br>      ME_CONFIG_MONGODB_URL: mongodb://${MONGO_INITDB_ROOT_USERNAME}:${MONGO_INITDB_ROOT_PASSWORD}@${MONGODB_HOST}:${MONGODB_PORT}/</pre><p><strong>Custom Python Application</strong></p><p>The application connects to MongoDB using the “uri” variable, creates a database and a collection, and inserts values into the collection.</p><pre>from pymongo import MongoClient<br>from dotenv import load_dotenv<br>import os<br><br>load_dotenv()<br><br># Connecting to the MongoDB server<br><br>uri = (<br>    f&quot;mongodb://{os.getenv(&#39;MONGODB_USER&#39;)}:&quot;<br>    f&quot;{os.getenv(&#39;MONGODB_PASS&#39;)}@{os.getenv(&#39;MONGODB_HOST&#39;)}:{os.getenv(&#39;MONGODB_PORT&#39;)}/&quot;<br><br>)<br><br><br>client = MongoClient(uri)<br><br># Create database<br>db = client[&quot;test_database&quot;]<br><br># Create a collection<br>collection = db[&quot;users&quot;]<br><br># Add values<br>user_data = {&quot;name&quot;: &quot;Default_User&quot;, &quot;age&quot;: 25, &quot;skills&quot;: [&quot;Python&quot;, &quot;MongoDB&quot;]}<br><br>result = collection.insert_one(user_data)</pre><p>Final Docker Compose file</p><pre>services:<br><br>  custom-app:<br>    image: ${IMAGE_NAME}<br>    restart: always<br>    depends_on:<br>      - mongodb<br>    environment:<br>      MONGODB_USER: ${MONGO_INITDB_ROOT_USERNAME}<br>      MONGODB_PASS: ${MONGO_INITDB_ROOT_PASSWORD}<br>      MONGODB_HOST: ${MONGODB_HOST}<br>      MONGODB_PORT: ${MONGODB_PORT}<br><br><br>  mongodb:<br>    image: mongo<br>    restart: always<br>    environment:<br>      MONGO_INITDB_ROOT_USERNAME: ${MONGO_INITDB_ROOT_USERNAME}<br>      MONGO_INITDB_ROOT_PASSWORD: ${MONGO_INITDB_ROOT_PASSWORD}<br>    volumes:<br>      - mongo_data:/data/db<br><br><br>  mongo-express:<br>    image: mongo-express<br>    restart: always<br>    ports:<br>      - 8081:8081<br>    environment:<br>      ME_CONFIG_BASICAUTH_ENABLED: true<br>      ME_CONFIG_BASICAUTH_USERNAME: ${MONGO_INITDB_ROOT_USERNAME}<br>      ME_CONFIG_BASICAUTH_PASSWORD: ${MONGO_INITDB_ROOT_PASSWORD}<br>      ME_CONFIG_MONGODB_URL: mongodb://${MONGO_INITDB_ROOT_USERNAME}:${MONGO_INITDB_ROOT_PASSWORD}@${MONGODB_HOST}:${MONGODB_PORT}/<br><br><br>volumes:<br>  mongo_data:<br></pre><h3><strong>Git Operations</strong></h3><p>In the project directory, initialize a new Git repo, set the remote URL, create a new branch, stage the files, commit the changes locally, and push to the remote repository.</p><pre>git init [initializes git repo in current directory]<br>git remote set-url origin https://github.com/Michael2921/Basic_DevSecOps.git [sets the remote url for the git repository]<br>git switch -c dev [creates a new branch and switches to it]<br>git add . [stages files in current directory]<br>git commit -m &quot;initial commit&quot; [commits files to local repo]<br>git push -u origin dev [pushes commited changes to local repo. needs to be logged in]</pre><h3>Setting up Jenkins</h3><ul><li>Create a multibranch pipeline in Jenkins.</li></ul><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*cegpHRr2Or79OMneAu-JGA.png" /></figure><ul><li>Add credentials for GitHub and MongoDB.</li></ul><figure><img alt="" src="https://cdn-images-1.medium.com/max/813/1*FG_of9ix_twRmOgUKhUPHQ.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/820/1*snUT2LxhRuQIBJixwchZOg.png" /></figure><ul><li>Add repository as a branch source in Jenkins.</li></ul><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*7ERTlCBmP5oQ5BGxsr5OQw.png" /></figure><p><strong>Create a starter Jenkinsfile.</strong></p><pre>pipeline {<br><br>    agent any<br><br>    environment {<br>        MONGODB_HOST = &quot;mongodb&quot;<br>        MONGODB_PORT = &quot;27017&quot;<br><br><br>    }<br><br>    stages {<br><br>        stage(&#39;Building image from Dockerfile.&#39;) {<br><br>            steps {<br>                sh &#39;docker build -t basic_devsecops:v1 .&#39;<br><br><br><br>            }<br>        }<br><br><br><br>        stage(&#39;Running docker compose.&#39;) {<br><br>           steps{<br><br>            script {<br>                withCredentials([<br>                    usernamePassword(<br>                    credentialsId: &#39;mongodb-creds&#39;,<br>                    usernameVariable: &#39;MONGO_INITDB_ROOT_USERNAME&#39;,<br>                    passwordVariable: &#39;MONGO_INITDB_ROOT_PASSWORD&#39;)<br>               ])<br>               {<br>                    sh &#39;docker compose down -v&#39;<br>                    sh &#39;docker compose -f docker-compose.yaml up&#39;<br><br>                }<br><br><br>                }<br><br><br>                }<br><br><br>                }<br>                }<br><br>        }</pre><h3><strong>Install Trivy in the Jenkins container.</strong></h3><pre><br>docker exec -ut -u 0 [CONTAINER_ID] /bin/bash # enters container as root<br><br>apt-get update &amp;&amp; sudo apt-get install wget gnupg lsb-release -y<br><br>apt-get update &amp;&amp; apt-get install -y wget # installs wget<br><br>wget -qO - https://aquasecurity.github.io/trivy-repo/deb/public.key | gpg --dearmor | tee /usr/share/keyrings/trivy.gpg &gt; /dev/null<br><br>echo &quot;deb [signed-by=/usr/share/keyrings/trivy.gpg] https://aquasecurity.github.io/trivy-repo/deb generic main&quot; | tee /etc/apt/sources.list.d/trivy.list<br><br>apt-get update<br><br>apt-get install trivy -y</pre><h3><strong>Install SonarQube as a Docker container.</strong></h3><pre>docker run -d --name sonarqube-jenkins -v sona_data:/opt/sonarqube/data -v sona_logs:/opt/sonarqube/logs -v sona_extensions:/opt/sonarqube/extensions -p 9000:9000 sonarqube:community</pre><p>The default password is admin: admin. You will be prompted to change the password on initial login. According to the official image on DockerHub, it is advised to create volumes with the directories /opt/sonarqube/data for data files, /opt/sonarqube/logs for logs, and /opt/sonarqube/extensions for 3rd-party plugins.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*6EHam22Iv-OdQC3-FdVxfA.png" /></figure><p><a href="https://docs.sonarsource.com/sonarqube-server/10.8/analyzing-source-code/ci-integration/jenkins-integration/global-setup">Setting up Jenkins | SonarQube Server 10.8 | Sonar Documentation</a></p><p>The above link describes how to integrate SonarQube into Jenkins.</p><p>Install the SonarQube scanner plugin on Jenkins (<strong>Manage Jenkins</strong> &gt; <strong>Manage Plugins</strong>)</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*5coOEICxNMFY5ski6YPtgg.png" /></figure><p>Create a SonarQube token in SonarQube (My account &gt; Security &gt; Generate token)</p><p>Add the token as a secret text in Jenkins credentials (Manage Jenkins -&gt; Credentials -&gt; Add credentials -&gt; Secret text)</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/810/1*zWgZb_BJNQYLWu12ku1Xaw.png" /></figure><p>Add the SonarQube server address and credentials to the system configuration in Jenkins (Manage Jenkins &gt; Configure System &gt; SonarQube Servers)</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*1cUko0hFKYBh73D-vv7NHg.png" /></figure><p>Install the SonarScanner instance (Manage Jenkins &gt; Tools &gt; Add SonarQube scanner)</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*9az95yv65g5svkCoBuFz4A.png" /></figure><p>Configure the SonarQube webhook for the quality gate and to notify Jenkins of the task status. The webhook path should be configured in SonarQube, and the path must be /sonarqube-webhook/ (Administration &gt; Configuration &gt; Webhooks)</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*VrPYSGM4vk3_PZnNfSGX4Q.png" /></figure><p><strong>SonarQube Quality Gates</strong></p><p>A quality gate in SonarQube is a pass/fail checkpoint that evaluates code against a set of conditions. This feature is helpful to stop a build from completing if it fails security checks.</p><p>SonarQube conducts quality gate checks on the default security gate using several criteria, including “New code” and “Overall code.” New code refers to lines recently added, modified, or touched, while overall code represents all source code, both legacy and newly merged.</p><p>To test the SonarQube scan, I created a custom quality gate and set conditions to fail the build if more than zero issues are detected in Overall Code.</p><p><strong>Default SonarQube quality gate</strong></p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*lCASB_XoihYI8a-UXjER3g.png" /></figure><p><strong>Created SonarQube quality gate</strong></p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*1pcDiGZ-QzeeZnbPcMIG-Q.png" /></figure><h3><strong>Fixing security issues to pass SonarQube quality gate</strong></h3><p>My build failed after assigning the project to my new quality gate.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*6OMCP6Y4SpHG2Idgi-PoUQ.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*ecpMukmfeBkavjc5DEerkw.png" /></figure><p>Looking in SonarQube, the issue responsible for the failure can be found.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*GeGXTasOJaLGjgBdmdrfIw.png" /></figure><p>It says my Python custom image runs with “root” as the default user, which is true, according to the Dockerfile above.</p><pre>FROM python:3.15.0b4-slim<br><br>WORKDIR /home/app<br><br>COPY requirements.txt .<br><br>RUN pip install -r requirements.txt<br><br>COPY app.py .<br><br>CMD [&quot;python&quot;, &quot;app.py&quot;]</pre><p>The updated Dockerfile looks like this. A new user was created with useradd, and the ownership of files in /home/app was set to the new user.</p><pre>FROM python:3.15.0b4-slim<br><br>WORKDIR /home/app<br><br>COPY requirements.txt .<br><br>RUN pip install -r requirements.txt<br><br>COPY app.py .<br><br>RUN useradd --create-home appuser &amp;&amp; chown -R appuser:appuser /home/app<br><br>USER appuser<br><br>CMD [&quot;python&quot;, &quot;app.py&quot;]<br><br></pre><p>To push modified changes to the remote repository, run:</p><pre>git add . [stages files in the current directory]<br>git commit -m &quot;commit message&quot; [commits modified files locally]<br>git push [pushes files to remote repository]</pre><p>After making the changes and running the build again, the SonarQube scan passes the quality gate</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*X6h2Tc4qKQjrz115Tqjx5w.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*TOUSpFRjDbfUZsTEZt2NKQ.png" /></figure><p>Looking in SonarQube, the quality gate passed with no issues.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*WvcnLfhNbB0kPQjJiqRmGw.png" /></figure><h3><strong>Remediating Vulnerabilities Found With Trivy</strong></h3><p>In my Jenkins pipeline, the SonarQube scan runs before the Trivy scan. SonarQube scans perform static code analysis, while Trivy scans containers for vulnerabilities.</p><p>I resolved the SonarQube issue by creating and using a non-root user. For Trivy, the Jenkinsfile is configured to fail the build if any high or critical vulnerabilities are detected.</p><pre>stage(&#39;Trivy scan&#39;) {<br><br>            steps {<br><br>               sh &#39;trivy image --severity HIGH,CRITICAL --exit-code 1 basic_devsecops:v1&#39;<br><br><br>            }<br><br>        }</pre><p>The initial Trivy scans show multiple critical and high vulnerabilities.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/441/1*orTyuWOA8YZ9Bh8AWRsbdA.png" /></figure><p>Two effective methods for addressing these vulnerabilities are hardened base images and multi-stage builds.</p><p>Hardened base images include only essential packages, are regularly updated with security patches, typically run as non-root by default, and are designed to minimize CVE exposure.</p><p>Multi-stage builds ensure that only runtime requirements are included in the final production image, thereby reducing the attack surface.</p><p>Because this is a Python application, secure Python base images were selected.</p><ul><li><a href="https://hub.docker.com/hardened-images/catalog/dhi/python/">Hardened Images catalog | Python | Docker Hub</a></li><li><a href="https://images.chainguard.dev/directory/image/python/versions#/">python Container Image Versions | Chainguard</a></li></ul><p>The Python image from dhi.io was used to build and install dependencies, while the cgr.dev image served as the runtime environment. SonarQube flagged an issue because it assumes the default user is root, whereas cgr.dev images use a non-root user. To address this, I updated the SonarQube quality gate, increasing the failure threshold for Overall Code issues from 1 to 2.</p><p>Updated Dockerfile:</p><pre>FROM dhi.io/python:3-dev AS builder<br><br>WORKDIR /home/app<br><br>COPY requirements.txt .<br><br>RUN pip install --target=/home/app/dependencies -r requirements.txt<br><br>FROM cgr.dev/chainguard/python:latest-dev<br><br>WORKDIR /home/app<br><br>COPY --from=builder /home/app/dependencies /home/app/dependencies<br><br>COPY app.py .<br><br>ENV PYTHONPATH=/home/app/dependencies<br><br>CMD [&quot;app.py&quot;]<br></pre><p>After running Trivy with hardened base images and using a multibranch build, the Trivy scan results show 0 critical and high vulnerabilities.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*JveH6aQ2qn_KZJYKP2_zbw.png" /></figure><h3><strong>Pushing Secure Code to the Master Branch</strong></h3><p>When the code successfully passes SonarQube and Trivy scans, it needs to be pushed to the master branch so it can be deployed. Here is the stage doing that.</p><pre>stage(&#39;Push secure code to master branch&#39;){<br>        when {branch &#39;dev&#39;} //master doesn&#39;t push to itself avoiding a build loop. work isn&#39;t done from master. only pushed from dev when security scans pass<br><br>            steps {<br>                   script {<br>                        sh &#39;echo &quot;Pushing code to master branch&quot;&#39;<br>                        sh &#39;git config user.email &quot;jenkins@example.com&quot;&#39;<br>                        sh &#39;git config user.name &quot;jenkins&quot;&#39;<br>                        sh &#39;git status&#39;<br>                        sh &#39;git branch&#39;<br>                        sh &#39;git remote set-url origin https://${GITHUB_TOKEN}@github.com/Michael2921/Basic_DevSecOps.git&#39;<br>                        sh &#39;git add .&#39;<br>                        sh &#39;git commit -m &quot;pushing secure code to master branch&quot;&#39;<br>                        sh &#39;git push -f origin HEAD:master&#39;<br><br><br>                   }<br><br>            }<br><br>}</pre><p>In the code block above, ‘git remote set-url origin’ requires a GitHub login. Since this is done non-interactively, a GitHub PAT (personal access token) needs to be generated in GitHub and added as a credential in Jenkins. “GITHUB_TOKEN” was already imported as an environment variable earlier.</p><pre>enivronment {<br>GITHUB_TOKEN = credentials(&#39;github-token&#39;)<br>}</pre><p><strong>Generate a Personal Access Token in GitHub</strong></p><ul><li>Go to profile in the top right corner ( Settings &gt; Credentials &gt; Personal Access Tokens (classic) and generate a token)</li><li>Select the appropriate scope, and generate the token.</li></ul><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*ymvcuTTw8xW-TbXGVlxmoA.png" /></figure><ul><li>Add Personal Access Token to Jenkins</li></ul><figure><img alt="" src="https://cdn-images-1.medium.com/max/805/1*1ZbE18afv9PwJyF9K3GQwQ.png" /></figure><h3><strong>Deploy Image to Private Docker Repository.</strong></h3><p>Before deploying the image to the AWS instance, it is advisable to push it to a repository. I selected a private DockerHub repository to reflect a typical production environment. The following stage outlines this process.</p><pre><br>        stage(&#39;Docker login and push to repo&#39;) {<br>        when {branch &#39;master&#39;}<br><br>            steps {<br>                script {<br>                    echo &quot;Logging in to docker&quot;<br>                    withCredentials([usernamePassword(credentialsId: &#39;docker-hub-repo&#39;, usernameVariable: &#39;USER&#39;, passwordVariable: &#39;PASS&#39;)]) {<br>                        sh &#39;echo $PASS | docker login -u $USER --password-stdin&#39;<br>                        sh &#39;echo &quot;Pushing secure image to repo&quot;&#39;<br>                        sh &quot;docker push ${IMAGE_NAME}&quot;<br><br>                  }<br><br>                }<br><br>            }<br><br>        }</pre><p>Using withCredentials, I was able to log in with the credentials I created in Jenkins for Docker. The image has to be named in a specific way, using the account name + repo name; this naming method implicitly specifies where the image is pushed when “docker push” is executed. IMAGE_NAME was already set as an environment variable earlier.</p><pre> IMAGE_NAME = &quot;michael101/basic-devsecops:v1&quot;</pre><figure><img alt="" src="https://cdn-images-1.medium.com/max/946/1*eKoSqNlo6Ha5QIofzF4ZRg.png" /></figure><h3><strong>Deploy image to EC2</strong></h3><p>In this stage, I will do a few things:</p><ul><li>Transfer necessary application files to the EC2 instance.</li><li>Configure the required environment variables on the EC2 instance.</li><li>Provide credentials for SSH access to the EC2 instance and for pulling the Docker image from the private repository.</li></ul><p>Here is the Jenkinsfile:</p><pre>stage(&#39;Deploying image to EC2&#39;){<br>        when {branch &#39;master&#39;} //deploys from master<br><br>            steps {<br>                script {<br>                    withCredentials([<br>                        usernamePassword(<br>                    credentialsId: &#39;mongodb-creds&#39;,<br>                    usernameVariable: &#39;MONGO_INITDB_ROOT_USERNAME&#39;,<br>                    passwordVariable: &#39;MONGO_INITDB_ROOT_PASSWORD&#39;),<br>                        usernamePassword(<br>                        credentialsId: &#39;docker-hub-repo&#39;,<br>                        usernameVariable: &#39;USER&#39;,<br>                        passwordVariable: &#39;PASS&#39;),<br><br>                        string(credentialsId: &#39;ec2-instance&#39;, variable: &#39;EC2_INSTANCE&#39;)<br><br>                    ]) {<br><br>                    def shellCmd = &quot;bash ./commands.sh ${IMAGE_NAME}&quot;<br>                    def ec2Instance = &quot;ec2-user@${EC2_INSTANCE}&quot;<br><br>                    sshagent([&#39;basic-devsecops-ssh&#39;]) { //variables are set in the ssh command and a shell script containing commands to login to docker repo and deploy is executed<br>                        sh &quot;scp -o StrictHostKeyChecking=no commands.sh docker-compose.yaml ${ec2Instance}:/home/ec2-user&quot;<br>                        sh &quot;ssh -o StrictHostKeyChecking=no ${ec2Instance} MONGO_INITDB_ROOT_USERNAME=&#39;${MONGO_INITDB_ROOT_USERNAME}&#39; MONGO_INITDB_ROOT_PASSWORD=&#39;${MONGO_INITDB_ROOT_PASSWORD}&#39; \<br>                        MONGODB_HOST=&#39;${MONGODB_HOST}&#39; MONGODB_PORT=&#39;${MONGODB_PORT}&#39; USER=${USER} PASS=${PASS} \<br>                        ${shellCmd}&quot;<br><br>                    }<br><br>                    }<br>                }<br><br>            }<br>        }</pre><p>The withCredentials block defines two credentials: one for DockerHub and another for SSH access to the EC2 instance.</p><p>Two variables are defined in this stage. One sets the username and IP address, while the other lists the commands to execute after establishing an SSH connection to the server.</p><pre>def ec2Instance = &quot;ec2-user@[ec2-Instance]&quot;<br>def ShellCmd = &quot;bash ./commands.sh&quot;</pre><p>Since the SSH session is non-interactive and quits after a single SSH command, I included the environment variables required to run the Docker Compose file and ran the commands.sh file (a shell script containing a list of commands) in the SSH session.</p><p>Here are the contents of commands.sh</p><pre>#!/usr/bin/env bash<br><br>export IMAGE_NAME=$1<br>echo $PASS | docker login -u $USER --password-stdin<br>docker-compose down -v<br>docker-compose -f docker-compose.yaml up -d<br>echo &quot;success&quot;</pre><p>The commands.sh file</p><ul><li>Assigns the first parameter while executing the command as the image name</li><li>Runs the “docker login” command</li><li>Runs the docker compose down command, while removing the volumes</li><li>Runs the docker compose command to pull and run the images.</li><li>Outputs a “success” message</li></ul><h3><strong>Implement a CI/CD pipeline using GitHub Webhooks.</strong></h3><p>In Jenkins: Go to multibranch pipeline &gt; Scan Repository Triggers.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*7FP1OOJwsfJ3ygejkmG9ug.png" /></figure><p>In GitHub: Go to project &gt; settings and create the payload URL</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*kudnq_rpuIKGpDGkKYRBMQ.png" /></figure><p><strong>CI/CD overall logic</strong></p><p><strong>Dev branch</strong></p><ul><li>Application is built from the dev branch</li><li>SonarQube scans code from the dev branch</li><li>If successful, an image is built from the scanned code</li><li>The built image is then scanned with Trivy</li><li>If the Trivy scan is successful, secure code is pushed to master.</li></ul><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*j-yrFjqsX9SEFBuuFyDbpA.png" /></figure><p><strong>Master branch</strong></p><ul><li>The application is not built directly from the master branch. Only code that has passed both SonarQube and Trivy scans is merged into master.</li><li>After secure code is pushed to master, the secure image is pushed to a private Docker repository.</li><li>Then the secure image is pulled from the private Docker repository and deployed to an EC2 instance.</li></ul><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*EfMEoD9Zs9lMx5gr-Y_j6w.png" /></figure><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=34ade9b05b09" width="1" height="1" alt="">]]></content:encoded>
        </item>
    </channel>
</rss>