Hands-On: Continuous Delivery to AWS Elastic Beanstalk (Free Tier)

This hands-on delivers the Python and Java apps to AWS. Each release tag builds once, publishes the artifacts to GitHub Releases, and deploys the released files to Elastic Beanstalk. A smoke test checks each environment before the release is accepted. You will roll back one environment and clean up every AWS resource at the end.

Contents
  1. Session plan and free-tier notes
  2. Starting point
  3. Phase A: AWS account, MFA, and budgets
  4. Phase B: Tools, AWS CLI, EB CLI, and GitHub CLI
  5. Phase C: Endpoint code for both apps
  6. Phase D: Deployer user and Elastic Beanstalk environments
  7. Phase E: Smoke test script
  8. Phase F: Pipelines, secrets, and environments
  9. Phase G: First release, v1.2.0
  10. Phase H: Release 1.2.1 and roll back
  11. Phase I: Cleanup
  12. Troubleshooting

Session plan and free-tier notes

TimePhaseWhat you finish
0:00 to 0:20A. AccountAWS account with MFA, an admin user, and budget alerts
0:20 to 0:35B. ToolsAWS CLI, EB CLI, GitHub CLI, and working sign-in
0:35 to 0:50C. CodeEndpoints in both apps, tests passing locally
0:50 to 1:05D. AWS resourcesDeployer user and two Elastic Beanstalk environments
1:05 to 1:15E and F. Smoke test and pipelinesRelease workflows and GitHub settings
1:15 to 1:25G. First releaseTag v1.2.0 and verify both environments
1:25 to 1:40H. Release 1.2.1 and rollbackUpgrade, then roll back one environment
1:40 to 2:00I. CleanupAll AWS resources removed and access keys deleted

Free-tier notes for AWS

Use a personal AWS account. The lab creates IAM users, access keys, public web apps, and an S3 bucket. Do not run it in a work account unless your organisation allows it.

Starting point

You need two empty GitHub repositories, python-ci-demo and java-ci-demo, each with the calculator and its unit tests. The lab adds the web endpoints, the release pipelines, and the AWS deployment. Both repositories must be public or your GitHub plan must allow Actions on private repositories, because the release bundles download the released files over HTTPS.

RepositoryAlready presentAdded in this lab
python-ci-demoapp/calculator.py, app/main.py, tests/test_calculator.py, requirements.txt with pytest, pytest-cov, flake8, pytest.ini, a green CI workflowpyproject.toml, app/web.py, tests/test_web.py, the smoke test script, the release workflow
java-ci-demoCalculator.java, App.java, CalculatorTest.java, pom.xml with the JaCoCo report, a green CI workflowServer.java, the pom changes, the smoke test script, the release workflow
Phases C and D come before the first release. The Elastic Beanstalk environments are created from the wheel and jar built on your machine, so they do not need the GitHub Release to exist yet.

Phase A: AWS account, MFA, and budgets About 20 min plus verification

Step A1 Create the account

  1. Open aws.amazon.com in a private browser window and choose Create an AWS account.
  2. Enter your personal email address and an account name such as lab-personal. Choose Verify email address and enter the six-digit code AWS emails you.
  3. Set a root password of at least 16 characters and store it in a password manager.
  4. Choose Personal as the account type. Enter your name, country, address, and phone number. Accept the customer agreement.
  5. Verify the phone by text message or voice call. Complete the CAPTCHA and enter the code.
  6. Choose Basic support (free).
  7. Enter a credit or debit card and the billing address. A small temporary authorisation is placed and reversed.
  8. Complete the identity check. If AWS calls the phone, enter the code it reads out.
  9. If the sign-up flow offers plan choices, pick the free plan with credits. Read which services it excludes. This lab uses Elastic Beanstalk, EC2, S3, CloudFormation, and IAM.
If the card is declined, check that it is not prepaid, that international or online transactions are enabled in your bank's app, and that the billing address matches the card. Some Indian-issued cards need this setting before AWS accepts them.

Step A2 Protect the root user

  1. Sign in at console.aws.amazon.com as the root user.
  2. Open the account menu in the top-right corner, choose Security credentials, then Assign MFA device. Register a passkey, a security key, or an authenticator app.
  3. On the same page, check that the access keys section is empty. Never create root access keys.

Step A3 Create the administrator user

  1. Open the IAM service and choose Users, then Create user.
  2. Name the user admin-you and tick Provide user access to the AWS Management Console. Choose a custom password and clear Users must create a new password at next sign-in.
  3. On the permissions step, choose Attach policies directly, select AdministratorAccess, and create the user.
  4. Open admin-you, choose Security credentials, then Assign MFA device, and register a passkey or an authenticator app.
  5. On the IAM dashboard, note the Account sign-in URL. Bookmark it. IAM users sign in through this URL, not through the root sign-in page.
  6. Sign out of root and sign in as admin-you with MFA. Use this user for all later work.
The admin user creates the lab's access keys and environments. The deployer user, created in Phase D, has narrower permissions and is the only identity the pipelines use.

Step A4 Let IAM users see billing, then create the budgets

  1. Sign in as root. Open the account menu, choose Account, and under IAM user and role access to Billing information, choose Edit, tick Activate IAM Access, and save. Then sign out of root.
  2. Sign in as admin-you. Open Billing and Cost Management, then Budgets, then Create budget.
  3. Choose the Monthly cost budget template, name it lab-guard, and set the budgeted amount to 10 in your currency. Add an alert at 80 percent of the budgeted amount and enter your email address.
  4. Create a second budget with the Zero spend budget template. It alerts you on any spend at all, which is the early warning you want during a lab.
  5. Open the Free Tier page from the billing menu and note the remaining credit and its expiry date, if the plan shows them.
Checkpoint: you can sign in as admin-you with MFA, and both budgets appear in the Budgets list.

Phase B: Tools, AWS CLI, EB CLI, and GitHub CLI About 15 min

Step B1 Install the tools

ToolmacOS (Homebrew)Ubuntu or DebianWindows (winget)
AWS CLI v2brew install awscliDownload the v2 bundle from the AWS install page and run its installerwinget install Amazon.AWSCLI
GitHub CLIbrew install ghFollow the GitHub CLI Linux install pagewinget install GitHub.cli
jqbrew install jqsudo apt-get install -y jqwinget install jqlang.jq
zip and unzipIncludedsudo apt-get install -y zip unzipUse Git Bash, which includes them
Python 3.11 or laterbrew install pythonsudo apt-get install -y python3 python3-venv python3-pipwinget install Python.Python.3.12
JDK 17 and Mavenbrew install openjdk@17 mavensudo apt-get install -y openjdk-17-jdk mavenwinget install EclipseAdoptium.Temurin.17.JDK and winget install Apache.Maven
Windows: open a new terminal after each installation, and run the rest of the lab in Git Bash or WSL, because the commands assume a POSIX shell.

Step B2 Verify the installations

aws --version
gh --version
jq --version
zip -h | head -1
python3 --version
java -version
mvn -version
Checkpoint: each command prints a version number.

Step B3 Install the Elastic Beanstalk CLI

python3 -m venv ~/.venvs/eb
~/.venvs/eb/bin/pip install --upgrade pip
~/.venvs/eb/bin/pip install awsebcli
~/.venvs/eb/bin/eb --version

Add the EB CLI to your path with this line in ~/.bashrc or ~/.zshrc, then open a new terminal:

export PATH="$HOME/.venvs/eb/bin:$PATH"

Step B4 Configure the admin profile

  1. Sign in to the console as admin-you. Open IAM, then Users, then admin-you, then Security credentials.
  2. Choose Create access key, select Command Line Interface (CLI), tick the confirmation box, and create the key. Copy the key ID and secret, or download the CSV. The secret is shown only once.
  3. Run aws configure --profile admin-you and enter the key ID, the secret, the region us-east-1, and the output format json.
  4. Check the identity with aws sts get-caller-identity --profile admin-you. The Arn should end in user/admin-you.
  5. Delete any CSV you downloaded.
Access keys are long-lived credentials. Never store them in a repository or a script. You will delete this key at the end of the lab in Phase I.

Step B5 Sign in to GitHub with the GitHub CLI

  1. Run gh auth login, choose GitHub.com and HTTPS, and answer yes to authenticate Git.
  2. Choose Login with a web browser, copy the one-time code, and complete the sign-in and MFA in the browser.
  3. Run gh auth refresh -s workflow if the workflow scope is missing. The lab updates workflow files and sets secrets, so it needs that scope.
  4. Confirm you can administer both repositories with gh repo view <your-user>/python-ci-demo --json viewerPermission --jq .viewerPermission. The value should be ADMIN.

Step B6 One check for all tools

echo "AWS identity: $(aws sts get-caller-identity --profile admin-you --query Arn --output text)"
echo "GitHub user:   $(gh api user --jq .login)"
echo "EB CLI:        $(~/.venvs/eb/bin/eb --version)"
Checkpoint: all three lines show your identity and version.

Phase C: Endpoint code for both apps About 15 min

Both apps get the same three endpoints: /health reports the version, /add adds two numbers, and /divide divides them. The smoke test checks all three.

Step C1 Python: packaging, web module, and tests

cd ~/work/python-ci-demo
git checkout main && git pull
git checkout -b feat/aws-endpoints
pyproject.toml
[build-system]
requires = ["setuptools>=69", "wheel"]
build-backend = "setuptools.build_meta"

[project]
name = "calc-ci-demo"
version = "1.2.0"
description = "Calculator demo for CI, artifacts, and AWS deployment"
requires-python = ">=3.11"
dependencies = [
    "flask>=3.0,<4",
    "gunicorn>=22,<23",
]

[tool.setuptools.packages.find]
include = ["app*"]
app/web.py
from importlib.metadata import PackageNotFoundError, version

from flask import Flask, abort, jsonify, request

from app.calculator import add, divide

try:
    APP_VERSION = version("calc-ci-demo")
except PackageNotFoundError:
    APP_VERSION = "dev"

app = Flask(__name__)


def _two_numbers() -> tuple[float, float]:
    try:
        return float(request.args["a"]), float(request.args["b"])
    except (KeyError, ValueError):
        abort(400, description="query parameters a and b must be numbers")


@app.get("/health")
def health():
    return jsonify(status="ok", version=APP_VERSION)


@app.get("/add")
def add_endpoint():
    a, b = _two_numbers()
    return jsonify(result=add(a, b))


@app.get("/divide")
def divide_endpoint():
    a, b = _two_numbers()
    try:
        return jsonify(result=divide(a, b))
    except ValueError as exc:
        return jsonify(error=str(exc)), 400
tests/test_web.py
from app.web import app


def client():
    app.config["TESTING"] = True
    return app.test_client()


def test_health_reports_ok():
    response = client().get("/health")

    assert response.status_code == 200
    assert response.get_json()["status"] == "ok"


def test_add_endpoint_returns_sum():
    response = client().get("/add?a=2&b=3")

    assert response.get_json() == {"result": 5.0}


def test_divide_endpoint_returns_quotient():
    response = client().get("/divide?a=10&b=4")

    assert response.get_json()["result"] == 2.5


def test_divide_by_zero_returns_400():
    assert client().get("/divide?a=1&b=0").status_code == 400


def test_non_numeric_input_returns_400():
    assert client().get("/add?a=x&b=3").status_code == 400

Append these pins to requirements.txt so CI installs the same Flask and Gunicorn versions the package declares:

flask==3.0.3
gunicorn==22.0.0
source .venv/bin/activate
pip install -r requirements.txt
pip install -e .
pytest -q
python3 -c "import tomllib; print(tomllib.load(open('pyproject.toml', 'rb'))['project']['version'])"
Checkpoint: pytest passes, and the version line prints 1.2.0.

Step C2 Java: server, manifest, and coverage exclusions

cd ~/work/java-ci-demo
git checkout main && git pull
git checkout -b feat/aws-endpoints
src/main/java/com/example/calc/Server.java
package com.example.calc;

import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpServer;
import java.io.IOException;
import java.io.OutputStream;
import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.Map;

public final class Server {

    private static final Calculator CALC = new Calculator();

    public static void main(String[] args) throws IOException {
        int port = Integer.parseInt(System.getenv().getOrDefault("PORT", "8080"));
        HttpServer server = HttpServer.create(new InetSocketAddress(port), 0);
        server.createContext("/", Server::handle);
        server.start();
        System.out.println("calc-ci-demo listening on port " + port);
    }

    static void handle(HttpExchange exchange) throws IOException {
        String path = exchange.getRequestURI().getPath();
        try {
            switch (path) {
                case "/health" -> send(exchange, 200, "{\"status\":\"ok\",\"version\":\"" + version() + "\"}");
                case "/add" -> {
                    Map<String, String> q = query(exchange);
                    send(exchange, 200, "{\"result\":" + CALC.add(intParam(q, "a"), intParam(q, "b")) + "}");
                }
                case "/divide" -> {
                    Map<String, String> q = query(exchange);
                    send(exchange, 200, "{\"result\":" + CALC.divide(intParam(q, "a"), intParam(q, "b")) + "}");
                }
                default -> send(exchange, 404, "{\"error\":\"not found\"}");
            }
        } catch (IllegalArgumentException e) {
            send(exchange, 400, "{\"error\":\"" + e.getMessage() + "\"}");
        }
    }

    static String version() {
        Package p = Server.class.getPackage();
        String v = p == null ? null : p.getImplementationVersion();
        return v == null ? "dev" : v;
    }

    static Map<String, String> query(HttpExchange exchange) {
        Map<String, String> params = new HashMap<>();
        String raw = exchange.getRequestURI().getRawQuery();
        if (raw != null) {
            for (String pair : raw.split("&")) {
                String[] kv = pair.split("=", 2);
                if (kv.length == 2) {
                    params.put(kv[0], kv[1]);
                }
            }
        }
        return params;
    }

    static int intParam(Map<String, String> params, String name) {
        String value = params.get(name);
        if (value == null) {
            throw new IllegalArgumentException("missing parameter " + name);
        }
        try {
            return Integer.parseInt(value);
        } catch (NumberFormatException e) {
            throw new IllegalArgumentException(name + " must be an integer");
        }
    }

    static void send(HttpExchange exchange, int status, String body) throws IOException {
        byte[] bytes = body.getBytes(StandardCharsets.UTF_8);
        exchange.getResponseHeaders().set("Content-Type", "application/json");
        exchange.sendResponseHeaders(status, bytes.length);
        try (OutputStream out = exchange.getResponseBody()) {
            out.write(bytes);
        }
    }
}

Make three changes in pom.xml:

  1. Set the project version: change the <version> directly under <artifactId>calc-ci-demo</artifactId> to 1.2.0.
  2. Replace the <configuration> of maven-jar-plugin with:
<configuration>
  <archive>
    <manifest>
      <mainClass>com.example.calc.Server</mainClass>
      <addDefaultImplementationEntries>true</addDefaultImplementationEntries>
    </manifest>
  </archive>
</configuration>
  1. In the jacoco-maven-plugin block, exclude both entry-point classes from coverage:
<excludes>
  <exclude>com/example/calc/App.class</exclude>
  <exclude>com/example/calc/Server.class</exclude>
</excludes>
mvn -B verify
mvn -B -DskipTests package
java -jar target/calc-ci-demo-1.2.0.jar &
sleep 2
curl -s http://127.0.0.1:8080/health
curl -s "http://127.0.0.1:8080/add?a=2&b=3"
kill %1
The server is excluded from the coverage gate because it runs forever and uses sockets. It is verified by the live smoke test in Phase G instead.
Checkpoint: BUILD SUCCESS, the local health check reports 1.2.0, and the add check returns {"result":5}.

Step C3 Commit, review, and merge both branches

cd ~/work/python-ci-demo
git add . && git commit -m "Add Flask endpoints with declared dependencies and tests"
git push -u origin feat/aws-endpoints

cd ~/work/java-ci-demo
git add . && git commit -m "Add HTTP server, version manifest, and coverage exclusions"
git push -u origin feat/aws-endpoints

Open a pull request in each repository, wait for CI to pass, and merge into main. Then return to main in both:

cd ~/work/python-ci-demo && git checkout main && git pull
cd ~/work/java-ci-demo && git checkout main && git pull

Phase D: Deployer user and Elastic Beanstalk environments About 20 min

Step D1 Create the deployer user with CLI commands

Goal: create the identity the pipelines use. It is separate from admin-you and holds only the permissions the deployment needs.

aws iam create-user --user-name ci-deployer --profile admin-you
for POLICY in AWSElasticBeanstalkFullAccess AmazonS3FullAccess AWSCloudFormationFullAccess; do
  aws iam attach-user-policy --user-name ci-deployer --policy-arn "arn:aws:iam::aws:policy/$POLICY" --profile admin-you
done
aws iam create-access-key --user-name ci-deployer --profile admin-you

Save the AccessKeyId and SecretAccessKey from the last command. Store them in a password manager for now, and enter them as GitHub secrets in Phase F. Then configure the deployer profile:

aws configure --profile ci-deployer
# Default region name: us-east-1
# Default output format: json
aws sts get-caller-identity --profile ci-deployer
Why these policies are broad. Elastic Beanstalk builds environments with CloudFormation and stores bundles in S3, so these three managed policies cover the lab. For production, replace them with a custom policy limited to one application, its environments, and one bucket. If a pipeline run reports a missing permission, the error names the action, and you add only that action.

Step D2 Build the artifacts locally

Goal: produce the Python wheel and the Java jar that the first environments run.

cd ~/work/python-ci-demo
source .venv/bin/activate
pip install --upgrade build
rm -rf dist
python -m build
ls dist/

cd ~/work/java-ci-demo
mvn -B clean package -DskipTests
ls target/calc-ci-demo-1.2.0.jar
Local artifacts are used for the first environments only. The release pipeline in Phase G builds the same files from the tagged commit and deploys those release assets, so the later deployments use the GitHub Release.

Step D3 Create the Python environment

Goal: one single-instance Python environment running the local wheel.

mkdir -p ~/work/aws-py
cp ~/work/python-ci-demo/dist/calc_ci_demo-1.2.0-py3-none-any.whl ~/work/aws-py/
printf '%s\n' "./calc_ci_demo-1.2.0-py3-none-any.whl" > ~/work/aws-py/requirements.txt
printf '%s\n' "from app.web import app as application" > ~/work/aws-py/application.py

cd ~/work/aws-py
export AWS_PROFILE=admin-you
~/.venvs/eb/bin/eb init calc-py-app --platform python-3.12 --region us-east-1
~/.venvs/eb/bin/eb create calc-py-env --single --instance_type t3.micro --timeout 20

Explanation

Step D4 Create the Java environment

Goal: one single-instance Java environment running the local jar, listening on the port the platform expects.

mkdir -p ~/work/aws-java
cp ~/work/java-ci-demo/target/calc-ci-demo-1.2.0.jar ~/work/aws-java/application.jar

cd ~/work/aws-java
export AWS_PROFILE=admin-you
~/.venvs/eb/bin/eb platform list | grep -i corretto
~/.venvs/eb/bin/eb init calc-java-app --platform corretto-17 --region us-east-1
~/.venvs/eb/bin/eb create calc-java-env --single --instance_type t3.micro --timeout 20
~/.venvs/eb/bin/eb setenv PORT=5000 --environment calc-java-env

Explanation

Step D5 Check both environments

cd ~/work/aws-py
export AWS_PROFILE=admin-you
PY_URL=$(~/.venvs/eb/bin/eb status calc-py-env | awk '/CNAME/ {print $2}')
echo "Python: $PY_URL"
curl -s "http://$PY_URL/health"
curl -s "http://$PY_URL/add?a=2&b=3"

cd ~/work/aws-java
JAVA_URL=$(~/.venvs/eb/bin/eb status calc-java-env | awk '/CNAME/ {print $2}')
echo "Java:   $JAVA_URL"
curl -s "http://$JAVA_URL/health"
curl -s "http://$JAVA_URL/add?a=2&b=3"

Expected output

Checkpoint: both environments are Green in eb status, and both endpoints respond. Keep the two CNAME values for Phase F.

Phase E: Smoke test script About 5 min

Goal: one script confirms a deployed environment reports the expected version and returns the correct calculation. The pipelines run it after every deployment.

Step E1 Create the script in both repositories

cd ~/work/python-ci-demo
git checkout -b cd/aws-smoke
mkdir -p scripts
scripts/smoke_test.sh
#!/usr/bin/env bash
set -euo pipefail

BASE_URL="${1:?usage: smoke_test.sh <base-url> <version> [attempts]}"
VERSION="${2:?usage: smoke_test.sh <base-url> <version> [attempts]}"
ATTEMPTS="${3:-20}"

for attempt in $(seq 1 "$ATTEMPTS"); do
  if curl -fsS "$BASE_URL/health" | jq -e --arg v "$VERSION" '.version == $v' > /dev/null; then
    if curl -fsS "$BASE_URL/add?a=2&b=3" | jq -e '.result == 5' > /dev/null; then
      echo "OK: $BASE_URL serves version $VERSION and passes the functional check (attempt $attempt)"
      exit 0
    fi
    echo "FAIL: version $VERSION is live but /add returned the wrong result"
    exit 1
  fi
  echo "attempt $attempt: version $VERSION not live yet"
  sleep 15
done

echo "FAIL: $BASE_URL did not report version $VERSION after $ATTEMPTS attempts"
exit 1
chmod +x scripts/smoke_test.sh
./scripts/smoke_test.sh "http://$PY_URL" 1.2.0 5

Explanation

Step E2 Create the same script in the Java repository

cd ~/work/java-ci-demo
git checkout -b cd/aws-smoke
mkdir -p scripts
cp ~/work/python-ci-demo/scripts/smoke_test.sh scripts/
chmod +x scripts/smoke_test.sh
./scripts/smoke_test.sh "http://$JAVA_URL" 1.2.0 5
Checkpoint: both runs print OK for version 1.2.0.

Phase F: Pipelines, secrets, and environments About 20 min

The pipelines deploy only to AWS in this lab. Each repository gets one release workflow. The build and release jobs publish the artifacts. The deploy job uses them.

Step F1 Python release workflow

Version check for the Python repository

This version of the script checks only pyproject.toml, because the AWS-only lab does not publish the npm package. If the script from the artifacts session exists, replace it with this one.

scripts/check_version.sh
#!/usr/bin/env bash
set -euo pipefail

TAG="${1:?usage: check_version.sh vX.Y.Z}"
VERSION="${TAG#v}"
PY_VERSION=$(python3 -c "import tomllib; print(tomllib.load(open('pyproject.toml', 'rb'))['project']['version'])")

[ "$PY_VERSION" = "$VERSION" ] || { echo "FAIL pyproject.toml is $PY_VERSION, tag is $VERSION"; exit 1; }
echo "OK: pyproject.toml matches $VERSION"
chmod +x scripts/check_version.sh
./scripts/check_version.sh v1.2.0

Workflow file

.github/workflows/release.yml (python-ci-demo)
name: Python Release and AWS Deploy

on:
  push:
    tags: ["v*.*.*"]

permissions:
  contents: write

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout tagged commit
        uses: actions/checkout@v4

      - name: Set up Python
        uses: actions/setup-python@v5
        with:
          python-version: "3.12"
          cache: pip

      - name: Install dependencies
        run: |
          python -m pip install --upgrade pip
          pip install -r requirements.txt build

      - name: Test before publishing
        run: pytest

      - name: Check version matches tag
        run: ./scripts/check_version.sh "$GITHUB_REF_NAME"

      - name: Build wheel and sdist
        run: python -m build

      - name: Write checksums
        working-directory: dist
        run: sha256sum * > SHA256SUMS.txt

      - name: Upload distribution
        uses: actions/upload-artifact@v4
        with:
          name: dist
          path: dist/

  release:
    needs: build
    runs-on: ubuntu-latest
    steps:
      - name: Download distribution
        uses: actions/download-artifact@v4
        with:
          name: dist
          path: dist

      - name: Create GitHub Release
        uses: softprops/action-gh-release@v2
        with:
          files: dist/*
          generate_release_notes: true
          prerelease: ${{ contains(github.ref_name, '-') }}

  deploy-aws:
    needs: release
    runs-on: ubuntu-latest
    environment: aws-free
    steps:
      - name: Checkout (for the smoke test script)
        uses: actions/checkout@v4

      - name: Create Elastic Beanstalk bundle that pins the released wheel
        run: |
          VERSION="${GITHUB_REF_NAME#v}"
          printf '%s\n' "calc-ci-demo @ https://github.com/${GITHUB_REPOSITORY}/releases/download/${GITHUB_REF_NAME}/calc_ci_demo-${VERSION}-py3-none-any.whl" > requirements.txt
          printf '%s\n' "from app.web import app as application" > application.py
          zip bundle-aws.zip requirements.txt application.py

      - name: Deploy to Elastic Beanstalk
        uses: einaregilsson/beanstalk-deploy@v22
        with:
          aws_access_key: ${{ secrets.AWS_ACCESS_KEY_ID }}
          aws_secret_key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          region: ${{ vars.AWS_REGION }}
          application_name: calc-py-app
          environment_name: calc-py-env
          version_label: ${{ github.ref_name }}
          deployment_package: bundle-aws.zip

      - name: Smoke test the AWS environment
        run: ./scripts/smoke_test.sh "http://${{ vars.AWS_PY_URL }}" "${GITHUB_REF_NAME#v}" 20

Explanation

Step F2 Java release workflow

Version check for the Java repository

The Maven version is checked inside the workflow with a sed command, so no separate script is needed.

.github/workflows/release.yml (java-ci-demo)
name: Java Release and AWS Deploy

on:
  push:
    tags: ["v*.*.*"]

permissions:
  contents: write

jobs:
  release:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout tagged commit
        uses: actions/checkout@v4

      - name: Set up JDK 17
        uses: actions/setup-java@v4
        with:
          distribution: temurin
          java-version: "17"
          cache: maven

      - name: Check pom.xml version matches tag
        run: |
          POM_VERSION=$(sed -n 's:.*<version>\(.*\)</version>.*:\1:p' pom.xml | head -1)
          [ "$POM_VERSION" = "${GITHUB_REF_NAME#v}" ] || { echo "pom.xml is $POM_VERSION, tag is $GITHUB_REF_NAME"; exit 1; }

      - name: Verify
        run: mvn -B verify

      - name: Package jar
        run: mvn -B -DskipTests package

      - name: Write checksum
        working-directory: target
        run: sha256sum calc-ci-demo-*.jar > SHA256SUM-jar.txt

      - name: Create GitHub Release
        uses: softprops/action-gh-release@v2
        with:
          files: |
            target/calc-ci-demo-*.jar
            target/SHA256SUM-jar.txt
          generate_release_notes: true
          prerelease: ${{ contains(github.ref_name, '-') }}

  deploy-aws:
    needs: release
    runs-on: ubuntu-latest
    environment: aws-free
    steps:
      - name: Checkout (for the smoke test script)
        uses: actions/checkout@v4

      - name: Download the released jar into a bundle
        run: |
          VERSION="${GITHUB_REF_NAME#v}"
          curl -fsSL -o application.jar "https://github.com/${GITHUB_REPOSITORY}/releases/download/${GITHUB_REF_NAME}/calc-ci-demo-${VERSION}.jar"
          zip bundle-aws.zip application.jar

      - name: Deploy to Elastic Beanstalk
        uses: einaregilsson/beanstalk-deploy@v22
        with:
          aws_access_key: ${{ secrets.AWS_ACCESS_KEY_ID }}
          aws_secret_key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          region: ${{ vars.AWS_REGION }}
          application_name: calc-java-app
          environment_name: calc-java-env
          version_label: ${{ github.ref_name }}
          deployment_package: bundle-aws.zip

      - name: Smoke test the AWS environment
        run: ./scripts/smoke_test.sh "http://${{ vars.AWS_JAVA_URL }}" "${GITHUB_REF_NAME#v}" 20

Explanation

Step F3 Commit both workflows through pull requests

cd ~/work/python-ci-demo
git add scripts .github/workflows/release.yml
git commit -m "Add AWS release workflow and smoke test"
git push -u origin cd/aws-smoke

cd ~/work/java-ci-demo
git add scripts .github/workflows/release.yml
git commit -m "Add AWS release workflow and smoke test"
git push -u origin cd/aws-smoke

Open a pull request in each repository, wait for CI, and merge. The release workflows run only on tags, so nothing is deployed yet.

Step F4 Set the AWS variables and secrets from the command line

The values are read from the tools or from a hidden prompt, so they do not appear in your shell history.

OWNER=<your-github-user>
PY_REPO="$OWNER/python-ci-demo"
JAVA_REPO="$OWNER/java-ci-demo"

# Deployer keys: the secret is read silently and sent through standard input
gh secret set AWS_ACCESS_KEY_ID --body "<deployer-access-key-id>" --repo "$PY_REPO"
read -rs AWS_SECRET && echo
printf '%s' "$AWS_SECRET" | gh secret set AWS_SECRET_ACCESS_KEY --repo "$PY_REPO"
printf '%s' "$AWS_SECRET" | gh secret set AWS_SECRET_ACCESS_KEY --repo "$JAVA_REPO"
gh secret set AWS_ACCESS_KEY_ID --body "<deployer-access-key-id>" --repo "$JAVA_REPO"
unset AWS_SECRET

# Region and environment URLs
for REPO in "$PY_REPO" "$JAVA_REPO"; do
  gh variable set AWS_REGION --body us-east-1 --repo "$REPO"
done
gh variable set AWS_PY_URL --body "$(cd ~/work/aws-py && ~/.venvs/eb/bin/eb status calc-py-env | awk '/CNAME/ {print $2}')" --repo "$PY_REPO"
gh variable set AWS_JAVA_URL --body "$(cd ~/work/aws-java && ~/.venvs/eb/bin/eb status calc-java-env | awk '/CNAME/ {print $2}')" --repo "$JAVA_REPO"

# Environments that gate the AWS deploy jobs
for ENV in aws-free; do
  gh api -X PUT "repos/$PY_REPO/environments/$ENV" > /dev/null
  gh api -X PUT "repos/$JAVA_REPO/environments/$ENV" > /dev/null
done

Explanation

Step F5 Confirm the configuration

gh variable list --repo "$PY_REPO"
gh secret list --repo "$PY_REPO"
gh variable list --repo "$JAVA_REPO"
gh secret list --repo "$JAVA_REPO"
gh api "repos/$PY_REPO/environments" --jq '.environments[].name'
Checkpoint: each repository lists AWS_REGION, its URL variable, and the two AWS secrets, and the aws-free environment exists.

Phase G: First release, v1.2.0 About 10 min

Step G1 Tag and release both repositories

cd ~/work/python-ci-demo
git checkout main && git pull
git tag -a v1.2.0 -m "Release 1.2.0: HTTP endpoints and AWS deployment"
git push origin v1.2.0

cd ~/work/java-ci-demo
git checkout main && git pull
git tag -a v1.2.0 -m "Release 1.2.0: HTTP endpoints and AWS deployment"
git push origin v1.2.0

What to watch

  1. In each repository, open Actions, then the run for v1.2.0.
  2. build and release pass. The release lists the wheel, the sdist, and the checksums (Python), or the jar and its checksum (Java).
  3. deploy-aws runs, uploads the bundle, updates the environment, and ends with OK: ... serves version 1.2.0.
Checkpoint: both deploy jobs pass, and both environments report version 1.2.0 on /health.

Step G2 Verify the environments from your terminal

curl -s "http://$(cd ~/work/aws-py && ~/.venvs/eb/bin/eb status calc-py-env | awk '/CNAME/ {print $2}')/health"
curl -s "http://$(cd ~/work/aws-java && ~/.venvs/eb/bin/eb status calc-java-env | awk '/CNAME/ {print $2}')/health"

Both lines show "version":"1.2.0". Note that these environments now run the released version, not the local build.


Phase H: Release 1.2.1 and roll back About 20 min

Step H1 Release 1.2.1

  1. On a branch in each repository, set the version to 1.2.1. In Python, edit pyproject.toml. In Java, edit the project <version> in pom.xml.
  2. Open a pull request, wait for CI, and merge into main.
  3. Check the Python version: ./scripts/check_version.sh v1.2.1. It should print OK.
  4. Tag and push both repositories:
cd ~/work/python-ci-demo
git checkout main && git pull
git tag -a v1.2.1 -m "Release 1.2.1"
git push origin v1.2.1

cd ~/work/java-ci-demo
git checkout main && git pull
git tag -a v1.2.1 -m "Release 1.2.1"
git push origin v1.2.1

Wait for both deploy-aws jobs to finish. Both environments should now report 1.2.1.

Step H2 Roll back the Python environment to 1.2.0

Goal: return the Python environment to the application version labelled v1.2.0, created in Phase G.

aws elasticbeanstalk describe-application-versions \
  --application-name calc-py-app \
  --query "ApplicationVersions[].VersionLabel" \
  --output text \
  --profile ci-deployer \
  --region us-east-1

aws elasticbeanstalk update-environment \
  --environment-name calc-py-env \
  --version-label v1.2.0 \
  --profile ci-deployer \
  --region us-east-1

aws elasticbeanstalk wait environment-updated \
  --environment-names calc-py-env \
  --profile ci-deployer \
  --region us-east-1

PY_URL=$(cd ~/work/aws-py && ~/.venvs/eb/bin/eb status calc-py-env | awk '/CNAME/ {print $2}')
~/work/python-ci-demo/scripts/smoke_test.sh "http://$PY_URL" 1.2.0 10

Explanation

Checkpoint: the Python environment reports 1.2.0, and the Java environment still reports 1.2.1. That difference shows that each environment rolls back on its own.

Step H3 Read the environment events

aws elasticbeanstalk describe-events \
  --environment-name calc-py-env \
  --max-items 5 \
  --query "Events[].Message" \
  --output text \
  --profile ci-deployer \
  --region us-east-1

The output lists the recent events, including the update to version label v1.2.0.

Step H4 Roll back the Java environment (optional)

Repeat Step H2 for calc-java-app, calc-java-env, and the label v1.2.0. Then run the smoke test against the Java CNAME with version 1.2.0.

After a rollback, fix the defect on main and release a new patch version. Do not move or reuse the failed tag. The history of labels stays intact, which is what makes the next rollback possible.

Phase I: Cleanup About 15 min

Elastic Beanstalk environments run EC2 instances, public IP addresses, and storage, so they keep charging until you terminate them. Clean up in the order below.

Step I1 Terminate the environments and delete the applications

cd ~/work/aws-py
export AWS_PROFILE=admin-you
~/.venvs/eb/bin/eb terminate calc-py-env --force

cd ~/work/aws-java
~/.venvs/eb/bin/eb terminate calc-java-env --force

aws elasticbeanstalk delete-application --application-name calc-py-app --terminate-env-by-force --region us-east-1 --profile admin-you
aws elasticbeanstalk delete-application --application-name calc-java-app --terminate-env-by-force --region us-east-1 --profile admin-you

Step I2 Confirm nothing is left running

aws ec2 describe-instances --region us-east-1 --profile admin-you \
  --query "Reservations[].Instances[].{Id:InstanceId,State:State.Name}" --output table
aws cloudformation list-stacks --region us-east-1 --profile admin-you \
  --query "StackSummaries[?StackStatus!='DELETE_COMPLETE'].StackName" --output table
aws s3api list-buckets --profile admin-you \
  --query "Buckets[?starts_with(Name, 'elasticbeanstalk')].Name" --output table

Expected output

Step I3 Empty and delete the Elastic Beanstalk bucket

BUCKET=$(aws s3api list-buckets --profile admin-you --query "Buckets[?starts_with(Name, 'elasticbeanstalk')].Name" --output text)
echo "Bucket: $BUCKET"
aws s3 rm "s3://$BUCKET" --recursive --profile admin-you
aws s3api delete-bucket --bucket "$BUCKET" --region us-east-1 --profile admin-you
Check the bucket name printed by the first command before the delete runs. The command empties every object in that bucket.

Step I4 Delete the deployer user and access keys

# Delete each access key of ci-deployer
for KEY in $(aws iam list-access-keys --user-name ci-deployer --profile admin-you --query "AccessKeyMetadata[].AccessKeyId" --output text); do
  aws iam delete-access-key --user-name ci-deployer --access-key-id "$KEY" --profile admin-you
done

# Detach the managed policies
for POLICY in AWSElasticBeanstalkFullAccess AmazonS3FullAccess AWSCloudFormationFullAccess; do
  aws iam detach-user-policy --user-name ci-deployer --policy-arn "arn:aws:iam::aws:policy/$POLICY" --profile admin-you
done

aws iam delete-user --user-name ci-deployer --profile admin-you

Step I5 Delete the admin access key and remove credentials from GitHub

  1. In the console, sign in as admin-you, open Security credentials, and deactivate then delete the CLI access key created in Step B4.
  2. In GitHub, remove AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_REGION, AWS_PY_URL, and AWS_JAVA_URL from both repositories, and delete the aws-free environment if you will not reuse it.
  3. Remove the local profiles with aws configure or by editing ~/.aws/credentials.

Step I6 Check the bills

  1. Open the billing dashboard the next day and confirm that EC2 and Elastic Beanstalk usage have stopped.
  2. Keep the budget alerts active for a few weeks, in case a resource was missed.
Checkpoint: no instances, stacks, or deployer keys remain, and the billing dashboard shows no new usage.

Troubleshooting

SymptomCause and fix
Card declined at sign-upEnable international or online transactions in the bank's app, check the billing address, and try another card if you have one.
Phone verification code never arrivesUse the voice call option, and check the number includes the country code.
eb create says the instance profile or role cannot be createdRun the command with AWS_PROFILE=admin-you, as in Step D3. The admin user has the IAM permissions the platform needs.
eb create fails with an unknown platform nameRun ~/.venvs/eb/bin/eb platform list and use the exact name for Python 3.12 or Corretto 17.
Java environment returns 502 Bad GatewayThe app is not on port 5000. Run eb setenv PORT=5000 --environment calc-java-env, wait for Green, and check the logs with eb logs calc-java-env.
Python environment Red, with ModuleNotFoundError in the logsThe wheel's dependencies did not install. Read eb logs calc-py-env, then check that the bundle's requirements.txt names the wheel for the correct tag.
Deploy job fails with AccessDeniedThe error names the action. Add that action to ci-deployer, then re-run the job. Do not switch the pipeline to the admin keys.
Deploy job says a version label already existsEach label is used once. Release the next patch version instead of reusing a tag.
Smoke test fails with the old version after 20 attemptsCheck that AWS_PY_URL or AWS_JAVA_URL has no http:// prefix, and that the environment finished updating in eb status.
Rollback command says version label not foundList labels with the describe-application-versions command in Step H2. The label is case-sensitive.
Monthly cost alert receivedOpen Cost Explorer, find the service, and terminate the Elastic Beanstalk environments first, then remove any remaining resources from Phase I.