⛄ Own Your Terraform State with an S3-Compatible Backend

Store Terraform or OpenTofu state yourself without relying on Terraform Cloud.

Recently, I was reading an article about Terraform Cloud and it reminded me of a problem I had already encountered while building my GitHub Actions workflows:

where should Terraform state live when the CI runners are disposable?

Terraform Cloud is one possible answer.

But it is not the only one.

If your object-storage provider exposes an S3-compatible API, you can keep control of the state yourself and use it directly as a remote backend.

In my case, I use DigitalOcean Spaces together with OpenTofu.

Why does remote state matter?

By default, Terraform and OpenTofu store their state locally in:

1terraform.tfstate

That works perfectly on a workstation.

Things become more interesting in CI.

A GitHub Actions runner is temporary. Once the runner disappears, anything stored only on its local filesystem disappears with it.

Consider a workflow such as:

1init
2  │
3  ▼
4plan
5  │
6  ▼
7apply

If every stage runs in an isolated environment and the state exists only as a local file, the next environment cannot automatically access it.

Uploading terraform.tfstate as a CI artifact would technically solve part of the problem, but it is not a great architecture.

Terraform/OpenTofu state can contain:

  • Resource IDs
  • Infrastructure metadata
  • Outputs
  • Provider information
  • Potentially sensitive values

The state is also something that multiple executions may need to access safely.

That is exactly what remote backends are designed for.

What does the backend do?

A backend defines where Terraform or OpenTofu stores its state.

With an S3 backend, the architecture becomes:

1GitHub Actions runner
2        │
3        │ OpenTofu
4        ▼
5┌─────────────────────┐
6│ S3-compatible API   │
7│                     │
8│ terraform.tfstate   │
9└─────────────────────┘

The CI runner itself remains disposable.

The state does not.

This also means that s3cmd is not actually responsible for storing Terraform state.

I use s3cmd to:

  • Connect to the object-storage service
  • Create buckets
  • Inspect their contents
  • Remove temporary buckets when required

OpenTofu itself communicates with the bucket through its S3 backend.

Why DigitalOcean Spaces?

I was already deploying infrastructure on DigitalOcean, and DigitalOcean Spaces provides an S3-compatible object-storage API.

That means tools designed for Amazon S3 can also communicate with Spaces by using the appropriate endpoint and credentials.

Conceptually:

 1                 ┌──────────────┐
 2                 │ GitHub       │
 3                 │ Actions      │
 4                 └──────┬───────┘
 5                        │
 6                        │ tofu init / plan / apply
 7                        ▼
 8                 ┌──────────────┐
 9                 │ OpenTofu     │
10                 │ S3 backend   │
11                 └──────┬───────┘
12                        │
13                        │ S3-compatible API
14                        ▼
15                 ┌──────────────┐
16                 │ DigitalOcean │
17                 │ Spaces       │
18                 └──────────────┘

No Terraform Cloud account is required.

The main steps

The workflow is relatively simple:

  1. Configure access to the S3-compatible service
  2. Create or provide the backend bucket
  3. Configure OpenTofu to use that bucket
  4. Run init, plan, and apply
  5. Clean up the bucket only if the infrastructure is intentionally ephemeral

The last point is important.

For permanent infrastructure, I would normally keep the state bucket persistent.

For short-lived test environments, dynamically creating and deleting the state bucket can make sense.

GitHub Actions configuration

First, I expose the required values to the workflow.

 1env:
 2  DO_PAT: ${{ secrets.DIGITALOCEAN_ACCESS_TOKEN }}
 3
 4  AWS_ACCESS_KEY_ID: ${{ secrets.DIGITALOCEAN_SPACES_ACCESS_TOKEN }}
 5  AWS_SECRET_ACCESS_KEY: ${{ secrets.DIGITALOCEAN_SPACES_SECRET_KEY }}
 6
 7  REGION: ${{ secrets.DIGITALOCEAN_REGION }}
 8
 9  MOUNT_POINT: "/opt/rkub"
10
11  BUCKET: "rkub-github-action-${{ github.run_id }}"

The important variables for the backend are:

1AWS_ACCESS_KEY_ID
2AWS_SECRET_ACCESS_KEY
3REGION
4BUCKET

The credentials are stored as GitHub Actions secrets rather than directly in the workflow.

Create the bucket

For this project, I used s3cmd to create the Space dynamically.

 1steps:
 2  - name: Set up s3cmd
 3    uses: s3-actions/s3cmd@main
 4    with:
 5      provider: digitalocean
 6      region: ${{ secrets.DIGITALOCEAN_REGION }}
 7      access_key: ${{ secrets.DIGITALOCEAN_SPACES_ACCESS_TOKEN }}
 8      secret_key: ${{ secrets.DIGITALOCEAN_SPACES_SECRET_KEY }}
 9
10  - name: Create Space bucket
11    run: |
12      if [[ "${BUCKET}" != "terraform-backend-github" ]]; then
13        s3cmd mb "s3://${BUCKET}"
14      fi
15
16      sleep 10

The bucket exists independently from the runner.

At that point, it can be used as the OpenTofu backend.

Configure the S3 backend

The Terraform/OpenTofu configuration also needs an S3 backend block.

For example:

 1terraform {
 2  backend "s3" {
 3    key = "terraform.tfstate"
 4
 5    # DigitalOcean Spaces is S3-compatible.
 6    endpoints = {
 7      s3 = "https://REGION.digitaloceanspaces.com"
 8    }
 9
10    skip_credentials_validation = true
11    skip_region_validation      = true
12    skip_requesting_account_id  = true
13    skip_metadata_api_check     = true
14  }
15}

The exact compatibility options depend on the S3-compatible provider and the OpenTofu version being used.

I deliberately leave the bucket name outside the configuration because it is supplied dynamically by the CI workflow.

Then initialization can inject the bucket:

1- name: Tofu Init
2  id: init
3  run: |
4    cd ./DO/infra
5
6    tofu init \
7      -backend-config="bucket=${BUCKET}"

OpenTofu now knows where the state belongs.

The backend becomes the persistent part of the workflow.

Validate, plan and apply

The rest of the pipeline remains fairly conventional.

 1- name: Checkout files
 2  uses: actions/checkout@v4
 3
 4- name: Setup Tofu
 5  uses: opentofu/setup-opentofu@v1
 6  with:
 7    tofu_version: "1.7.3"
 8
 9- name: Tofu Init
10  id: init
11  run: |
12    cd ./DO/infra
13    tofu init -backend-config="bucket=${BUCKET}"
14
15- name: Tofu Validate
16  id: validate
17  run: |
18    cd ./DO/infra
19    tofu validate -no-color
20
21- name: Tofu Plan
22  id: plan
23  run: |
24    cd ./DO/infra
25
26    tofu plan \
27      -out=terraform.tfplan \
28      -var "GITHUB_RUN_ID=$GITHUB_RUN_ID" \
29      -var "token=${DO_PAT}" \
30      -var "worker_count=${WORKER_COUNT}" \
31      -var "controller_count=${CONTROLLER_COUNT}" \
32      -var "instance_size=${SIZE}" \
33      -var "spaces_access_key_id=${{ secrets.DIGITALOCEAN_SPACES_ACCESS_TOKEN }}" \
34      -var "spaces_access_key_secret=${{ secrets.DIGITALOCEAN_SPACES_SECRET_KEY }}" \
35      -var "mount_point=${MOUNT_POINT}" \
36      -var "airgap=${AIRGAP}" \
37      -var "terraform_backend_bucket_name=${BUCKET}"
38
39  continue-on-error: true
40
41- name: Tofu Plan Status
42  if: steps.plan.outcome == 'failure'
43  run: exit 1
44
45- name: Tofu Apply
46  run: |
47    cd ./DO/infra
48    tofu apply terraform.tfplan

The important part is not the plan or apply commands themselves.

It is that every OpenTofu command points to the same backend.

 1          Runner A
 2          tofu init
 3              │
 4              ▼
 5        ┌───────────┐
 6        │           │
 7        │ S3 state  │
 8        │           │
 9        └───────────┘
10              ▲
11              │
12          Runner B
13          tofu init

Different runners can therefore work against the same remote state instead of depending on a local terraform.tfstate.

State locking

Remote storage solves persistence, but there is another problem:

what happens if two pipelines try to modify the same state at the same time?

That is where state locking becomes important.

A lock prevents two writers from modifying the same state concurrently.

Modern OpenTofu versions support S3-based locking with:

1use_lockfile = true

For example:

1terraform {
2  backend "s3" {
3    bucket       = "terraform-backend"
4    key          = "rkub/terraform.tfstate"
5    region       = "fra1"
6    use_lockfile = true
7  }
8}

Whether native S3 locking works correctly with a particular S3-compatible provider depends on the API features that provider implements, so it is something worth validating before relying on it.

For a simple personal CI environment where only one workflow can run at a time, GitHub Actions concurrency controls can also provide an additional safeguard.

For example:

1concurrency:
2  group: rkub-infrastructure
3  cancel-in-progress: false

This does not replace backend locking, but it can prevent multiple copies of the same workflow from racing against each other.

Keep the state bucket outside the managed infrastructure

There is one architectural trap worth mentioning.

Imagine that the infrastructure being destroyed contains the bucket holding its own Terraform state.

That creates an obvious chicken-and-egg problem.

1OpenTofu
2   │
3   ├── manages cluster
4   ├── manages network
5   └── manages state bucket
6             │
7             └── contains OpenTofu state

Destroying everything may also destroy the information required to manage everything.

For permanent infrastructure, the backend should generally belong to a small administrative layer that exists independently from the environment it manages.

Something closer to:

 1Administrative infrastructure
 2        │
 3        └── State bucket
 4                │
 5                ▼
 6             OpenTofu
 7                │
 8                ▼
 9       Application infrastructure
10       ├── network
11       ├── cluster
12       ├── storage
13       └── workloads

That separation makes the whole setup much safer.

What about temporary infrastructure?

My original Rkub workflow had a slightly different requirement.

The entire infrastructure was temporary.

The workflow created an environment, used it, and eventually destroyed it.

In that specific situation, dynamically creating a bucket such as:

1rkub-github-action-${GITHUB_RUN_ID}

made sense.

The state was only needed for the lifetime of that ephemeral environment.

The lifecycle became:

 1Create backend bucket
 2        │
 3        ▼
 4   tofu init
 5        │
 6        ▼
 7   tofu apply
 8        │
 9        ▼
10 Run workload/tests
11        │
12        ▼
13  tofu destroy
14        │
15        ▼
16Delete backend bucket

This is different from the normal production case.

For a long-lived environment, I would keep the backend.

For an intentionally disposable environment, the backend can be disposable too.

Why not Terraform Cloud?

Terraform Cloud provides much more than remote state.

It can provide:

  • Remote runs
  • State management
  • Team collaboration
  • Policy controls
  • Variable management
  • Governance features

Those may be useful depending on the organization.

My requirement here was much smaller.

I needed:

a reliable place to store state between ephemeral CI runners.

An S3-compatible object-storage service already solved that problem.

I therefore did not need to introduce another platform simply to store the state.

This is less about being against Terraform Cloud and more about choosing the smallest tool that solves the requirement.

What is s3cmd doing here?

This is worth repeating because the original version of this article blurred the distinction.

s3cmd is not the Terraform backend.

It is an S3 client.

In this workflow I use it to bootstrap and manage the object-storage bucket.

The responsibilities are:

 1s3cmd
 2  │
 3  └── create / inspect / delete bucket
 4
 5OpenTofu
 6  │
 7  └── read / write / lock state
 8
 9DigitalOcean Spaces
10  │
11  └── persist objects

Once the bucket exists, OpenTofu does not need s3cmd to manage its state.

That separation makes the architecture easier to understand.

The full pipeline

Below is the complete GitHub Actions workflow from the Rkub project:

  1---
  2name: Stage online install
  3
  4on:
  5  workflow_dispatch:
  6
  7env:
  8  DO_PAT: ${{secrets.DIGITALOCEAN_ACCESS_TOKEN}}
  9  AWS_ACCESS_KEY_ID: ${{secrets.DIGITALOCEAN_SPACES_ACCESS_TOKEN}}
 10  AWS_SECRET_ACCESS_KEY: ${{secrets.DIGITALOCEAN_SPACES_SECRET_KEY}}
 11  REGION: ${{secrets.DIGITALOCEAN_REGION}}
 12  MOUNT_POINT: "/opt/rkub"
 13  BUCKET: "rkub-github-action-${{ github.run_id }}"
 14  #BUCKET: "terraform-backend-github"
 15  CONTROLLER_COUNT: "1"
 16  WORKER_COUNT: "1"
 17  SIZE: "s-2vcpu-4gb"
 18  AIRGAP: "false"
 19
 20jobs:
 21  bucket:
 22    name: Bucket
 23    runs-on: ubuntu-latest
 24    timeout-minutes: 10
 25
 26    steps:
 27      - name: Set up S3cmd cli tool
 28        uses: s3-actions/s3cmd@main
 29        with:
 30          provider: digitalocean
 31          region: ${{secrets.DIGITALOCEAN_REGION}}
 32          access_key: ${{secrets.DIGITALOCEAN_SPACES_ACCESS_TOKEN}}
 33          secret_key: ${{secrets.DIGITALOCEAN_SPACES_SECRET_KEY}}
 34
 35      - name: Create Space Bucket
 36        run: |
 37          ## sed -i -e 's/signature_v2.*$/signature_v2 = True/' ~/.s3cfg
 38          ## sed -i -e 's/signature_v2.*$/signature_v2 = True/' /home/runner/work/_temp/s3cmd.conf
 39          if [[ $BUCKET != "terraform-backend-github" ]]; then s3cmd mb s3://${BUCKET}; fi
 40          sleep 10
 41
 42  deploy:
 43    name: Deploy
 44    runs-on: ubuntu-latest
 45    needs: [ Bucket ]
 46    timeout-minutes: 20
 47
 48    defaults:
 49      run:
 50        shell: bash
 51        working-directory: ./test
 52
 53    steps:
 54      - name: Checkout files
 55        uses: actions/checkout@v4
 56
 57      - name: Setup Tofu
 58        uses: opentofu/setup-opentofu@v1
 59        with:
 60          tofu_version: "1.7.3"
 61
 62      - name: Tofu Init
 63        id: init
 64        run: |
 65          cd ./DO/infra
 66          tofu init -backend-config="bucket=${BUCKET}"
 67
 68      - name: Tofu Validate
 69        id: validate
 70        run: |
 71          cd ./DO/infra
 72          tofu validate -no-color
 73
 74      - name: Tofu Plan
 75        id: plan
 76        run: |
 77          cd ./DO/infra
 78          tofu plan -out=terraform.tfplan \
 79          -var "GITHUB_RUN_ID=$GITHUB_RUN_ID" \
 80          -var "token=${DO_PAT}" \
 81          -var "worker_count=${WORKER_COUNT}" \
 82          -var "controller_count=${CONTROLLER_COUNT}" \
 83          -var "instance_size=${SIZE}" \
 84          -var "spaces_access_key_id=${{secrets.DIGITALOCEAN_SPACES_ACCESS_TOKEN}}" \
 85          -var "spaces_access_key_secret=${{secrets.DIGITALOCEAN_SPACES_SECRET_KEY}}" \
 86          -var "mount_point=${MOUNT_POINT}" \
 87          -var "airgap=${AIRGAP}" \
 88          -var "terraform_backend_bucket_name=${BUCKET}"
 89        continue-on-error: true
 90
 91      - name: Tofu Plan Status
 92        if: steps.plan.outcome == 'failure'
 93        run: exit 1
 94
 95      - name: Tofu Apply
 96        run: |
 97          cd ./DO/infra
 98          tofu apply terraform.tfplan
 99
100      # Save Artifacts
101      - name: Install s3fs-fuse on Ubuntu
102        run: |
103          sudo apt-get install -y s3fs
104
105      - name: Mount Space Bucket
106        run: |
107          echo "${{secrets.DIGITALOCEAN_SPACES_ACCESS_TOKEN}}:${{secrets.DIGITALOCEAN_SPACES_SECRET_KEY}}" > ./passwd-s3fs
108          chmod 600 ./passwd-s3fs
109          mkdir -p ${MOUNT_POINT}
110          s3fs ${BUCKET} ${MOUNT_POINT} -o url=https://${REGION}.digitaloceanspaces.com -o passwd_file=./passwd-s3fs
111          df -Th ${MOUNT_POINT}
112
113      - name: Save files
114        run: |
115          cp ${{ github.workspace }}/test/inventory/hosts.ini ${MOUNT_POINT}/hosts.ini
116          cp ${{ github.workspace }}/test/DO/infra/.key.private ${MOUNT_POINT}/.key.private
117
118  reachable:
119    name: Reachable
120    runs-on: ubuntu-latest
121    needs: [ Deploy ]
122    timeout-minutes: 10
123
124    defaults:
125      run:
126        shell: bash
127        working-directory: ./test
128
129    steps:
130      - name: Checkout files
131        uses: actions/checkout@v4
132
133      # Get Artifacts 
134      - name: Install s3fs-fuse on Ubuntu
135        run: |
136          sudo apt-get install -y s3fs
137
138      - name: Mount Space Bucket
139        run: |
140          echo "${{secrets.DIGITALOCEAN_SPACES_ACCESS_TOKEN}}:${{secrets.DIGITALOCEAN_SPACES_SECRET_KEY}}" > ./passwd-s3fs
141          chmod 600 ./passwd-s3fs
142          mkdir -p ${MOUNT_POINT}
143          s3fs ${BUCKET} ${MOUNT_POINT} -o url=https://${REGION}.digitaloceanspaces.com -o passwd_file=./passwd-s3fs
144          df -Th ${MOUNT_POINT}
145
146      - name: Get Artificats
147        run: |
148          cp ${MOUNT_POINT}/hosts.ini ${{ github.workspace }}/test/inventory/hosts.ini
149          cp ${MOUNT_POINT}/.key.private ${{ github.workspace }}/test/DO/infra/.key.private
150
151      # Test
152      - name: Set up Python
153        id: setup_python
154        uses: actions/setup-python@v5
155        with:
156          python-version: 3.12
157
158      - name: Install dependencies
159        run: |
160          python3 -m pip install --upgrade pip
161          python3 -m pip install "ansible-core>=2.15,<2.17"
162          ansible --version
163
164      - name: Test if reachable
165        run: |
166          ANSIBLE_HOST_KEY_CHECKING=False ansible RKE2_CLUSTER -m ping -u root
167
168      - name: Wait for cloud-init to finish
169        run: |
170          ANSIBLE_HOST_KEY_CHECKING=False ansible RKE2_CLUSTER -m shell -a "cloud-init status --wait" -u root -v
171
172  install:
173    name: Install
174    runs-on: ubuntu-latest
175    needs: [ Reachable ]
176    timeout-minutes: 60
177
178    defaults:
179      run:
180        shell: bash
181        working-directory: ./test
182
183    steps:
184      - name: Checkout files
185        uses: actions/checkout@v4
186
187      - name: Install requirements
188        run: |
189          cd ..
190          make prerequis
191          ansible --version
192
193      - name: Install dependencies
194        run: |
195          python3 -m pip install --upgrade pip
196          python3 -m pip install "ansible-core>=2.15,<2.17"
197          ansible --version
198
199      # Get Artifacts 
200      - name: Install s3fs-fuse on Ubuntu
201        run: |
202          sudo apt-get install -y s3fs
203
204      - name: Mount Space Bucket
205        run: |
206          echo "${{secrets.DIGITALOCEAN_SPACES_ACCESS_TOKEN}}:${{secrets.DIGITALOCEAN_SPACES_SECRET_KEY}}" > ./passwd-s3fs
207          chmod 600 ./passwd-s3fs
208          mkdir -p ${MOUNT_POINT}
209          s3fs ${BUCKET} ${MOUNT_POINT} -o url=https://${REGION}.digitaloceanspaces.com -o passwd_file=./passwd-s3fs
210          df -Th ${MOUNT_POINT}
211
212      - name: Get Artificats
213        run: |
214          cp ${MOUNT_POINT}/hosts.ini ${{ github.workspace }}/test/inventory/hosts.ini
215          cp ${MOUNT_POINT}/.key.private ${{ github.workspace }}/test/DO/infra/.key.private
216
217      # Install
218      - name: Run playbook install.yml
219        run: |
220          ANSIBLE_HOST_KEY_CHECKING=False ansible-playbook -u root playbooks/install.yml -e "airgap=false" -e "method=tarball"
221
222      #- name: Run playbook rancher.yml
223      #  run: |
224      #    ANSIBLE_HOST_KEY_CHECKING=False ansible-playbook -u root playbooks/rancher.yml
225
226      #- name: Run playbook longhorn.yml
227      #  run: |
228      #    ANSIBLE_HOST_KEY_CHECKING=False ansible-playbook -u root playbooks/longhorn.yml
229
230      #- name: Run playbook neuvector.yml
231      #  run: |
232      #    ANSIBLE_HOST_KEY_CHECKING=False ansible-playbook -u root playbooks/neuvector.yml
233
234  test:
235    name: Test
236    runs-on: ubuntu-latest
237    needs: [ Install ]
238    timeout-minutes: 10
239
240    defaults:
241      run:
242        shell: bash
243        working-directory: ./test
244
245    steps:
246      - name: Checkout files
247        uses: actions/checkout@v4
248
249      # Get Artifacts 
250      - name: Install s3fs-fuse on Ubuntu
251        run: |
252          sudo apt-get install -y s3fs
253
254      - name: Mount Space Bucket
255        run: |
256          echo "${{secrets.DIGITALOCEAN_SPACES_ACCESS_TOKEN}}:${{secrets.DIGITALOCEAN_SPACES_SECRET_KEY}}" > ./passwd-s3fs
257          chmod 600 ./passwd-s3fs
258          mkdir -p ${MOUNT_POINT}
259          s3fs ${BUCKET} ${MOUNT_POINT} -o url=https://${REGION}.digitaloceanspaces.com -o passwd_file=./passwd-s3fs
260          df -Th ${MOUNT_POINT}
261
262      - name: Get Artificats
263        run: |
264          cp ${MOUNT_POINT}/hosts.ini ${{ github.workspace }}/test/inventory/hosts.ini
265          cp ${MOUNT_POINT}/.key.private ${{ github.workspace }}/test/DO/infra/.key.private
266
267      # Test
268      - name: Install dependencies
269        run: |
270          python3 -m pip install --upgrade pip
271          python3 -m pip install "ansible-core>=2.15,<2.17"
272          python3 -m pip install -U pytest-testinfra pytest-sugar pytest
273          ansible --version
274
275      - name: Run Python Tests
276        run: |
277          export DEFAULT_PRIVATE_KEY_FILE=.key
278          python3 -m pytest --hosts=RKE2_CONTROLLERS --ansible-inventory=inventory/hosts.ini --force-ansible --connection=ansible basic_server_tests.py
279          python3 -m pytest --hosts=RKE2_WORKERS --ansible-inventory=inventory/hosts.ini --force-ansible --connection=ansible basic_agent_tests.py
280
281  delay:
282    name: Delay
283    runs-on: ubuntu-latest
284    needs: [ Test ]
285    if: always()
286
287    steps:
288      - name: Delay 10min
289        uses: whatnick/wait-action@master
290        with:
291          time: '600s'
292
293  cleanup:
294    name: Cleanup
295    runs-on: ubuntu-latest
296    needs: [ Delay ]
297    if: always()
298    timeout-minutes: 30
299
300    defaults:
301      run:
302        shell: bash
303        working-directory: ./test/DO/infra
304
305    steps:
306      - name: Checkout files
307        uses: actions/checkout@v4
308
309      - name: Setup Tofu
310        uses: opentofu/setup-opentofu@v1
311        with:
312          tofu_version: "1.7.3"
313
314      - name: Tofu Init
315        id: init
316        run: |
317          tofu init -backend-config="bucket=${BUCKET}"
318        continue-on-error: true
319
320      - name: Tofu plan delete stack
321        id: plan
322        run: |
323          tofu plan -destroy -out=terraform.tfplan \
324          -var "GITHUB_RUN_ID=$GITHUB_RUN_ID" \
325          -var "token=${DO_PAT}" \
326          -var "worker_count=${WORKER_COUNT}" \
327          -var "controller_count=${CONTROLLER_COUNT}" \
328          -var "instance_size=${SIZE}" \
329          -var "spaces_access_key_id=${{secrets.DIGITALOCEAN_SPACES_ACCESS_TOKEN}}" \
330          -var "spaces_access_key_secret=${{secrets.DIGITALOCEAN_SPACES_SECRET_KEY}}" \
331          -var "mount_point=${MOUNT_POINT}" \
332          -var "airgap=${AIRGAP}" \
333          -var "terraform_backend_bucket_name=${BUCKET}"
334        continue-on-error: true
335
336      - name: Tofu Apply
337        run: |
338          tofu apply terraform.tfplan
339        continue-on-error: true
340
341      - name: Set up S3cmd cli tool
342        uses: s3-actions/s3cmd@main
343        with:
344          provider: digitalocean
345          region: ${{secrets.DIGITALOCEAN_REGION}}
346          access_key: ${{secrets.DIGITALOCEAN_SPACES_ACCESS_TOKEN}}
347          secret_key: ${{secrets.DIGITALOCEAN_SPACES_SECRET_KEY}}
348
349      - name: Remove Space bucket
350        run: |
351          ## sed -i -e 's/signature_v2.*$/signature_v2 = True/' ~/.s3cfg
352          ## sed -i -e 's/signature_v2.*$/signature_v2 = True/' /home/runner/work/_temp/s3cmd.conf
353          if [[ $BUCKET != "terraform-backend-github" ]]; then s3cmd rb s3://${BUCKET} --recursive; fi
354          sleep 10

Conclusion

Terraform and OpenTofu state should not depend on the lifetime of a CI runner.

A remote backend solves that problem by moving state out of the execution environment and into persistent storage.

In this case:

 1GitHub Actions
 2      │
 3      ▼
 4   OpenTofu
 5      │
 6      ▼
 7 S3 backend
 8      │
 9      ▼
10DigitalOcean Spaces

s3cmd is useful around the edges for managing the bucket, but OpenTofu itself is responsible for using that bucket as its backend.

For permanent infrastructure, the backend should generally be persistent and independent from the infrastructure it manages.

For short-lived CI environments such as my Rkub workflow, dynamically creating and deleting the backend can also be a perfectly reasonable design.

The important part is that the runner remains disposable while the state lives somewhere intentional.

And if an S3-compatible backend already solves the problem, adding another platform is optional rather than mandatory.

Sunday, October 4, 2026 Monday, May 5, 2025