Module 4: Secrets Environments And Identity

7. Hands-on: dummy credentials for LocalStack

Description

It's time to fix, in Andes Cargo's real ci.yml, exactly the antipattern lesson 2 named: LocalStack's dummy credentials, written directly in the YAML since Module 3, are going to migrate to a real Secret, read with the secrets.AWS_ACCESS_KEY_ID syntax you learned in lesson 3. You're going to run the complete pipeline twice —before and after the change— and confirm, with literal act output, the behavior is identical: the change breaks nothing, because it was never about the pipeline behaving differently, it was about the credential no longer living where it shouldn't.

Explicit honesty, before writing a single line: having test/test live in a gitignored .secrets instead of in ci.yml is a real hygiene improvement, not a complete security solution — because, as you already know from lesson 5, it's still technically a long-lived credential, even if it has no real value to protect. This is acceptable only because the final destination is LocalStack, which validates nothing against any real AWS account. Against a real AWS account, not even this improved version would be enough — you'd need OIDC (lesson 5), not just moving the key to a Secret.

Connection to the module

This lesson builds directly on the ci.yml you left finished in Module 3, lesson 8. It adds no new step, changes no plan result — it exclusively changes where the two credential lines in the env: block come from. This module's project (lesson 8) inherits this same, already-migrated ci.yml, and adds the document of what a complete jump to OIDC would take in a real deployment.


Step 1 — The before: confirm the antipattern, running

Before changing anything, confirm the ci.yml inherited from Module 3 still runs exactly as you left it. On andes-cargo-infra/:

act pull_request -e .github/act-events/pr-event.json -j terraform-checks --secret-file .secrets

What to expect (literal excerpt, executed to write this lesson — the fmt, init, and validate steps run successfully, the LocalStack connection fails honestly, and the plan completes anyway):

[ci/terraform-checks]   ✅  Success - Main Terraform format check [131.9725ms]
[ci/terraform-checks] ⭐ Run Main Terraform init
[ci/terraform-checks]   ✅  Success - Main Terraform init [15.252881667s]
[ci/terraform-checks] ⭐ Run Main Terraform validate
[ci/terraform-checks]   ✅  Success - Main Terraform validate [1.748320208s]
[ci/terraform-checks] ⭐ Run Main Install awslocal
[ci/terraform-checks]   ✅  Success - Main Install awslocal [12.325428291s]
[ci/terraform-checks] ⭐ Run Main Confirm the runner can reach LocalStack on the host
[ci/terraform-checks]   | 
[ci/terraform-checks]   | Could not connect to the endpoint URL: "http://host.docker.internal:4566/"
[ci/terraform-checks] Failed but continue next step
[ci/terraform-checks]   ❌  Failure - Main Confirm the runner can reach LocalStack on the host [4.3284415s]
[ci/terraform-checks] ⭐ Run Main Install tflocal
[ci/terraform-checks]   ✅  Success - Main Install tflocal [2.5084035s]
[ci/terraform-checks] ⭐ Run Main Terraform plan
[ci/terraform-checks]   | Plan: 12 to add, 0 to change, 0 to destroy.
[ci/terraform-checks]   ✅  Success - Main Terraform plan [4.300556083s]
[ci/terraform-checks] ⭐ Run Main Publish the plan to the job summary
[ci/terraform-checks]   ✅  Success - Main Publish the plan to the job summary [60.103291ms]
[ci/terraform-checks] 🏁  Job succeeded

Two honest things to read carefully before continuing. First: the "Confirm the runner can reach LocalStack" step fails with Could not connect to the endpoint URL — the same error, for the same reason, you already saw in Module 2, lesson 8: no LocalStack is running on the host at this moment (with no LOCALSTACK_AUTH_TOKEN exported, the container doesn't start). That step's continue-on-error: true, already present in ci.yml since Module 3, is exactly what lets the job move forward instead of stopping there. Second, the most important one for this lesson: terraform plan (the next step) does succeed, with Plan: 12 to add, 0 to change, 0 to destroy — because, against an empty state, generating a creation plan needs no real network call to AWS or LocalStack at all; the provider already has everything it needs locally (remember skip_requesting_account_id = true in providers.tf, inherited from terraform-and-iac-guide). The plan is real and correct; the connectivity check, separately, honestly reflects that LocalStack isn't up right now.


Step 2 — Migrate ci.yml's env: block

Open .github/workflows/ci.yml. The block you're going to change is exactly the one this module's lesson 1 flagged:

    env:
      AWS_ACCESS_KEY_ID: test
      AWS_SECRET_ACCESS_KEY: test
      AWS_DEFAULT_REGION: us-east-1
      AWS_ENDPOINT_URL: http://host.docker.internal:4566

Replace the first two lines with lesson 3's secrets.<NAME> syntax:

    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
      AWS_ENDPOINT_URL: http://host.docker.internal:4566

Notice what did not change: AWS_DEFAULT_REGION and AWS_ENDPOINT_URL are still written directly — they're not credentials, they're network and region configuration, values that pose no exposure risk even if anyone reads them. Migrating to secrets.<NAME> is specific to the two lines that are actual credentials; unnecessarily turning non-sensitive configuration into a Secret would only add friction with no real security benefit.

The .secrets already existing at andes-cargo-infra/'s root since Module 2, lesson 7, already has exactly the right names:

AWS_ACCESS_KEY_ID=test
AWS_SECRET_ACCESS_KEY=test

No need to create or modify .secrets — the name to the left of each = already matches, letter for letter, the name inside secrets.<NAME> in the YAML you just wrote. It's the same exact-match requirement Module 2, lesson 7, and this module's lesson 3 both warned about.


Step 3 — The after: run it again, and confirm the behavior didn't change

act pull_request -e .github/act-events/pr-event.json -j terraform-checks --secret-file .secrets

What to expect (literal excerpt, executed to write this lesson):

[ci/terraform-checks]   ✅  Success - Main Terraform format check [125.128ms]
[ci/terraform-checks] ⭐ Run Main Terraform init
[ci/terraform-checks]   ✅  Success - Main Terraform init [15.666698709s]
[ci/terraform-checks] ⭐ Run Main Terraform validate
[ci/terraform-checks]   ✅  Success - Main Terraform validate [1.754935459s]
[ci/terraform-checks] ⭐ Run Main Install awslocal
[ci/terraform-checks]   ✅  Success - Main Install awslocal [9.942404542s]
[ci/terraform-checks] ⭐ Run Main Confirm the runner can reach LocalStack on the host
[ci/terraform-checks]   | 
[ci/terraform-checks]   | Could not connect to the endpoint URL: "http://host.docker.internal:4566/"
[ci/terraform-checks] Failed but continue next step
[ci/terraform-checks]   ❌  Failure - Main Confirm the runner can reach LocalStack on the host [8.658047584s]
[ci/terraform-checks] ⭐ Run Main Install tflocal
[ci/terraform-checks]   ✅  Success - Main Install tflocal [2.438441625s]
[ci/terraform-checks] ⭐ Run Main Terraform plan
[ci/terraform-checks]   | Plan: 12 to add, 0 to change, 0 to destroy.
[ci/terraform-checks]   ✅  Success - Main Terraform plan [4.307120666s]
[ci/terraform-checks] ⭐ Run Main Publish the plan to the job summary
[ci/terraform-checks]   ✅  Success - Main Publish the plan to the job summary [66.274334ms]
[ci/terraform-checks] 🏁  Job succeeded

Identical at every step that matters — same steps, same Plan: 12 to add, 0 to change, 0 to destroy, same final Job succeeded. Each step's timing varies a few milliseconds between runs (normal, every container run has minor performance variation), but the structure, the results, and the plan's content are exactly the same. This is, with real output, lesson 3's proof: act --secret-file .secrets delivers secrets.AWS_ACCESS_KEY_ID's value indistinguishably from having it written directly — the only difference is where that value lives, never written into the file Git tracks.


The version you'd see with a real LOCALSTACK_AUTH_TOKEN (representative)

With LocalStack really running on the host (Module 1, lesson 8, Step 5), the "Confirm the runner can reach LocalStack on the host" step would go from ❌ Failure to ✅ Success, returning the same identity JSON already confirmed in this ecosystem's previous guides:

What to expect (representative — same format already confirmed in this guide's Module 1 and Module 2; no live run against a valid token at this moment):

{
    "UserId": "AKIAIOSFODNN7EXAMPLE",
    "Account": "000000000000",
    "Arn": "arn:aws:iam::000000000000:root"
}

Not a single line of the ci.yml migrated in this lesson would need to change to produce this output — the difference between the real failure you saw above and this representative result is exclusively whether LocalStack is up on the other side of host.docker.internal:4566, exactly the same distinction Module 2, lesson 8, already explained.


Step 4 — Commit the migration

git add -A
git commit -m "Migrate ci.yml credentials from hardcoded values to secrets.AWS_ACCESS_KEY_ID / secrets.AWS_SECRET_ACCESS_KEY"
git log --oneline -3

What to expect (representative for the hashes, literal for the structure):

a1b2c3d Migrate ci.yml credentials from hardcoded values to secrets.AWS_ACCESS_KEY_ID / secrets.AWS_SECRET_ACCESS_KEY
7b6421e ci.yml: publish the plan to the job summary
dfcbfce ci.yml: install tflocal and run terraform plan

Confirm, one last time, that .secrets still doesn't show up anywhere in the history:

git log --all --full-history -- .secrets

What to expect (literal) — no output, no commit found, because .secrets never stopped being gitignored since the exact moment it was created in Module 2:

(no output)

Common mistakes

Migrating AWS_DEFAULT_REGION or AWS_ENDPOINT_URL to Secrets too, "to be consistent" (scope-based). What happens: someone, with the habit freshly installed, also turns the region and the endpoint into Secrets, even though they're not credentials. Why it happens: after learning the rule "credentials go in Secrets," it's easy to overgeneralize to "everything in env: should be a Secret." How to spot it: if your .secrets has lines like AWS_DEFAULT_REGION=us-east-1, which aren't secret at all. How to fix it: a Secret exists to protect a value whose exposure poses a real risk. us-east-1 or http://host.docker.internal:4566 pose no risk if anyone reads them — turning them into Secrets only adds a layer of indirection with no real security benefit, and makes reading ci.yml to understand which region or endpoint it uses harder, not easier.

Expecting the migration to change the plan's result (expectation-based). What happens: someone, after running the "before" and the "after," notices the plan's numbers are identical and wonders if something went wrong, because they expected to see a difference confirming "the change did something." Why it happens: it's intuitive to expect a code change to produce a visibly different result. How to spot it: if you're looking for a difference in Plan: 12 to add, 0 to change, 0 to destroy between this lesson's two runs. How to fix it: the identical result is the proof the change worked correctly — moving a credential from one place to another, without changing its value, should never change what Terraform plans to do. If the plan had changed, that would have been the signal something went wrong in the migration, not that it went right.


Exercises

Exercise 1 — Break the migration on purpose, and diagnose the error. Intentionally change the name of one of the keys in .secrets (for example, from AWS_ACCESS_KEY_ID to AWS_ACCES_KEY_ID, with a typo) and run Step 3's command again. What changes in the output?

See solution

The job keeps running to the end —terraform plan with Plan: 12 to add, 0 to change, 0 to destroy— because, as you saw in Step 1, generating a plan against empty state needs no real credential to succeed. What would change, if you had a real LOCALSTACK_AUTH_TOKEN and LocalStack running, is the connectivity step: secrets.AWS_ACCESS_KEY_ID would resolve to an empty string (because .secrets no longer has any line with that exact name), and awslocal sts get-caller-identity would fail with a missing-credentials error instead of —or in addition to— any connection error. This is exactly the syntax mistake lesson 3 warned about: the name to the left of = in .secrets has to match, letter for letter, the name inside secrets.<NAME>.

Exercise 2 — Explain why this lesson doesn't completely solve the antipattern. A colleague, after seeing the migrated ci.yml, says: "great, the security problem VALIDACION mentioned is now solved." Do you agree? Answer in two or three sentences.

See solution

A complete answer sounds, roughly, like this: "Partially — this migration solves the problem of the credential being written in a file Git tracks, which is a real, correct step. But secrets.AWS_ACCESS_KEY_ID is still, technically, a long-lived credential stored somewhere (the local .secrets, or a real GitHub Secret in a real repository) — just better protected. The antipattern's complete resolution, the one VALIDACION cites, is OIDC (lesson 5), where there's no long-lived credential to store at all. This lesson practices the right intermediate step, not the final destination."

Exercise 3 — Predict the behavior with no explicit --secret-file. Based on what you learned in Module 2, lesson 7, about act's default behavior, predict what would happen if you ran Step 3's command without the --secret-file .secrets flag, and why.

See solution

The result would be identical to what this lesson shows — act looks, by default, for a file named exactly .secrets in the current working directory, even without the flag passed explicitly (confirmed in Module 2, lesson 7, against act --help). This lesson's correct behavior —still writing --secret-file .secrets explicitly— doesn't change the result, but it does document the intent directly in the command, instead of depending on whoever runs it knowing an implicit act convention from memory.


Summary and next step

In this lesson you migrated Andes Cargo's real ci.yml, with literal act output before and after the change, confirming that moving the credentials from directly written values to secrets.AWS_ACCESS_KEY_ID/secrets.AWS_SECRET_ACCESS_KEY —read from .secrets with act --secret-file— doesn't change the pipeline's result, only where the credential lives. You saw, again, the honest LocalStack connectivity failure (from not having a token exported in this session) and the real plan completing anyway, with no need for that connection. And you acknowledged this improvement's limit: it's still a long-lived credential, acceptable only because the destination is LocalStack.

Before moving on you should be able to: migrate any credentials env: block to the secrets.<NAME> syntax; explain why Step 1's plan succeeds despite the connectivity failure; and explain, without hesitation, why this migration isn't the same as completely solving the antipattern.

This module's project (lesson 8) inherits this already-migrated ci.yml, and adds the document —without building it— of what secret, what environment, and what OIDC role Andes Cargo would use in a real deployment against a genuine AWS account.

Resources

  1. nektosact.com — User Guide — complete reference for act --secret-file, used in this lesson.
  2. GitHub Docs — Using secrets in GitHub Actions — the real Secrets model, already cited in lesson 3.
  3. This guide's Module 3, lesson 8 (08-project-andes-cargos-ci-workflow.md) — the original ci.yml this lesson migrates, with the fmt/validate/plan steps that don't change.