Module 2: Anatomy Of A Github Actions Workflow
8. Project: Andes Cargo's first real workflow
Description
This is the project that closes Module 2. Everything before it —the complete anatomy (lesson 2), the triggers (lessons 3 and 4), reusable Actions (lesson 5), hand-written events (lesson 6), and gitignored secrets (lesson 7)— converges here in hello-andes-cargo.yml: this guide's first workflow that genuinely attempts to touch something related to Andes Cargo's infrastructure, not a disposable example. It doesn't run terraform plan yet —that's Module 3— but it does confirm something that module is going to need without exception: that the ephemeral container where an act job runs can reach the LocalStack running, separately, on your host machine.
Connection to the module
This lesson uses, unchanged, actions/checkout@v4 (lesson 5), runs with act push (lesson 3), and inherits the .actrc and andes-cargo-infra/ structure you built in lessons 6 and 7. It's this guide's first time a workflow needs networking between two distinct Docker containers —the act job's and LocalStack's— the architecture problem this guide's design researched and solved with host.docker.internal. Module 3 picks this same network connection back up, now for a real terraform plan.
The workflow: hello-andes-cargo.yml
.github/workflows/hello-andes-cargo.yml, inside andes-cargo-infra/:
name: hello-andes-cargo
on: push
jobs:
say-hello-to-localstack:
runs-on: ubuntu-latest
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
steps:
- name: Check out andes-cargo-infra
uses: actions/checkout@v4
- name: Install awslocal
run: pip3 install --quiet --break-system-packages awscli awscli-local
- name: Confirm the runner can reach LocalStack on the host
run: awslocal sts get-caller-identity
Three new pieces worth naming before running it:
AWS_ENDPOINT_URL: http://host.docker.internal:4566—host.docker.internalis a special DNS name Docker resolves, from inside a container, to the machine hosting it —your host. It's the exact piece that connects the job's ephemeral container (where this workflow runs) with LocalStack's container (which you ran, separately, on your host, following Module 1's same pattern). Without this endpoint,awslocalwould try to connect tolocalhost:4566inside the job's container — and there's nothing listening there, because LocalStack runs in a completely different container.--container-options "--add-host=host.docker.internal:host-gateway", already added to your.actrc(second line, alongside Module 1's pinned image) — necessary forhost.docker.internalto resolve reliably on any system, including Linux (on macOS Docker Desktop it usually resolves without this flag, but this guide pins it explicitly from the start to not depend on that platform difference). Module 3, lesson 5, returns to this mechanism with much more detail — for now, it's enough to know it exists and why.- Installing
awslocalon every run — thecatthehacker/ubuntu:act-latestimage (Medium, the one you chose in Module 1) doesn't ship the AWS client or its LocalStack wrapper preinstalled; like any real, ephemeral runner, every run starts "clean," so the workflow itself has to install what it needs, every time.
Running it: the half that DOES succeed
act -l
What to expect (literal output, executed to write this lesson — with the four workflows you built in this guide so far):
Stage Job ID Job name Workflow name Workflow file Events
0 say-hello-to-localstack say-hello-to-localstack hello-andes-cargo hello-andes-cargo.yml push
0 print-payload print-payload print-event-payload print-event.yml pull_request
0 check-secrets check-secrets secrets-test secrets-test.yml workflow_dispatch
0 check-one-secret check-one-secret single-secret-test single-secret-test.yml workflow_dispatch
act push -j say-hello-to-localstack
What to expect (literal output, executed to write this lesson; the first two steps run successfully):
[hello-andes-cargo/say-hello-to-localstack] ⭐ Run Set up job
[hello-andes-cargo/say-hello-to-localstack] 🚀 Start image=catthehacker/ubuntu:act-latest
[hello-andes-cargo/say-hello-to-localstack] ✅ Success - Set up job
[hello-andes-cargo/say-hello-to-localstack] ⭐ Run Main Check out andes-cargo-infra
[hello-andes-cargo/say-hello-to-localstack] 🐳 docker cp src=/path/to/andes-cargo-infra/. dst=/path/to/andes-cargo-infra
[hello-andes-cargo/say-hello-to-localstack] ✅ Success - Main Check out andes-cargo-infra [26.322666ms]
[hello-andes-cargo/say-hello-to-localstack] ⭐ Run Main Install awslocal
[hello-andes-cargo/say-hello-to-localstack] | 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
[hello-andes-cargo/say-hello-to-localstack] ✅ Success - Main Install awslocal [9.777224542s]
[hello-andes-cargo/say-hello-to-localstack] ⭐ Run Main Confirm the runner can reach LocalStack on the host
[hello-andes-cargo/say-hello-to-localstack] | Could not connect to the endpoint URL: "http://host.docker.internal:4566/"
[hello-andes-cargo/say-hello-to-localstack] ❌ Failure - Main Confirm the runner can reach LocalStack on the host [8.799492333s]
[hello-andes-cargo/say-hello-to-localstack] exitcode '255': failure
[hello-andes-cargo/say-hello-to-localstack] 🏁 Job failed
Error: Job 'say-hello-to-localstack' failed
This is a real failure, and it's exactly the right result for this moment — read it carefully, because there are two different things mixed into that single red line: one that worked, and one that didn't, for a reason that has nothing to do with act or with the workflow.
Reading the failure: the part that DID work
The awslocal sts get-caller-identity command took 8.8 seconds to fail — it didn't fail instantly. That detail is proof that the network connection did work: host.docker.internal correctly resolved to your host's IP (if DNS had failed, the error would have been instant, something like "cannot resolve host name" — not what you saw). The AWS client tried to connect to your host's port 4566, retried according to its default retry policy (the reason for the ~8.8 seconds, not a coincidence), and finally reported Could not connect to the endpoint URL — the exact message for "I reached the door, but there's nobody on the other side," not "I couldn't find the building."
Confirm this yourself, with a direct test independent of the workflow:
docker ps -a --filter name=localstack_main
What to expect (literal, in this run):
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
No rows — LocalStack isn't running. The exact reason, you already know it from Module 1, lesson 8: without LOCALSTACK_AUTH_TOKEN exported with a valid value, LocalStack's container starts and shuts down within seconds with the license error (exit code 55). Reproduced again, today, exactly like in Module 1:
LocalStack version: 2026.7.3
LocalStack build date: 2026-08-12
LocalStack build git hash: 8f10c66d8
Localstack returning with exit code 55. Reason:
===============================================
License activation failed! 🔑❌
Reason: No credentials were found in the environment. Please make sure to either set the LOCALSTACK_AUTH_TOKEN variable to a valid auth token.
This workflow's failure isn't a mistake in this lesson, in act, or in the YAML — it's the direct, expected consequence of LocalStack not running, because you don't have a token exported in this session. The step that tries to reach LocalStack really ran, made a real network attempt, with a real timeout — and failed for the right, honest reason: there's nothing listening on the other side, not because the path is broken.
The version you'd see with a valid token (representative)
With LOCALSTACK_AUTH_TOKEN correctly exported before starting LocalStack's container —following Module 1, lesson 8, Step 5 exactly— that same awslocal sts get-caller-identity, run inside the same act job, would return:
What to expect (representative — same format already confirmed, twice, in this ecosystem's previous guides; no live run against a valid token at this moment):
{
"UserId": "AKIAIOSFODNN7EXAMPLE",
"Account": "000000000000",
"Arn": "arn:aws:iam::000000000000:root"
}
"Account": "000000000000" — the same fixed account ID as always. The difference between this output and the real error above isn't about networking, nor about the workflow's configuration — it's exclusively about whether LocalStack is running or not on the other side of host.docker.internal:4566. If you have a valid token, export LOCALSTACK_AUTH_TOKEN and start LocalStack (Module 1, lesson 8, Step 5) before repeating act push -j say-hello-to-localstack — you should see exactly this JSON in your terminal, instead of the connection error.
Committing the project
git add -A
git commit -m "Add hello-andes-cargo.yml: first workflow reaching for LocalStack via host.docker.internal"
git log --oneline
What to expect (representative for the commit hashes, literal for the structure — four commits, one for each piece this module added to andes-cargo-infra/):
5094200 Add hello-andes-cargo.yml: first workflow reaching for LocalStack via host.docker.internal
4e4f5c5 Add .secrets (gitignored) and secrets-test workflows for act --secret-file / -s
b42e54d Add pr-event.json and print-event.yml to practice act -e
ce6efa5 Bootstrap CI/CD layer: initialize git repository, .actrc, and .github/workflows/
Closing Module 2
You completed the module that teaches you to read and write a workflow with judgment. Review what you're taking with you:
- The complete anatomy, dissected on a real file and run, not just read:
onas the trigger condition,jobs/stepsas the work structure,runs-onas the destination,uses/withas reuse with parameters, andenvat its three levels (lesson 2). - An infrastructure pipeline's four triggers —
push,pull_request,workflow_dispatch,schedule— with a verified, honest finding:actdoesn't evaluatebranches:/paths:before running a job (lessons 3 and 4). - Versioning Actions by SHA vs. tag, with two real SHAs verified against GitHub's API, and why this is the most-cited-as-absent security practice in the competition (lesson 5).
- Complete simulation with no GitHub account: hand-written events with
act -e(lesson 6), and secrets passed without ever committing them (lesson 7). - Andes Cargo's first real workflow, with an honest, explained —not hidden— failure that confirms exactly what it needed to confirm: the network path to LocalStack exists and works, even though LocalStack itself isn't running at this moment.
What comes next
Module 3 takes this same andes-cargo-infra/ and builds half of the pipeline's CI: HashiCorp/GitHub's standard fmt/validate/plan pattern, run automatically on every Pull Request. You're going to install Terraform inside an ephemeral runner with hashicorp/setup-terraform@v3 —the Action you already know from this module's lesson 5— you're going to connect that runner to the same LocalStack through the same host.docker.internal you just used here, and you're going to close the module with ci.yml, the first workflow that runs a real terraform plan, inside CI, against Andes Cargo's infrastructure.
Common mistakes
Thinking this lesson's failure means something's wrong with the YAML (expectation-based, the most important one in this project). What happens: someone sees Job failed in red and reflexively assumes there's a syntax or logic error in hello-andes-cargo.yml. Why it happens: a red job, in most programming contexts, means "something you wrote is wrong." How to spot it: if your first instinct is to check the YAML for a typo, instead of checking whether LocalStack is running. How to fix it: the exact message Could not connect to the endpoint URL, after several seconds of trying (not instant), is the specific signature of "the network path works, but there's nothing on the other side" — not a workflow configuration error. docker ps -a --filter name=localstack_main is always the right first diagnostic command in this scenario.
Exporting LOCALSTACK_AUTH_TOKEN in the terminal, but forgetting act runs in a separate container (variable-scope-based). What happens: someone exports the token in their shell, starts LocalStack correctly, and keeps seeing the same connection error in the workflow. Why it happens: exporting a variable in your terminal makes it available to commands you run from that same terminal —like LocalStack's own docker run— but doesn't automatically inject it inside the ephemeral container act creates for the job, which has its own isolated environment. How to spot it: docker ps shows LocalStack running (STATUS: Up), but the workflow keeps failing anyway. How to fix it: in this specific case there's no need to pass LocalStack's token to the job — the job only needs dummy AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY (already in the workflow's env:) to talk to LocalStack, never LOCALSTACK_AUTH_TOKEN, which is exclusive to LocalStack's own container, started separately on your host.
Forgetting --container-options in .actrc and seeing a different DNS resolution error (configuration-based). What happens: on a system where host.docker.internal doesn't resolve automatically (typically Linux without Docker Desktop), without the --container-options "--add-host=host.docker.internal:host-gateway" line in .actrc, the error would change from "can't connect" to something like "can't resolve host name" — a DNS failure, not a connection one. How to spot it: if the error explicitly mentions being unable to resolve host.docker.internal, instead of the connection failing after several seconds of trying. How to fix it: confirm your .actrc has both lines —Module 1's pinned image, and this lesson's --container-options— before continuing; Module 3, lesson 5, goes deeper into this mechanism if you need the full detail.
Exercises
Exercise 1 — Diagnose the failure like someone new to this lesson would. A colleague, without having read this lesson, shows you the same Could not connect to the endpoint URL: "http://host.docker.internal:4566/" error and asks whether their workflow is broken. Answer them with the correct diagnosis, in three steps.
See solution
Step 1: check how long it took to fail — if it was several seconds (not instant), the network probably works; if it was instant, suspect a DNS problem (missing --container-options). Step 2: run docker ps -a --filter name=localstack_main — if no row shows up, or if it shows up with STATUS: Exited, LocalStack isn't running, which is this exact error's most common cause. Step 3: if LocalStack does show up as Up, check that the workflow's AWS_ENDPOINT_URL points exactly at http://host.docker.internal:4566 — a typo there (for example, localhost:4566, which doesn't resolve inside the job's container) would produce the same kind of error.
Exercise 2 — Explain why the 8.8 seconds matter. Without looking at this lesson, explain in one sentence why the time the step took to fail (~8.8s, not instant) is real diagnostic information, not an unimportant detail.
See solution
The wait time reveals at which layer the connection failed: an instant failure usually means the host name didn't even resolve (DNS failure, name-resolution layer); a failure after several seconds —the time the AWS client spent retrying before giving up— means the name did resolve and the client did try connecting to a real port, only there was no service listening there. They're two different problems, with two different fixes, and the wait time is the clue that tells them apart without needing any additional command.
Exercise 3 — Recreate the success, if you have a LocalStack token. If you have access to a valid LocalStack token, export LOCALSTACK_AUTH_TOKEN, start the container following Module 1/lesson 8, Step 5, and run act push -j say-hello-to-localstack again. What exactly changes in the output, and what stays identical?
See solution
What changes: the last step goes from ❌ Failure to ✅ Success, and instead of the Could not connect to the endpoint URL message, you should see the identity JSON (UserId, Account: "000000000000", Arn) — the same format you already saw, representative, in this lesson and in this ecosystem's two previous guides. What stays identical: the workflow's YAML doesn't change a single line, the "Install awslocal" time stays similar, and AWS_ENDPOINT_URL still points at exactly the same host.docker.internal:4566 — the only difference between failure and success is whether there's a real LocalStack listening on the other side, nothing on the workflow's side or act's.
Summary and next step
In this project you built hello-andes-cargo.yml, this guide's first workflow that tries to touch something related to Andes Cargo — and you saw, with honest output explained in detail, exactly where and why it fails without a LocalStack token: the network path (host.docker.internal) works, LocalStack simply isn't running. You confirmed, with docker ps, that the cause is exactly the expected one —not a workflow error— and you saw the representative result you'd get with a valid token.
Before moving on you should be able to: explain what host.docker.internal is and why the workflow needs it; diagnose, in fewer than three commands, whether an awslocal failure inside an act job is a networking problem or LocalStack being off; and say from memory the four pieces this module added to andes-cargo-infra/ (extended .actrc, .github/act-events/pr-event.json, gitignored .secrets, hello-andes-cargo.yml).
With this, Module 2 is closed. You have a workflow's complete anatomy, the mechanics of simulating events and secrets, and the first real connection attempt toward Andes Cargo.
Next module: half of the pipeline's CI — fmt, validate, and plan running automatically on every Pull Request, with Terraform installed inside an ephemeral runner, and this lesson's same host.docker.internal, now carrying a real terraform plan to LocalStack.
Resources
- nektosact.com — User Guide — complete reference for
act -landact push, used in this project. - Docker Docs — Networking: use cases for host.docker.internal — official documentation for the networking mechanism connecting the
actjob with LocalStack. - LocalStack Docs — Auth Token — the source of the license error reproduced in this lesson.
terraform-and-iac-guide, Module 1, lesson 5 (NIEVA) — LocalStack's clean startup, assumed and not repeated here.