# Using Route53 as a Key Value Store in GitHub Actions

> Use Route53 private hosted zone TXT records as a cheap key-value store for GitHub Actions, with composite actions that set and get values through the AWS CLI.

- URL: https://doug.sh/posts/using-route53-as-a-key-value-store-in-github-actions/
- Author: Doug Calobrisi
- Date: 2021-12-13
- Updated: 2026-09-14
- Tags: github, actions, aws, route53


> **2026 update:** [a modernized version of this post is here](/posts/route53-as-a-key-value-store-2026-edition/) — uses OIDC instead of static AWS keys and fixes the deprecated `::set-output` syntax that broke the Get action on current runners.

## The Need

GitHub Actions is great, but one thing it’s missing is a form of persistence, or simple key-value store. Sure, you could persist to a git repo - and that might be the right move, depending on the use case. But, what if you just want to store a string or two that you'll need later?

## AWS Route53?

Route53 is a highly-available DNS service offered by AWS. It’s automatically replicated across the globe. It’s cheap, too. Each hosted zone costs $0.50USD/month, and a million queries is $0.40USD.

Since it’s a name server, you can store a variety of records, one of which is `TXT` - or a text record. The DNS `TXT` record was originally intended for human-readable text, or notes, about a DNS record. It’s the perfect candidate to hold a string for us.

DNS zones and their records, by their nature, are publicly accessible to the Internet. However, Route53 also provides Private Hosted Zones, which are DNS zones meant only to be used inside of a VPC (Virtual Private Cloud). We can use these Private Hosted Zones to store records that are only retrievable in a VPC or via the AWS API with credentials.

## Game Plan

We can use Route53 as our key value store, but how do we store or fetch data? Since we're using GitHub Actions, we'll use a reusable Composite Action. We'll create two Actions -- one that sets data, and one that gets data. 

We'll use the AWS CLI utility known as `awscli` to access the AWS API.

Our data setting Action will use `awscli` to `UPSERT` the value of the key as the `TXT` value of the hostname record using the `change-resource-record-sets` command and providing a JSON payload we'll construct. 

Our data getting Action will also use `awscli`, but it will fetch the `TXT` value of the hostname record using the `list-resource-record-sets` command.

We don't want our data to be publicly available, so we're using Private Hosted Zones. This means we'll need to use the AWS API to fetch our data, in this case using `awscli`. But, if you were to use a publicly available zone, you could just use `nslookup --type=txt <key/hostname>`.

Below, we'll look out how each Action works. 

## Creating a Private Hosted Zone

You create a Private Hosted Zone the same way as any other Route53 zone, but you need to specify a few other options like the region and VPC. You can do this via the AWS Console GUI or using `awscli` at a terminal.

Since this zone is completely isolated to our account and VPCs, we can name it anything. The zone name is a namespace, and you can have many. In this case, we just name it `kvstore`. 

It doesn't matter which VPCs we associate with the zone, as we'll just be using the API to get/set the values.

#### Terminal
```bash
aws route53 create-hosted-zone --name kvstore \
--caller-reference 2021-12-13-00:47 \
--hosted-zone-config PrivateZone=true \
-—vpc VPCRegion=us-east-1,VPCId=vpc-ccab2fb4
```

#### AWS Console

![AWS Console](/images/route53-keyvalue-store_privatezonegui.png)



## Composite Action: Set

We'll create a Composite Action with three inputs defined:
- `key` - the 'key', a FQDN (fully qualified domain name)
- `value` - the value
- `hosted-zone-id` - the Hosted Zone ID assigned by AWS

Then, we'll execute two steps:
1. *Create JSON Payload* - we create `payload.json` for our request using the `key` and `value` inputs to the Action.
2.  *Set Record* - we call `awscli`, specifying the `hosted-zone-id` we pass in as an input, as well as the path to our payload JSON.

```yaml
name: 'Set Route53 TXT Record'
inputs:
  key:
    description: 'FQDN of Key'
    required: true
  value:
    description: 'Value to set'
    required: true
  hosted-zone-id:
    description: 'Route53 Hosted Zone ID'
    required: true
runs:
  using: "composite"
  steps:
  
    - name: Create JSON Payload
      run: |
        cat << EOF >> payload.json
        {
          "Changes": [{
          "Action": "UPSERT",
          "ResourceRecordSet": {
            "Name": "${{ inputs.key }}",
            "Type": "TXT",
            "TTL": 30,
            "ResourceRecords": [{ "Value": "\"${{ inputs.value }}\""}]
          }
         }]
        }
        EOF
        cat payload.json
      shell: bash

    - name: Set Record
      run: |
        aws route53 change-resource-record-sets \
        --hosted-zone-id ${{ inputs.hosted-zone-id }} \
        --change-batch file://payload.json
      shell: bash
```

## Composite Action: Get

Our Get Composite Action will also need inputs defined:
- `key` - the ‘key’ to fetch, a FQDN (fully qualified domain name)
- `hosted-zone-id` - the Hosted Zone ID assigned by AWS

Then, we'll execute a single step that fetches the value for the key, strips quotations, and sets the value of the `value` output to the value of the key.


```yaml

name: 'Get Route53 TXT Record'

inputs:
  key:
    description: 'FQDN of Key'
    required: true
  hosted-zone-id:
    description: 'Route53 Hosted Zone ID'
    required: true
    
outputs:
  value:
    description: 'Value of key'
    value: ${{ steps.get-record.outputs.value }}

runs:
  using: "composite"
  steps:
    - name: Get Record
      id: get-record
      run: |
        VALUE=`aws route53 list-resource-record-sets \
        --hosted-zone-id ${{ inputs.hosted-zone-id }} \
        --query "ResourceRecordSets[?Name == '${{ inputs.key }}.']" \
        | jq '.[0].ResourceRecords[0].Value' \
        | tr -d '"' | tr -d '\'`

        echo "::set-output name=value::$VALUE"
      shell: bash
```

## Using the Actions

Now that we have our two Actions, we can put them to use in a Workflow.

We'll need to specify three environment variables for AWS, `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, and `AWS_DEFAULT_REGION`.

When using the `get` action, the value of the key will be available as the `value` output of the step. You can access it's value using `${{ steps.<step-id>.outputs.value }}`

```yaml
name: Route53 KV Store Actions Example
on: [workflow_dispatch]
jobs:
  test:
    runs-on: ubuntu-latest
    env:
      AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
      AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
      AWS_DEFAULT_REGION: us-east-1

   steps:
      - uses: actions/checkout@v2

      - uses: ./actions/set
        with: 
          key: testkey.kvstore
          value: "hello world"
          hosted-zone-id: Z0750343I14B1A1HAP9
        
      - uses: ./actions/get
        id: get-testkey
        with:
          key: testkey.kvstore
          hosted-zone-id: Z0750343I14B1A1HAP9

      - run: echo ${{ steps.get-testkey.outputs.value }}
```

