Hands-On: Docker Images and Container CI/CD to AWS Elastic Beanstalk
This hands-on packages the Python and Java apps as Docker images and runs them locally. Then it adds CI/CD: each release tag builds the artifact once, builds the image from that artifact, smoke-tests the image, pushes it to GitHub Container Registry (GHCR), and deploys that exact tag to Elastic Beanstalk's Docker platform.
- Session plan and prerequisites
- Step 1: Check the tools
- Step 2: Python image, build and run locally
- Step 3: Java image, build and run locally
- Step 4: Make the images pullable
- Step 5: Create the Elastic Beanstalk Docker environments
- Step 6: CI/CD workflows
- Step 7: Set the container URL variables
- Step 8: First release and verification
- Step 9: Troubleshooting
- Step 10: Cleanup
Session plan and prerequisites
| Time | Step | What you finish |
|---|---|---|
| 0:00 to 0:05 | 1. Tools | Docker is running |
| 0:05 to 0:20 | 2 and 3. Local images | Both apps run in containers on your machine |
| 0:20 to 0:30 | 4. Packages public | Images can be pulled without credentials |
| 0:30 to 0:45 | 5. Container environments | Two Elastic Beanstalk Docker environments |
| 0:45 to 1:05 | 6. Workflows | Release pipelines that build, test, push, and deploy images |
| 1:05 to 1:15 | 7 and 8. Variables and first release | Tag v1.2.0 deploys both containers |
| 1:15 to 1:30 | 9 and 10. Troubleshooting and cleanup | Containers and images removed |
Prerequisites
| Requirement | How to check |
|---|---|
| Docker Desktop or Docker Engine | docker --version |
The platform lab from the AWS hands-on: the calc-py-env and calc-java-env environments exist, and the pipelines already deploy to them | eb list in the AWS project folders |
Releases v1.2.0 in both repositories, with the endpoints | Releases page of each repository |
Python 3.12 and JDK 17 with Maven, and gh, jq, zip | python3 --version, mvn -version, gh --version |
Step 1: Check the tools
docker --version
docker info --format '{{.ServerVersion}}'
--platform linux/amd64. Elastic Beanstalk's t3.micro instances run on x86_64, and an arm64 image fails there with exec format error. The CI runner always builds for amd64, so only local builds need the flag.Step 2: Python image, build and run locally About 10 min
Step 2.1 Dockerfile and ignore file
FROM python:3.12-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PORT=8000
WORKDIR /srv
COPY dist/*.whl /tmp/
RUN pip install --no-cache-dir /tmp/*.whl \
&& rm -f /tmp/*.whl \
&& useradd --create-home appuser
USER appuser
EXPOSE 8000
CMD ["sh", "-c", "exec gunicorn --bind 0.0.0.0:${PORT} app.web:app"]
.venv/
.git/
.github/
reports/
.pytest_cache/
__pycache__/
**/__pycache__/
*.egg-info/
npm/
Explanation
FROM python:3.12-slimpins a small base image with the same Python version as the Elastic Beanstalk platform.COPY dist/*.whlbrings in the wheel built bypython -m build. The wheel declares Flask and Gunicorn, so pip installs them during the build.useraddandUSER appuserrun the app without root privileges. If the process is compromised, it has fewer permissions.CMDwithsh -cexpands${PORT}at start-up, andexecmakes Gunicorn PID 1 so it receives stop signals..dockerignorekeeps the virtual environment, Git history, and CI files out of the build context. The context is smaller and the build is faster.
Step 2.2 Build and run
cd ~/work/python-ci-demo
source .venv/bin/activate
rm -rf dist && python -m build --outdir dist
docker build -t calc-py:local .
docker run -d --name calc-py -p 8000:8000 calc-py:local
sleep 3
./scripts/smoke_test.sh http://localhost:8000 1.2.0 10
docker exec calc-py whoami
docker logs calc-py --tail 20
docker rm -f calc-py
Expected output
- The smoke test prints
OKfor version 1.2.0. whoamiprintsappuser.- The logs show Gunicorn listening on port 8000 with no traceback.
/health with version 1.2.0 and runs as a non-root user.Step 3: Java image, build and run locally About 10 min
Step 3.1 Dockerfile and ignore file
FROM eclipse-temurin:17-jre
ENV PORT=8080
WORKDIR /app
COPY target/calc-ci-demo-*.jar /app/app.jar
RUN useradd --system --create-home appuser
USER appuser
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "/app/app.jar"]
.git/
.github/
.idea/
*.iml
.vscode/
src/
Explanation
eclipse-temurin:17-jreis a runtime-only image. It has no compiler and no Maven, which keeps it smaller and reduces what an attacker can use.COPY target/calc-ci-demo-*.jarcopies the jar built by Maven. The image does not compile code.- The
Serverclass readsPORT, so the same image works on any platform that sets that variable.
Step 3.2 Build and run
cd ~/work/java-ci-demo
mvn -B -DskipTests package
docker build -t calc-java:local .
docker run -d --name calc-java -p 8080:8080 calc-java:local
sleep 3
./scripts/smoke_test.sh http://localhost:8080 1.2.0 10
docker exec calc-java whoami
docker rm -f calc-java
OK for version 1.2.0, and whoami prints appuser.Step 4: Make the images pullable About 10 min
The Elastic Beanstalk instances pull images without credentials, so the packages must be public. CI pushes them in Step 8, so do this step after the first release:
- On GitHub, open your profile and choose Packages.
- Open
python-ci-demo, choose Package settings, then Change visibility, and set it to Public. - Repeat for
java-ci-demo.
Step 5: Create the Elastic Beanstalk Docker environments About 15 min
Step 5.1 Find the Docker platform name
~/.venvs/eb/bin/eb platform list | grep -i "docker running on 64bit amazon linux 2023"
Use the exact name from the output in the commands below, in place of <docker platform name>.
Step 5.2 Python container environment
mkdir -p ~/work/aws-py-container && cd ~/work/aws-py-container
printf '%s\n' \
"services:" \
" web:" \
" image: ghcr.io/<owner>/python-ci-demo:1.2.0" \
" ports:" \
" - \"80:8000\"" \
" restart: always" > docker-compose.yml
export AWS_PROFILE=admin-you
~/.venvs/eb/bin/eb init calc-py-app -p "<docker platform name>" -r us-east-1
~/.venvs/eb/bin/eb create calc-py-container-env --single -i t3.micro --timeout 20
Step 5.3 Java container environment
mkdir -p ~/work/aws-java-container && cd ~/work/aws-java-container
printf '%s\n' \
"services:" \
" web:" \
" image: ghcr.io/<owner>/java-ci-demo:1.2.0" \
" ports:" \
" - \"80:8080\"" \
" restart: always" > docker-compose.yml
~/.venvs/eb/bin/eb init calc-java-app -p "<docker platform name>" -r us-east-1
~/.venvs/eb/bin/eb create calc-java-container-env --single -i t3.micro --timeout 20
Explanation
ports: "80:8000"publishes container port 8000 on the instance's port 80, where Elastic Beanstalk sends HTTP traffic.imagenames the tag that the pipeline pushes. Until that tag exists in GHCR, the environment can't start, so the first deployment waits for Step 8.eb createcreates the environment. The firsteb createmay fail if the image isn't published yet. In that case, create the environments after Step 8 instead.
Step 6: CI/CD workflows About 20 min
Step 6.1 Python release workflow
name: Python Release, Image, and AWS Container Deploy
on:
push:
tags: ["v*.*.*"]
permissions:
contents: write
packages: 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, '-') }}
image:
needs: release
runs-on: ubuntu-latest
steps:
- name: Checkout (Dockerfile and smoke test)
uses: actions/checkout@v4
- name: Download distribution
uses: actions/download-artifact@v4
with:
name: dist
path: dist
- name: Set image name in lowercase
run: echo "IMAGE=ghcr.io/${GITHUB_REPOSITORY,,}" >> "$GITHUB_ENV"
- name: Set up Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Generate image tags
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.IMAGE }}
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
- name: Build image locally (not pushed yet)
uses: docker/build-push-action@v6
with:
context: .
load: true
push: false
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
- name: Smoke test the image
run: |
VERSION="${GITHUB_REF_NAME#v}"
docker run -d --name calc-smoke -p 8000:8000 "$IMAGE:$VERSION"
if ! ./scripts/smoke_test.sh http://localhost:8000 "$VERSION" 15; then
docker logs calc-smoke
exit 1
fi
docker rm -f calc-smoke
- name: Push the verified image
env:
TAGS: ${{ steps.meta.outputs.tags }}
run: |
printf '%s\n' "$TAGS" | while read -r TAG; do
[ -n "$TAG" ] && docker push "$TAG"
done
deploy-container:
needs: image
runs-on: ubuntu-latest
environment: aws-free
steps:
- name: Checkout (for the smoke test script)
uses: actions/checkout@v4
- name: Create Docker Compose bundle for Elastic Beanstalk
run: |
VERSION="${GITHUB_REF_NAME#v}"
IMAGE="ghcr.io/${GITHUB_REPOSITORY,,}"
printf '%s\n' \
"services:" \
" web:" \
" image: $IMAGE:$VERSION" \
" ports:" \
" - \"80:8000\"" \
" restart: always" > docker-compose.yml
zip deploy-container.zip docker-compose.yml
- 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-container-env
version_label: ${{ github.ref_name }}-container
deployment_package: deploy-container.zip
- name: Smoke test the Elastic Beanstalk container environment
run: ./scripts/smoke_test.sh "http://${{ vars.AWS_PY_CONTAINER_URL }}" "${GITHUB_REF_NAME#v}" 20
Step 6.2 Java release workflow
name: Java Release, Image, and AWS Container Deploy
on:
push:
tags: ["v*.*.*"]
permissions:
contents: write
packages: 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: Upload jar
uses: actions/upload-artifact@v4
with:
name: jar
path: |
target/calc-ci-demo-*.jar
target/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, '-') }}
image:
needs: release
runs-on: ubuntu-latest
steps:
- name: Checkout (Dockerfile and smoke test)
uses: actions/checkout@v4
- name: Download jar
uses: actions/download-artifact@v4
with:
name: jar
path: target
- name: Set image name in lowercase
run: echo "IMAGE=ghcr.io/${GITHUB_REPOSITORY,,}" >> "$GITHUB_ENV"
- name: Set up Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Generate image tags
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.IMAGE }}
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
- name: Build image locally (not pushed yet)
uses: docker/build-push-action@v6
with:
context: .
load: true
push: false
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
- name: Smoke test the image
run: |
VERSION="${GITHUB_REF_NAME#v}"
docker run -d --name calc-smoke -p 8080:8080 "$IMAGE:$VERSION"
if ! ./scripts/smoke_test.sh http://localhost:8080 "$VERSION" 15; then
docker logs calc-smoke
exit 1
fi
docker rm -f calc-smoke
- name: Push the verified image
env:
TAGS: ${{ steps.meta.outputs.tags }}
run: |
printf '%s\n' "$TAGS" | while read -r TAG; do
[ -n "$TAG" ] && docker push "$TAG"
done
deploy-container:
needs: image
runs-on: ubuntu-latest
environment: aws-free
steps:
- name: Checkout (for the smoke test script)
uses: actions/checkout@v4
- name: Create Docker Compose bundle for Elastic Beanstalk
run: |
VERSION="${GITHUB_REF_NAME#v}"
IMAGE="ghcr.io/${GITHUB_REPOSITORY,,}"
printf '%s\n' \
"services:" \
" web:" \
" image: $IMAGE:$VERSION" \
" ports:" \
" - \"80:8080\"" \
" restart: always" > docker-compose.yml
zip deploy-container.zip docker-compose.yml
- 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-container-env
version_label: ${{ github.ref_name }}-container
deployment_package: deploy-container.zip
- name: Smoke test the Elastic Beanstalk container environment
run: ./scripts/smoke_test.sh "http://${{ vars.AWS_JAVA_CONTAINER_URL }}" "${GITHUB_REF_NAME#v}" 20
Step 6.3 What each job does
| Job | Purpose |
|---|---|
build (Python) or release (Java) | Runs the tests, checks the version against the tag, and builds the artifacts once. |
release (Python) | Publishes the wheel and sdist to a GitHub Release. The image job uses these files. |
image | Builds the image from the downloaded artifacts with load: true, so the image is available locally in the job. It runs the smoke test on that image, then pushes the same image with docker push. Nothing is rebuilt between the test and the push. |
deploy-container | Writes a docker-compose.yml that names the pushed tag, uploads it, and smoke-tests the environment. The environment protection rule aws-free applies here. |
Step 7: Set the container URL variables About 5 min
PY_REPO="<owner>/python-ci-demo"
JAVA_REPO="<owner>/java-ci-demo"
PY_C=$(cd ~/work/aws-py-container && ~/.venvs/eb/bin/eb status calc-py-container-env | awk '/CNAME/ {print $2}')
JAVA_C=$(cd ~/work/aws-java-container && ~/.venvs/eb/bin/eb status calc-java-container-env | awk '/CNAME/ {print $2}')
[ -n "$PY_C" ] && [ -n "$JAVA_C" ] || { echo "STOP: a CNAME is empty"; exit 1; }
gh variable set AWS_PY_CONTAINER_URL --body "$PY_C" --repo "$PY_REPO"
gh variable set AWS_JAVA_CONTAINER_URL --body "$JAVA_C" --repo "$JAVA_REPO"
gh variable get AWS_PY_CONTAINER_URL --repo "$PY_REPO"
gh variable get AWS_JAVA_CONTAINER_URL --repo "$JAVA_REPO"
http://. The workflows add the scheme, so a value starting with http:// produces http://http://... and the smoke test fails.Step 8: First release and verification About 20 min
Step 8.1 Merge the Docker files
- In each repository, commit the
Dockerfile,.dockerignore, and the workflow file to a branch, open a pull request, and merge it intomain. - Confirm CI still passes on
main. The tests and lint don't change.
Step 8.2 Tag v1.2.0
cd ~/work/python-ci-demo
git checkout main && git pull
git tag -a v1.2.0 -m "Release 1.2.0 with container image"
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 with container image"
git push origin v1.2.0
In each repository, watch the run. The image job should finish with the smoke test printing OK, followed by the pushes of 1.2.0 and 1.2. The deploy-container job will fail if the environment doesn't exist yet. That's expected at this point.
Step 8.3 Make the packages public
Complete Step 4. Both packages must exist before the environments can pull them.
Step 8.4 Create the container environments
Run the commands in Step 5.2 and Step 5.3 now. The image tag 1.2.0 is available, so the environments can start.
Step 8.5 Set the variables and re-run the deploy
- Run Step 7 to set
AWS_PY_CONTAINER_URLandAWS_JAVA_CONTAINER_URL. - In each repository's Actions page, open the failed run for
v1.2.0and choose Re-run failed jobs. Thedeploy-containerjob runs again, using the same image tag.
Step 8.6 Verify both containers
PY_C=$(cd ~/work/aws-py-container && ~/.venvs/eb/bin/eb status calc-py-container-env | awk '/CNAME/ {print $2}')
JAVA_C=$(cd ~/work/aws-java-container && ~/.venvs/eb/bin/eb status calc-java-container-env | awk '/CNAME/ {print $2}')
curl -s "http://$PY_C/health"
curl -s "http://$PY_C/add?a=2&b=3"
curl -s "http://$JAVA_C/health"
curl -s "http://$JAVA_C/add?a=2&b=3"
Expected output
- Both
/healthresponses report version1.2.0. - The Python
/addreturns{"result":5.0}, and the Java/addreturns{"result":5}.
Step 9: Troubleshooting
| Symptom | Cause and fix |
|---|---|
failed to compute cache key: "/dist/*.whl" not found | Build the wheel before docker build, with python -m build. |
exec format error on Elastic Beanstalk | The image is arm64. Build it with --platform linux/amd64. The CI runner already builds amd64. |
denied: permission_denied when pushing | The workflow needs packages: write, which both workflows set. Check the package's access settings for the repository. |
| Environment Degraded, with image pull errors | The package is private or the tag is missing. Make the package public (Step 4), and check the tag in docker-compose.yml. |
| Deploy job fails with environment not found | Create the environment in Step 5, then re-run the deploy job. |
| Smoke test fails in the image job | The job prints docker logs calc-smoke. Read the first traceback. |
| Port 8000 or 8080 already in use locally | Stop the process holding the port (docker ps, or lsof -i :8000), or map a different host port, such as 8001:8000. |
| Compose file rejected by Elastic Beanstalk | Check that the platform is Docker on Amazon Linux 2023, and that the file uses two-space indentation. |
| The image job pushes but the tags are wrong | Check that the tag starts with v, such as v1.2.0. The metadata action removes the v to create 1.2.0. |
Step 10: Cleanup
cd ~/work/aws-py-container && ~/.venvs/eb/bin/eb terminate calc-py-container-env --force
cd ~/work/aws-java-container && ~/.venvs/eb/bin/eb terminate calc-java-container-env --force
docker rmi calc-py:local calc-java:local
docker image prune -f
Then delete the GHCR packages from each repository's Packages settings, and remove the AWS_PY_CONTAINER_URL and AWS_JAVA_CONTAINER_URL variables if you won't reuse them. The platform environments from the AWS hands-on are removed by the cleanup steps in that document.