Module 3: The Iac Pipeline Fmt Validate Plan
5. Connecting the runner to LocalStack through the host
Description
fmt and validate —lesson 4— never talk to any real provider: they work exclusively on the HCL's text. terraform plan —lesson 6— is different: to precisely calculate what would change, it needs to be able to reach the same LocalStack running on your host. This lesson builds and verifies that network connection, before trusting it for a real plan: you extend .actrc with the flag you already used in Module 2, add a step that tries to talk to LocalStack (awslocal s3 ls) inside the same ci.yml job, and read, with complete honesty, what happens when that attempt runs into a LocalStack that isn't running — the exact same pattern you already lived through in Module 2, lesson 8.
Connection to the module
Lesson 4 left ci.yml with two steps that never need networking. This lesson adds the third, which does need it, and verifies the path before lesson 6 depends on it for something more important than an s3 ls. Lesson 6 is also going to show you a second side of this story: why Andes Cargo's specific terraform plan doesn't, in this particular case, need this connection to be working — a real nuance that only makes sense after seeing, here, why it would normally be needed.
Analogy: testing the cable before trusting the appliance
Before plugging something important —a refrigerator loaded with groceries, not a desk lamp— into a new power outlet, any sensible electrician tests first with something simple and cheap: a tester, a hand lamp. If the tester doesn't light up, better to find out there, not after loading the refrigerator. awslocal s3 ls is that tester: a read-only command, with no cost if it fails, that confirms whether the cable —the network path between the job's container and LocalStack— is alive, before you trust it for something with more weight, like lesson 6's terraform plan.
Step 1 — .actrc, with the networking piece
Your .actrc, at andes-cargo-infra/'s root, already has this second line since Module 2 (lesson 8) — confirm it:
cat .actrc
What to expect (literal, inherited, unchanged in this lesson):
-P ubuntu-latest=catthehacker/ubuntu:act-latest
--container-options "--add-host=host.docker.internal:host-gateway"
The second line is what makes everything that follows possible: it tells act that, when creating each job's container, it should add a DNS entry resolving host.docker.internal to the host's gateway —necessary on Linux; on macOS/Windows Docker Desktop it usually resolves without this flag, but this guide sets it explicitly to not depend on that platform difference. Without this line, the name host.docker.internal simply wouldn't resolve inside the job's container, and the error you'd see below would be a DNS one ("can't resolve the name"), not a connection one ("resolved the name, but nobody answers") — a distinction you already saw in Module 2, lesson 8, and that you're going to confirm again in this lesson.
Step 2 — A step that confirms the path, without blocking the rest of the job
Add two steps to ci.yml, after Terraform validate:
- name: Install awslocal
run: pip3 install --quiet --break-system-packages awscli awscli-local
- name: Confirm the runner can reach LocalStack on the host
continue-on-error: true
run: awslocal s3 ls
Notice continue-on-error: true — a step field you haven't seen until now. It tells act (and real GitHub Actions): "if this step fails, mark the failure, but keep running the rest of the job, don't stop it." It's a deliberate decision in this lesson, and it's worth explaining why before running it: this step depends on LocalStack being up and licensed —something outside Terraform's or act's control— so a failure here shouldn't block fmt/validate (which already passed) nor, as you're going to confirm in lesson 6, the plan that follows. It's a diagnostic, not a gatekeeper.
Step 3 — Running the complete job, with LocalStack off
First confirm you don't have LocalStack running at this moment —this lesson's default state, same as in Module 2, lesson 8:
docker ps -a --filter name=localstack_main
What to expect (literal, with no rows — no LocalStack container running):
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
Now run the complete job:
act pull_request -e .github/act-events/pr-event.json -j terraform-checks
What to expect (literal output, executed to write this lesson — notice the exact time the failing step takes, and that the complete job still finishes green):
[ci/terraform-checks] ⭐ Run Main Terraform validate
[ci/terraform-checks] | Success! The configuration is valid.
[ci/terraform-checks] ✅ Success - Main Terraform validate [1.800934209s]
[ci/terraform-checks] ⭐ Run Main Install awslocal
[ci/terraform-checks] 🐳 docker exec cmd=[bash -e /var/run/act/workflow/5] user= workdir=
[ci/terraform-checks] | WARNING: Running pip as the 'root' user can result in broken permissions and conflicting behaviour with the system package manager. It is recommended to use a virtual environment instead: https://pip.pypa.io/warnings/venv
[ci/terraform-checks] ✅ Success - Main Install awslocal [11.648304875s]
[ci/terraform-checks] ⭐ Run Main Confirm the runner can reach LocalStack on the host
[ci/terraform-checks] 🐳 docker exec cmd=[bash -e /var/run/act/workflow/6] user= workdir=
[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 [11.315293s]
[ci/terraform-checks] ⭐ Run Complete job
[ci/terraform-checks] ✅ Success - Complete job
[ci/terraform-checks] 🏁 Job succeeded
This is a real, honest failure, and exactly what you'd expect with no LocalStack running — read it with the same care as in Module 2, lesson 8, because the reading is identical.
Reading the failure: what DID work, and what didn't
The step took 11.3 seconds to fail — it didn't fail instantly. That time is, again, proof that the piece this lesson builds —.actrc with --container-options, host.docker.internal in the job's env— does work: the name resolved correctly to your host, and awslocal really tried connecting to port 4566, retrying per its default policy (the reason for the ~11 seconds, not a coincidence), before reporting Could not connect to the endpoint URL — the specific message for "I reached the door, but there's nobody on the other side," not "I couldn't find the street." If --container-options were missing from .actrc, or if the job's env pointed somewhere wrong, you'd see a completely different error: a DNS resolution one, typically instant, not after several seconds of retrying.
Confirm it independently, same as in Module 2:
docker ps -a --filter name=localstack_main
What to expect (literal): no rows — LocalStack isn't running, this failure's exact reason, with no connection to act, to .actrc, or to ci.yml's YAML.
And notice the block's last line above: 🏁 Job succeeded. Even though Confirm the runner can reach LocalStack on the host failed, the complete job finished green — exactly what continue-on-error: true promises: the failure gets recorded (on real GitHub, it would show up as a warning mark on that specific step, without turning the whole job red), but nothing else stops.
The version you'd see with a valid token (representative)
With LOCALSTACK_AUTH_TOKEN correctly exported and LocalStack started on your host —following Module 1, lesson 8, Step 5— that same awslocal s3 ls, run inside the same act job, would return:
What to expect (representative — same format already confirmed, twice, in this guide's Module 1 and Module 2; no live run against a valid token at this moment):
2026-08-10 12:00:00 andes-cargo-shipment-docs
A single row, the name of the bucket terraform-and-iac-guide already declared and applied in its capstone —if your LocalStack retains that state from an earlier session with the right plan, or an empty list if it's a fresh session with no apply run yet. The difference between this and the error above isn't about the workflow's configuration or act's — it's exclusively about whether LocalStack is running on the other side of host.docker.internal:4566.
Going deeper: why this step doesn't block, but is still valuable
It's worth being precise about this step's role within the complete pipeline. It isn't a check that guarantees lesson 6's plan is going to work —in fact, you're going to discover an interesting nuance in that lesson: Andes Cargo's specific plan, against a completely empty state, doesn't need this connection to correctly calculate what to create. So what's it for?
It serves as an early, cheap diagnostic of an infrastructure problem in the pipeline itself —not in the Andes Cargo project. The day this project has a data source that does need to read something real from AWS/LocalStack (for example, if someone in the future adds data "aws_s3_bucket" "existing" to reference a bucket created outside of Terraform), that plan is going to depend on this network path working — and if that day comes and the connection is broken, you'd rather find out in an 11-second step marked as a warning, not in a complete plan that fails with no clear clues why. continue-on-error: true is exactly the right balance: visible information, without blocking a pipeline that, today, doesn't depend on it to succeed.
Common mistakes
Removing continue-on-error: true and having the whole job fail unnecessarily (configuration-based). What happens: someone copies this step without the continue-on-error field, and discovers the complete ci.yml turns red every time it runs without LocalStack active —even when lesson 6's plan wouldn't need that connection to succeed. How to spot it: the job fails exactly at the awslocal step, and no later step runs. How to fix it: confirm continue-on-error: true is present, at the same indentation as run:, inside the same step.
Interpreting Failed but continue next step as a real error message (reading-based). What happens: someone sees that line in act's output and reads it as an additional problem, separate from the Could not connect to the endpoint URL above. How to spot it: the line shows up right after the real error, in a neutral tone, not with the ❌ symbol. How to fix it: it's exactly what it says — act confirming that, because of the step's continue-on-error: true, it's going to keep going with the next step instead of stopping the job. It's information about the pipeline's behavior, not a second error.
Confusing job-level AWS_ENDPOINT_URL with a variable only awslocal needs (scope-based, preview of lesson 6). What happens: someone thinks this variable is exclusive to the awslocal step, and moves it to that specific step's env instead of leaving it at the job level. How to fix it: leave it at the job level — lesson 6 reuses it for tflocal, which also reads it, and Module 2's three-level env hierarchy (lesson 2) exists exactly for this case: a variable more than one step needs.
Exercises
Exercise 1 — Diagnose without docker ps. Without running docker ps -a --filter name=localstack_main, what other piece of data in this lesson's act output lets you distinguish "LocalStack off" from "a typo in AWS_ENDPOINT_URL"?
See solution
The time it took the step to fail ([11.315293s]). A typo in AWS_ENDPOINT_URL resulting in a host name that doesn't resolve at all (for example, hostdocker.internal, missing the dot) would fail almost instantly, with a DNS resolution error — not after several seconds of retrying. The prolonged wait time is the specific signature of "the name resolved, the connection attempt was real, but nobody answered on the other side" — exactly what you learned to read in Module 2, lesson 8, and confirmed again here.
Exercise 2 — Explain continue-on-error to a colleague who's never seen it. In two sentences, without copying this lesson's definition, explain what continue-on-error: true does and why this lesson uses it specifically on the awslocal step.
See solution
A complete answer sounds, roughly, like this: "continue-on-error: true tells the engine that, if this particular step fails, mark it as failed but keep running the rest of the job anyway, instead of stopping there. This lesson uses it because the awslocal step depends on something outside the pipeline's control —whether LocalStack is running or not— and it makes no sense for that external dependency to block checks that are completely under the HCL's own control, like fmt, validate, or the plan that doesn't need this connection."
Exercise 3 — Predict a different second awslocal's behavior. If you added a second step, awslocal dynamodb list-tables, without continue-on-error: true, right after this lesson's step, and LocalStack is still off, what do you expect to happen to the complete job?
See solution
That second step would fail with the same kind of error (Could not connect to the endpoint URL, after several seconds), but this time, without continue-on-error: true, the job would stop right there — any step after that one (like lesson 6's terraform plan, if it came later in the file) wouldn't get to run, and the complete job would finish red, not green. It's the exact difference between the two behaviors you saw in this lesson and in lesson 4: a blocking step stops everything that follows; one with continue-on-error: true doesn't.
Summary and next step
In this lesson you extended ci.yml with a step that cheaply and non-blockingly tests the network path to LocalStack through host.docker.internal. You confirmed, with literal, honest output, that path works —the connection really gets attempted, it takes several seconds, and it fails because LocalStack isn't running, not because the network is misconfigured— and you saw continue-on-error: true in action: the complete job finished green despite that specific step's failure.
Before moving on you should be able to: explain what .actrc's second line does and why Linux needs it while Docker Desktop sometimes doesn't; read a connection failure's wait time as diagnostic information; and decide when a step should carry continue-on-error: true and when it shouldn't.
Lesson 6 uses this exact same connection —AWS_ENDPOINT_URL: http://host.docker.internal:4566— for this guide's first real terraform plan, and shows you a nuance that only makes sense after this lesson: why, in this particular case, that plan doesn't end up needing LocalStack to be running.
Resources
- Docker Docs — Networking: connect from a container to a service on the host — official documentation for
host.docker.internal, this lesson's central piece. - nektos/act — issue #1835 and issue #2412 — the discussion confirming the
--container-options "--add-host=host.docker.internal:host-gateway"flag used in.actrc. - GitHub Docs — Workflow syntax:
jobs.<job_id>.steps[*].continue-on-error— official reference for the field used in this lesson. - LocalStack Docs — Auth Token — the source of the license error, already seen in Module 1 and Module 2, relevant to understanding why LocalStack isn't running in this lesson.