⛄ Own your terraform state (with s3cmd)

What if you do not want to use Terraform cloud ?…

Recently I was reading this article about Terraform Cloud, and remembered that I went throught same issue when writing my github Workflows…

The issue…

When using CI, each job is a runner, so new triggered container for each step of the pipeline. So the terraform.tfstate is lost between the pipeline steps. In my case, I was deploying on Digital Ocean providers, willing to store the state on S3 bucket created at the start of the pipeline and destroyed at the end.

I wrote a documentation about s3cmd which cover 3 differents use cases (manual usage; in a github workflow; or mounting S3 bucket as a FS) in docs/storage/s3. The purpose of this article is not to repeat the documentation but rather underline the fact that we don’t need this dependence to Terraform Cloud and another account somewhere else, when s3cmd can help us to do the job.

The solution!

The big steps

  • Install and init a connection with s3cmd
  • Create a bucket
  • Use it as your terrafrom backend
  • Cleanup after if needed.

Inside a Github Workflows:

This is pretty usefull in a pipeline, but do not forget to include a job to cleanup everything once done…

  • Init the vars :
1env:
2  DO_PAT: ${{secrets.DIGITALOCEAN_ACCESS_TOKEN}}
3  AWS_ACCESS_KEY_ID: ${{secrets.DIGITALOCEAN_SPACES_ACCESS_TOKEN}}
4  AWS_SECRET_ACCESS_KEY: ${{secrets.DIGITALOCEAN_SPACES_SECRET_KEY}}
5  REGION: ${{secrets.DIGITALOCEAN_REGION}}
6  MOUNT_POINT: "/opt/rkub"
7  BUCKET: "rkub-github-action-${{ github.run_id }}"
  • Create a bucket directly on your provider :
 1    steps:
 2      - name: Set up S3cmd cli tool
 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          ## sed -i -e 's/signature_v2.*$/signature_v2 = True/' ~/.s3cfg
13          ## sed -i -e 's/signature_v2.*$/signature_v2 = True/' /home/runner/work/_temp/s3cmd.conf
14          if [[ $BUCKET != "terraform-backend-github" ]]; then s3cmd mb s3://${BUCKET}; fi
15          sleep 10
  • Then use it as terraform/opentofu backend:
 1    steps:
 2      - name: Checkout files
 3        uses: actions/checkout@v4
 4
 5      - name: Setup Tofu
 6        uses: opentofu/setup-opentofu@v1
 7        with:
 8          tofu_version: "1.7.3"
 9
10      - name: Tofu Init
11        id: init
12        run: |
13          cd ./DO/infra
14          tofu init -backend-config="bucket=${BUCKET}"
15
16      - name: Tofu Validate
17        id: validate
18        run: |
19          cd ./DO/infra
20          tofu validate -no-color
21
22      - name: Tofu Plan
23        id: plan
24        run: |
25          cd ./DO/infra
26          tofu plan -out=terraform.tfplan \
27          -var "GITHUB_RUN_ID=$GITHUB_RUN_ID" \
28          -var "token=${DO_PAT}" \
29          -var "worker_count=${WORKER_COUNT}" \
30          -var "controller_count=${CONTROLLER_COUNT}" \
31          -var "instance_size=${SIZE}" \
32          -var "spaces_access_key_id=${{secrets.DIGITALOCEAN_SPACES_ACCESS_TOKEN}}" \
33          -var "spaces_access_key_secret=${{secrets.DIGITALOCEAN_SPACES_SECRET_KEY}}" \
34          -var "mount_point=${MOUNT_POINT}" \
35          -var "airgap=${AIRGAP}" \
36          -var "terraform_backend_bucket_name=${BUCKET}"
37        continue-on-error: true
38
39      - name: Tofu Plan Status
40        if: steps.plan.outcome == 'failure'
41        run: exit 1
42
43      - name: Tofu Apply
44        run: |
45          cd ./DO/infra
46          tofu apply terraform.tfplan

Doing so, I was able to pass the state throught the different jobs in my pipeline. Instead of doing one big job with all the step, I found this solution more elegent.

The full Pipeline

Below the full pipeline Github 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
Thursday, January 15, 2026 Monday, May 5, 2025