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.

Contents
  1. Session plan and prerequisites
  2. Step 1: Check the tools
  3. Step 2: Python image, build and run locally
  4. Step 3: Java image, build and run locally
  5. Step 4: Make the images pullable
  6. Step 5: Create the Elastic Beanstalk Docker environments
  7. Step 6: CI/CD workflows
  8. Step 7: Set the container URL variables
  9. Step 8: First release and verification
  10. Step 9: Troubleshooting
  11. Step 10: Cleanup

Session plan and prerequisites

TimeStepWhat you finish
0:00 to 0:051. ToolsDocker is running
0:05 to 0:202 and 3. Local imagesBoth apps run in containers on your machine
0:20 to 0:304. Packages publicImages can be pulled without credentials
0:30 to 0:455. Container environmentsTwo Elastic Beanstalk Docker environments
0:45 to 1:056. WorkflowsRelease pipelines that build, test, push, and deploy images
1:05 to 1:157 and 8. Variables and first releaseTag v1.2.0 deploys both containers
1:15 to 1:309 and 10. Troubleshooting and cleanupContainers and images removed

Prerequisites

RequirementHow to check
Docker Desktop or Docker Enginedocker --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 themeb list in the AWS project folders
Releases v1.2.0 in both repositories, with the endpointsReleases page of each repository
Python 3.12 and JDK 17 with Maven, and gh, jq, zippython3 --version, mvn -version, gh --version
The container environments are separate from the platform environments, so both deployment styles can run side by side. The platform environments remain in place for comparison.

Step 1: Check the tools

docker --version
docker info --format '{{.ServerVersion}}'
Apple Silicon Macs: build with --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

The image installs the wheel that the build produced. It does not copy the source tree, so the container contains the same artifact that the tests and the release use.

Step 2.1 Dockerfile and ignore file

Dockerfile (python-ci-demo, repository root)
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"]
.dockerignore (python-ci-demo, repository root)
.venv/
.git/
.github/
reports/
.pytest_cache/
__pycache__/
**/__pycache__/
*.egg-info/
npm/

Explanation

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

Checkpoint: the container answers /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

Dockerfile (java-ci-demo, repository root)
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"]
.dockerignore (java-ci-demo, repository root)
.git/
.github/
.idea/
*.iml
.vscode/
src/

Explanation

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
Checkpoint: the smoke test prints 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:

  1. On GitHub, open your profile and choose Packages.
  2. Open python-ci-demo, choose Package settings, then Change visibility, and set it to Public.
  3. Repeat for java-ci-demo.
If you keep the packages private, the Elastic Beanstalk environment needs registry credentials in its configuration. This document does not cover that setup.

Step 5: Create the Elastic Beanstalk Docker environments About 15 min

Elastic Beanstalk runs a Docker image through a docker-compose.yml file in the bundle. Each container environment is separate from the platform environment, so the two sets can run side by side.

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

The image must exist before the environment can run it. The simplest order is: publish the image with the tag (Step 8), make the package public (Step 4), then create the environment.

Step 6: CI/CD workflows About 20 min

Each workflow runs in this order on a tag: test, build the wheel or jar, publish the GitHub Release, build the image from the release artifacts, smoke-test the image, push it, and deploy the same tag to the container environment.

Step 6.1 Python release workflow

python-ci-demo/.github/workflows/release.yml
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

java-ci-demo/.github/workflows/release.yml
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

JobPurpose
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.
imageBuilds 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-containerWrites a docker-compose.yml that names the pushed tag, uploads it, and smoke-tests the environment. The environment protection rule aws-free applies here.
The image job tests the image before pushing it, so a broken image never reaches the registry. The deploy job then uses the tag that was tested. A deployment cannot run an untested image.

Step 7: Set the container URL variables About 5 min

Run this after the container environments exist (Step 5). The variables are separate from the platform variables from the AWS hands-on, so the two deployment styles don't overwrite each other.

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"
Store the host name without 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

The first tag publishes the images, but the deploy job can't update an environment that doesn't exist yet, and the environment can't start without a published image. So the first release has a set order.

Step 8.1 Merge the Docker files

  1. In each repository, commit the Dockerfile, .dockerignore, and the workflow file to a branch, open a pull request, and merge it into main.
  2. 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

  1. Run Step 7 to set AWS_PY_CONTAINER_URL and AWS_JAVA_CONTAINER_URL.
  2. In each repository's Actions page, open the failed run for v1.2.0 and choose Re-run failed jobs. The deploy-container job 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

Checkpoint: both container environments are Ok in the Elastic Beanstalk console, and both endpoints respond from the Docker images.

Step 9: Troubleshooting

SymptomCause and fix
failed to compute cache key: "/dist/*.whl" not foundBuild the wheel before docker build, with python -m build.
exec format error on Elastic BeanstalkThe image is arm64. Build it with --platform linux/amd64. The CI runner already builds amd64.
denied: permission_denied when pushingThe workflow needs packages: write, which both workflows set. Check the package's access settings for the repository.
Environment Degraded, with image pull errorsThe 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 foundCreate the environment in Step 5, then re-run the deploy job.
Smoke test fails in the image jobThe job prints docker logs calc-smoke. Read the first traceback.
Port 8000 or 8080 already in use locallyStop 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 BeanstalkCheck 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 wrongCheck 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.