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:
- Configure access to the S3-compatible service
- Create or provide the backend bucket
- Configure OpenTofu to use that bucket
- Run
init,plan, andapply - 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 10Conclusion
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.


