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.
- Session plan and free-tier notes
- Starting point
- Phase A: AWS account, MFA, and budgets
- Phase B: Tools, AWS CLI, EB CLI, and GitHub CLI
- Phase C: Endpoint code for both apps
- Phase D: Deployer user and Elastic Beanstalk environments
- Phase E: Smoke test script
- Phase F: Pipelines, secrets, and environments
- Phase G: First release, v1.2.0
- Phase H: Release 1.2.1 and roll back
- Phase I: Cleanup
- Troubleshooting
Session plan and free-tier notes
| Time | Phase | What you finish |
|---|---|---|
| 0:00 to 0:20 | A. Account | AWS account with MFA, an admin user, and budget alerts |
| 0:20 to 0:35 | B. Tools | AWS CLI, EB CLI, GitHub CLI, and working sign-in |
| 0:35 to 0:50 | C. Code | Endpoints in both apps, tests passing locally |
| 0:50 to 1:05 | D. AWS resources | Deployer user and two Elastic Beanstalk environments |
| 1:05 to 1:15 | E and F. Smoke test and pipelines | Release workflows and GitHub settings |
| 1:15 to 1:25 | G. First release | Tag v1.2.0 and verify both environments |
| 1:25 to 1:40 | H. Release 1.2.1 and rollback | Upgrade, then roll back one environment |
| 1:40 to 2:00 | I. Cleanup | All AWS resources removed and access keys deleted |
Free-tier notes for AWS
- A card is required to create an AWS account. AWS places a small temporary authorisation to verify it.
- Elastic Beanstalk itself is free. You pay for the EC2 instance, its storage, and any load balancer, so the lab uses a single instance with no load balancer.
- Use a free-tier-eligible instance type. The lab uses
t3.micro. Check the eligible types for your region on the Free Tier page, and change the instance type in Phase D if needed. - Public IPv4 addresses are billed. An instance with a public address incurs a charge for the address, even inside the free-tier period. Terminate the environments at the end (Phase I).
- Terms change. Check the Free Tier page and the billing page before the session.
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.
| Repository | Already present | Added in this lab |
|---|---|---|
python-ci-demo | app/calculator.py, app/main.py, tests/test_calculator.py, requirements.txt with pytest, pytest-cov, flake8, pytest.ini, a green CI workflow | pyproject.toml, app/web.py, tests/test_web.py, the smoke test script, the release workflow |
java-ci-demo | Calculator.java, App.java, CalculatorTest.java, pom.xml with the JaCoCo report, a green CI workflow | Server.java, the pom changes, the smoke test script, the release workflow |
Phase A: AWS account, MFA, and budgets About 20 min plus verification
Step A1 Create the account
- Open
aws.amazon.comin a private browser window and choose Create an AWS account. - 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. - Set a root password of at least 16 characters and store it in a password manager.
- Choose Personal as the account type. Enter your name, country, address, and phone number. Accept the customer agreement.
- Verify the phone by text message or voice call. Complete the CAPTCHA and enter the code.
- Choose Basic support (free).
- Enter a credit or debit card and the billing address. A small temporary authorisation is placed and reversed.
- Complete the identity check. If AWS calls the phone, enter the code it reads out.
- 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.
Step A2 Protect the root user
- Sign in at
console.aws.amazon.comas the root user. - 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.
- On the same page, check that the access keys section is empty. Never create root access keys.
Step A3 Create the administrator user
- Open the IAM service and choose Users, then Create user.
- Name the user
admin-youand 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. - On the permissions step, choose Attach policies directly, select
AdministratorAccess, and create the user. - Open
admin-you, choose Security credentials, then Assign MFA device, and register a passkey or an authenticator app. - 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.
- Sign out of root and sign in as
admin-youwith MFA. Use this user for all later work.
Step A4 Let IAM users see billing, then create the budgets
- 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.
- Sign in as
admin-you. Open Billing and Cost Management, then Budgets, then Create budget. - Choose the Monthly cost budget template, name it
lab-guard, and set the budgeted amount to10in your currency. Add an alert at 80 percent of the budgeted amount and enter your email address. - 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.
- Open the Free Tier page from the billing menu and note the remaining credit and its expiry date, if the plan shows them.
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
| Tool | macOS (Homebrew) | Ubuntu or Debian | Windows (winget) |
|---|---|---|---|
| AWS CLI v2 | brew install awscli | Download the v2 bundle from the AWS install page and run its installer | winget install Amazon.AWSCLI |
| GitHub CLI | brew install gh | Follow the GitHub CLI Linux install page | winget install GitHub.cli |
| jq | brew install jq | sudo apt-get install -y jq | winget install jqlang.jq |
| zip and unzip | Included | sudo apt-get install -y zip unzip | Use Git Bash, which includes them |
| Python 3.11 or later | brew install python | sudo apt-get install -y python3 python3-venv python3-pip | winget install Python.Python.3.12 |
| JDK 17 and Maven | brew install openjdk@17 maven | sudo apt-get install -y openjdk-17-jdk maven | winget install EclipseAdoptium.Temurin.17.JDK and winget install Apache.Maven |
Step B2 Verify the installations
aws --version
gh --version
jq --version
zip -h | head -1
python3 --version
java -version
mvn -version
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
- Sign in to the console as
admin-you. Open IAM, then Users, thenadmin-you, then Security credentials. - 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.
- Run
aws configure --profile admin-youand enter the key ID, the secret, the regionus-east-1, and the output formatjson. - Check the identity with
aws sts get-caller-identity --profile admin-you. The Arn should end inuser/admin-you. - Delete any CSV you downloaded.
Step B5 Sign in to GitHub with the GitHub CLI
- Run
gh auth login, choose GitHub.com and HTTPS, and answer yes to authenticate Git. - Choose Login with a web browser, copy the one-time code, and complete the sign-in and MFA in the browser.
- Run
gh auth refresh -s workflowif theworkflowscope is missing. The lab updates workflow files and sets secrets, so it needs that scope. - Confirm you can administer both repositories with
gh repo view <your-user>/python-ci-demo --json viewerPermission --jq .viewerPermission. The value should beADMIN.
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)"
Phase C: Endpoint code for both apps About 15 min
Step C1 Python: packaging, web module, and tests
cd ~/work/python-ci-demo
git checkout main && git pull
git checkout -b feat/aws-endpoints
[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*"]
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
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'])"
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
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:
- Set the project version: change the
<version>directly under<artifactId>calc-ci-demo</artifactId>to1.2.0. - Replace the
<configuration>ofmaven-jar-pluginwith:
<configuration>
<archive>
<manifest>
<mainClass>com.example.calc.Server</mainClass>
<addDefaultImplementationEntries>true</addDefaultImplementationEntries>
</manifest>
</archive>
</configuration>
- In the
jacoco-maven-pluginblock, 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
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
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
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
AWS_PROFILE=admin-youis used for environment creation, because the platform creates the instance role and the environment's CloudFormation stack. The admin user has the permissions for both. The deployer user performs routine deployments only.requirements.txtnames the wheel by its relative path. The bundle includes the wheel, so Elastic Beanstalk installs it on the instance without downloading anything.--singlecreates one instance without a load balancer, which keeps the cost down.- If
eb createreports that the platform name is unknown, run~/.venvs/eb/bin/eb platform listand use the Python 3.12 name it shows.
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
- The Java platform runs
application.jarfrom the bundle root and routes HTTP to port 5000. ThePORTvariable tellsServerto listen on that port. - Changing an environment variable restarts the application. Wait for the environment to return to Green before you test.
- If
grepshows a different Corretto name, use that name ineb init.
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
- Each
/healthreports the version1.2.0. - The Python
/addreturns{"result":5.0}, and the Java/addreturns{"result":5}.
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
#!/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
BASE_URLis the environment's root address. AWS environments serve plain HTTP on port 80.- The script waits for the expected version, so a deployment that is up but still running the old version fails the check.
jq -esets the exit status from the test, which the pipeline uses to pass or fail the job.
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
OK for version 1.2.0.Phase F: Pipelines, secrets, and environments About 20 min
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.
#!/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
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
needs: releasemeans the deploy job runs only after the GitHub Release exists, and it deploys the same wheel that the release published.- The bundle installs the released wheel by URL. The running code is the artifact that was tested and published, not a rebuild.
version_labelis the tag. Elastic Beanstalk keeps each label, so Phase H can roll back to it.einaregilsson/beanstalk-deployuploads the bundle to S3, registers an application version, and updates the environment. It is a community action. Pin the version shown and check its README before you upgrade.- The smoke test runs after the update, so the job fails when the environment does not report the tagged version.
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.
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
- The Java deploy job downloads the jar from the release rather than rebuilding it. The bundle is the same file the release published.
- The bundle's
application.jaris at its root, which is where Elastic Beanstalk looks for it. - The
sedline reads the first<version>inpom.xml. That element is the project version because it appears before any plugin or dependency version in this file.
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
- The access key ID is not secret on its own, but it is stored as a secret so that both the ID and the secret are kept in the same place.
read -rsreads the secret without echoing it.printfsends it to the CLI through standard input, so it never appears as a command argument.- The URLs come from
eb status, so they cannot be mistyped. - Each environment is named after its deploy job. Add required reviewers under Settings, then Environments, then aws-free if you want a manual approval before each deployment.
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'
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
- In each repository, open Actions, then the run for
v1.2.0. buildandreleasepass. The release lists the wheel, the sdist, and the checksums (Python), or the jar and its checksum (Java).deploy-awsruns, uploads the bundle, updates the environment, and ends withOK: ... serves version 1.2.0.
/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
- On a branch in each repository, set the version to
1.2.1. In Python, editpyproject.toml. In Java, edit the project<version>inpom.xml. - Open a pull request, wait for CI, and merge into
main. - Check the Python version:
./scripts/check_version.sh v1.2.1. It should printOK. - 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
- The first command lists the version labels that exist. Confirm
v1.2.0is in the list before you roll back. update-environmentpoints the environment at an existing application version. It does not upload anything, so the rollback takes about as long as a normal update.- The wait command returns once the environment has finished updating. Checking earlier would still show 1.2.1.
- The smoke test confirms the rollback. It expects version 1.2.0, so it fails if the environment is still running 1.2.1.
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.
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
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
- The instance list is empty, or every instance is
terminated. - No stack name refers to the environments.
- The bucket list may show a bucket named
elasticbeanstalk-us-east-1-<account-id>. Elastic Beanstalk keeps it for bundles. Empty it and delete it, as in the next step, if you do not plan to reuse it.
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
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
- In the console, sign in as
admin-you, open Security credentials, and deactivate then delete the CLI access key created in Step B4. - In GitHub, remove
AWS_ACCESS_KEY_ID,AWS_SECRET_ACCESS_KEY,AWS_REGION,AWS_PY_URL, andAWS_JAVA_URLfrom both repositories, and delete theaws-freeenvironment if you will not reuse it. - Remove the local profiles with
aws configureor by editing~/.aws/credentials.
Step I6 Check the bills
- Open the billing dashboard the next day and confirm that EC2 and Elastic Beanstalk usage have stopped.
- Keep the budget alerts active for a few weeks, in case a resource was missed.
Troubleshooting
| Symptom | Cause and fix |
|---|---|
| Card declined at sign-up | Enable 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 arrives | Use the voice call option, and check the number includes the country code. |
eb create says the instance profile or role cannot be created | Run 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 name | Run ~/.venvs/eb/bin/eb platform list and use the exact name for Python 3.12 or Corretto 17. |
| Java environment returns 502 Bad Gateway | The 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 logs | The 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 AccessDenied | The 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 exists | Each label is used once. Release the next patch version instead of reusing a tag. |
| Smoke test fails with the old version after 20 attempts | Check 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 found | List labels with the describe-application-versions command in Step H2. The label is case-sensitive. |
| Monthly cost alert received | Open Cost Explorer, find the service, and terminate the Elastic Beanstalk environments first, then remove any remaining resources from Phase I. |