This post will show you how to utilize Ansible to manage your servers in Google Cloud Platform.

In this post we will:

  • Install Ansible
  • Configure access to GCP
  • Create three instances
  • Create a dynamic inventory
  • Run a playbook against those instances
  • Remove everything we created

Let’s go through these one by one.

Install Ansible

First you need to install Ansible on what is called the control machine. When you get started with Ansible this might be your laptop, or a server you share with your team. But as you progress it is recommended to use Ansible Automation Platform as a centralised place to run and manage all your automation.

To install Ansible on RHEL:

sudo dnf install ansible-core

To install Ansible on macOS, assuming you have Homebrew:

brew install ansible

For other operating systems see the documentation.

Install Ansible Collections

After installing Ansible we also need to install content collections which are needed to give Ansible the required integration with Google Cloud Platform.

ansible-galaxy collection install jugasit.gcp

This will install the collection gcp from the namespace jugasit. It contains roles for managing resources in GCP.

Connect to GCP

In order for Ansible to authenticate against GCP you need to create a Service Account. When doing so you will get a JSON file. In the examples below we assume it was saved as gcp_credentials.json in the working directory. If you saved it somewhere else, adjust the examples accordingly.

Create servers

Create a playbook named create_servers.yml with the following content:

- name: Create resources in GCP
  hosts: localhost
  connection: local

  vars_files:
    - vars/gcp.yml

  roles:
    - name: jugasit.gcp.addresses
      tags: addresses

    - name: jugasit.gcp.networks
      tags: networks

    - name: jugasit.gcp.firewalls
      tags: firewalls

    - name: jugasit.gcp.instances
      tags: instances

Let’s break this down. This contains a single play called Create resources in GCP which we run against localhost. We load variables from a file called vars/gcp.yml (which we will create soon) and then we run a list of roles from the collection we just installed. These roles are tagged so we can customise our run and run only selected roles as we prefer. For example:

# do not create firewall rules
ansible-playbook create_servers.yml --skip-tags firewalls

# only create instances
ansible-playbook create_servers.yml --tags instances

# only create addresses and networks
ansible-playbook create_servers.yml --tags addresses,networks

Next step is to create the variables file. It is good practice to keep variables inside the folder vars. Now we have only a single file here but you may want to split your variables into several files to keep the files from growing too big. Having them in the same folder keeps things organised.

Create the file vars/gcp.yml with the following content:

# the project ID where resources are managed
gcp_project: playground-123456

# the default region and zone for placing resources
gcp_region: europe-north1
gcp_zone: europe-north1-a

# how to authenticate against gcp
gcp_auth_kind: serviceaccount
gcp_service_account_file: ./gcp_credentials.json

# for SSH we use the current user (via environment variable)
# and the public key in the users home directory.
gcp_ssh_user: "{{ lookup('env', 'USER') }}"
gcp_ssh_key: "{{ lookup('file', '~/.ssh/id_rsa.pub') }}"

# list of networks to create
gcp_networks:
  - name: lab-net
    description: Lab network for testing Ansible.
    mtu: 1460

# list of external IP addresses to create
gcp_addresses:
  - name: lab-address-001
  - name: lab-address-002
  - name: lab-address-003

# list of instances to create
gcp_instances:
  - name: lab-instance-001
    machine_type: e2-standard-4
    labels:
      aap_gateway:
      aap_controller:
      aap_eda:
    disks:
      - auto_delete: true
        boot: true
        type: PERSISTENT
        initialize_params:
          source_image: projects/rhel-cloud/global/images/rhel-9-v20250611
          disk_size_gb: 50
    network_interfaces:
      - network:
          selfLink: global/networks/lab-net
        access_configs:
          - name: External NAT
            nat_ip:
              selfLink: global/addresses/lab-address-001
            type: ONE_TO_ONE_NAT
    metadata:
      ssh-keys: "{{ gcp_ssh_user }}:{{ gcp_ssh_key }}"

  - name: lab-instance-002
    machine_type: e2-standard-2
    labels:
      aap_database:
    disks:
      - auto_delete: true
        boot: true
        type: PERSISTENT
        initialize_params:
          source_image: projects/rhel-cloud/global/images/rhel-9-v20250611
          disk_size_gb: 50
    network_interfaces:
      - network:
          selfLink: global/networks/lab-net
        access_configs:
          - name: External NAT
            nat_ip:
              selfLink: global/addresses/lab-address-002
            type: ONE_TO_ONE_NAT
    metadata:
      ssh-keys: "{{ gcp_ssh_user }}:{{ gcp_ssh_key }}"

  - name: lab-instance-003
    machine_type: e2-standard-2
    labels:
      aap_hub:
    disks:
      - auto_delete: true
        boot: true
        type: PERSISTENT
        initialize_params:
          source_image: projects/rhel-cloud/global/images/rhel-9-v20250611
          disk_size_gb: 50
    network_interfaces:
      - network:
          selfLink: global/networks/lab-net
        access_configs:
          - name: External NAT
            nat_ip:
              selfLink: global/addresses/lab-address-003
            type: ONE_TO_ONE_NAT
    metadata:
      ssh-keys: "{{ gcp_ssh_user }}:{{ gcp_ssh_key }}"

# list of firewall rules to create
gcp_firewalls:

  # allow ssh
  - name: lab-firewall-001
    network:
      selfLink: global/networks/lab-net
    source_ranges: ["0.0.0.0/0"]
    allowed:
      - ip_protocol: icmp
      - ip_protocol: tcp
        ports:
          - 22

There a lot going on here. This is a definition of everything we want to create in GCP, and also some configuration on how to create it.

The variables define three instances using three external IPs, and they are all placed on the same network which we also create here. Lastly we create a firewall rule for the network allowing SSH and ping from anywhere to our servers.

As you noticed the playbook is fairly simple and the complexity lies in our variables. This is a good practice when using Ansible at scale as it lets you think of your environment as a state, instead of the result of a bunch of operations.

Besides, these variable are defined in a YAML file in this example, but they could come from other sources such as a CMDB.

Now let’s run this playbook:

$ ansible-playbook create_servers.yml

PLAY [Create resources in GCP] *************************************************

TASK [Gathering Facts] *********************************************************
ok: [localhost]

TASK [jugasit.gcp.addresses : Configure GCP addresses] *************************
changed: [localhost] => (item=lab-address-001)
changed: [localhost] => (item=lab-address-002)
changed: [localhost] => (item=lab-address-003)

TASK [jugasit.gcp.networks : Configure GCP networks] ***************************
changed: [localhost] => (item=lab-net)

TASK [jugasit.gcp.firewalls : Configure GCP firewalls] *************************
changed: [localhost] => (item=lab-firewall-001)

TASK [jugasit.gcp.instances : Configure GCP instances] *************************
changed: [localhost] => (item=lab-instance-001)
changed: [localhost] => (item=lab-instance-002)
changed: [localhost] => (item=lab-instance-003)

TASK [jugasit.gcp.instances : Wait for GCP instance] ***************************
ok: [localhost] => (item=lab-instance-001)
ok: [localhost] => (item=lab-instance-002)
ok: [localhost] => (item=lab-instance-003)

PLAY RECAP *********************************************************************
localhost                  : ok=6    changed=4    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0   

After this we have three servers which we can do something with.

Managing the servers

Creating servers in GCP is neat, but even better would be if we could create a new playbook to run against these servers. Let’s create one called update_servers.yml with the following content:

- name: Update servers
  hosts: all
  become: true
  vars:
    ansible_ssh_common_args: '-o StrictHostKeyChecking=no'
  tasks:
    - name: Update all packages
      ansible.builtin.package:
        name: '*'
        state: latest

Now, to run this we need an inventory with the servers. Instead of creating one manually, let’s use the dynamic inventory plugin for GCP. Create an inventory file called inventory.gcp.yml with the following content:

plugin: google.cloud.gcp_compute
projects:
  - playground-123456
auth_kind: serviceaccount
service_account_file: ./gcp_credentials.json

groups:
  automationgateway: "'aap_gateway' in labels"
  automationcontroller: "'aap_controller' in labels"
  automationeda: "'aap_eda' in labels"
  automationhub: "'aap_hub' in labels"
  database: "'aap_database' in labels"

This tells Ansible to use the gcp_compute inventory plug from the google.cloud collection (installed as a dependency when we installed jugasit.gcp earlier).

We tell Ansible which project to fetch servers from and how to authenticate.

Lastly we tell Ansible to put the servers into groups if they have certain labels.

To test the inventory run the following command:

$ ansible-inventory -i inventory.gcp.yml --graph
@all:
  |--@ungrouped:
  |--@automationgateway:
  |  |--34.88.55.195
  |--@automationcontroller:
  |  |--34.88.55.195
  |--@automationeda:
  |  |--34.88.55.195
  |--@database:
  |  |--35.228.226.185
  |--@automationhub:
  |  |--34.88.46.132

Everything looks good, so let’s run our playbook:

ansible-playbook update_servers.yml -i inventory.gcp.yml

PLAY [Update servers] **********************************************************

TASK [Gathering Facts] *********************************************************
ok: [34.88.46.132]
ok: [35.228.226.185]
ok: [34.88.55.195]

TASK [Update all packages] *****************************************************
ok: [34.88.55.195]
ok: [35.228.226.185]
ok: [34.88.46.132]

PLAY RECAP *********************************************************************
34.88.46.132               : ok=2    changed=0    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0   
34.88.55.195               : ok=2    changed=0    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0   
35.228.226.185             : ok=2    changed=0    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0   

Cleaning up

Being able to create servers is fine, but we should also remove stuff. Especially in a lab like this as we don’t want any resources to linger and cost money.

Create a new playbook called remove_servers.yml with the following content:

- name: Remove resources in GCP
  hosts: localhost
  connection: local
  vars_files:
    - vars/gcp.yml
  vars:
    gcp_state: absent
  roles:
    - jugasit.gcp.instances
    - jugasit.gcp.addresses
    - jugasit.gcp.firewalls
    - jugasit.gcp.networks

This looks almost identical to the playbook for creating stuff but there are two differences:

  • The roles are in a different order
  • We tell the roles that all resources should be absent instead of present.

Let’s run this playbook:

$ ansible-playbook remove_servers.yml

PLAY [Remove resources in GCP] *************************************************

TASK [Gathering Facts] *********************************************************
ok: [localhost]

TASK [jugasit.gcp.instances : Configure GCP instances] *************************
changed: [localhost] => (item=lab-instance-001)
changed: [localhost] => (item=lab-instance-002)
changed: [localhost] => (item=lab-instance-003)

TASK [jugasit.gcp.instances : Wait for GCP instance] ***************************
skipping: [localhost] => (item=lab-instance-001) 
skipping: [localhost] => (item=lab-instance-002) 
skipping: [localhost] => (item=lab-instance-003) 
skipping: [localhost]

TASK [jugasit.gcp.addresses : Configure GCP addresses] *************************
changed: [localhost] => (item=lab-address-001)
changed: [localhost] => (item=lab-address-002)
changed: [localhost] => (item=lab-address-003)

TASK [jugasit.gcp.firewalls : Configure GCP firewalls] *************************
changed: [localhost] => (item=lab-firewall-001)

TASK [jugasit.gcp.networks : Configure GCP networks] ***************************
changed: [localhost] => (item=lab-net)

PLAY RECAP *********************************************************************
localhost                  : ok=5    changed=4    unreachable=0    failed=0    skipped=1    rescued=0    ignored=0   

Conclusion

That wraps up this guide. I hope you learned something from it and is eager to expand on this to automate even more stuff in Google Cloud Platform.

If you need any help with your automation endeavours do not hesitate to contact me.