DO-1743: pin third-party GitHub Actions to commit SHAs for security #6
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: π³ Docker ECR Deployment | ||
|
Check failure on line 1 in .github/workflows/docker-ecr-deploy.yml
|
||
| on: | ||
| workflow_call: | ||
| inputs: | ||
| # Core Configuration | ||
| aws-region: | ||
| description: "AWS region for ECR registry" | ||
| type: string | ||
| required: false | ||
| default: "ap-southeast-2" | ||
| ecr-repository: | ||
| description: "ECR repository name (required)" | ||
| type: string | ||
| required: true | ||
| dockerfile-path: | ||
| description: "Path to Dockerfile" | ||
| type: string | ||
| required: false | ||
| default: "Dockerfile" | ||
| build-context: | ||
| description: "Docker build context path" | ||
| type: string | ||
| required: false | ||
| default: "." | ||
| # Platform and Build Configuration | ||
| platforms: | ||
| description: "Target platforms for multi-platform builds" | ||
| type: string | ||
| required: false | ||
| default: "linux/amd64,linux/arm64" | ||
| push-to-registry: | ||
| description: "Push built images to ECR registry" | ||
| type: boolean | ||
| required: false | ||
| default: true | ||
| # Security and Scanning | ||
| vulnerability-scan: | ||
| description: "Enable container vulnerability scanning" | ||
| type: boolean | ||
| required: false | ||
| default: true | ||
| security-threshold: | ||
| description: "Security vulnerability threshold (CRITICAL/HIGH/MEDIUM/LOW)" | ||
| type: string | ||
| required: false | ||
| default: "HIGH" | ||
| # Tagging Strategy | ||
| tag-strategy: | ||
| description: "Image tagging strategy (latest/semantic/branch/custom)" | ||
| type: string | ||
| required: false | ||
| default: "latest" | ||
| custom-tags: | ||
| description: "Custom tags (comma-separated) when using custom strategy" | ||
| type: string | ||
| required: false | ||
| default: "" | ||
| # Build Optimization | ||
| cache-from: | ||
| description: "Cache sources for build optimization (comma-separated)" | ||
| type: string | ||
| required: false | ||
| default: "" | ||
| build-args: | ||
| description: "Docker build arguments as JSON object" | ||
| type: string | ||
| required: false | ||
| default: "{}" | ||
| target-stage: | ||
| description: "Target build stage for multi-stage Dockerfiles" | ||
| type: string | ||
| required: false | ||
| default: "" | ||
| # Registry Management | ||
| cleanup-old-images: | ||
| description: "Clean up old images from ECR registry" | ||
| type: boolean | ||
| required: false | ||
| default: false | ||
| retention-count: | ||
| description: "Number of images to retain when cleaning up" | ||
| type: string | ||
| required: false | ||
| default: "10" | ||
| # Container Signing | ||
| enable-signing: | ||
| description: "Enable container image signing with cosign" | ||
| type: boolean | ||
| required: false | ||
| default: false | ||
| # Advanced Configuration | ||
| debug: | ||
| description: "Enable verbose logging and debug output" | ||
| type: boolean | ||
| required: false | ||
| default: false | ||
| secrets: | ||
| aws-access-key-id: | ||
| description: "AWS access key ID" | ||
| required: true | ||
| aws-secret-access-key: | ||
| description: "AWS secret access key" | ||
| required: true | ||
| container-signing-key: | ||
| description: "Private key for container signing (optional)" | ||
| required: false | ||
| outputs: | ||
| image-uri: | ||
| description: "Full URI of the built container image" | ||
| value: ${{ jobs.build.outputs.image-uri }} | ||
| image-digest: | ||
| description: "SHA256 digest of the built image" | ||
| value: ${{ jobs.build.outputs.image-digest }} | ||
| image-tags: | ||
| description: "Applied image tags as JSON array" | ||
| value: ${{ jobs.build.outputs.image-tags }} | ||
| vulnerability-report: | ||
| description: "Container vulnerability scan results" | ||
| value: ${{ jobs.scan.outputs.vulnerability-report }} | ||
| jobs: | ||
| # Validate inputs and prepare build configuration | ||
| prepare: | ||
| name: π Prepare Docker Build | ||
| runs-on: ubuntu-latest | ||
| outputs: | ||
| image-tags: ${{ steps.tag-config.outputs.tags }} | ||
| build-args: ${{ steps.build-config.outputs.args }} | ||
| cache-config: ${{ steps.cache-config.outputs.setup }} | ||
| platforms: ${{ steps.platform-config.outputs.platforms }} | ||
| security-scan: ${{ steps.security-config.outputs.enabled }} | ||
| steps: | ||
| - name: Validate required inputs | ||
| run: | | ||
| echo "π Validating Docker build configuration..." | ||
| if [ -z "${{ inputs.ecr-repository }}" ]; then | ||
| echo "β Error: ecr-repository is required" | ||
| exit 1 | ||
| fi | ||
| # Validate security threshold | ||
| case "${{ inputs.security-threshold }}" in | ||
| CRITICAL|HIGH|MEDIUM|LOW) | ||
| echo "β Security threshold: ${{ inputs.security-threshold }}" | ||
| ;; | ||
| *) | ||
| echo "β Error: security-threshold must be one of: CRITICAL, HIGH, MEDIUM, LOW" | ||
| exit 1 | ||
| ;; | ||
| esac | ||
| # Validate tag strategy | ||
| case "${{ inputs.tag-strategy }}" in | ||
| latest|semantic|branch|custom) | ||
| echo "β Tag strategy: ${{ inputs.tag-strategy }}" | ||
| ;; | ||
| *) | ||
| echo "β Error: tag-strategy must be one of: latest, semantic, branch, custom" | ||
| exit 1 | ||
| ;; | ||
| esac | ||
| # Validate build args JSON if provided | ||
| if [ "${{ inputs.build-args }}" != "{}" ]; then | ||
| echo '${{ inputs.build-args }}' | jq . > /dev/null | ||
| if [ $? -ne 0 ]; then | ||
| echo "β Error: build-args must be valid JSON" | ||
| exit 1 | ||
| fi | ||
| fi | ||
| echo "β All inputs validated successfully" | ||
| - name: Configure image tags | ||
| id: tag-config | ||
| run: | | ||
| echo "π·οΈ Configuring image tags..." | ||
| tags="" | ||
| case "${{ inputs.tag-strategy }}" in | ||
| latest) | ||
| tags="latest" | ||
| if [ "${{ github.ref_type }}" = "tag" ]; then | ||
| tags="$tags,${{ github.ref_name }}" | ||
| fi | ||
| ;; | ||
| semantic) | ||
| if [ "${{ github.ref_type }}" = "tag" ]; then | ||
| tag_name="${{ github.ref_name }}" | ||
| tags="$tag_name" | ||
| # Extract semantic version components | ||
| if [[ $tag_name =~ ^v?([0-9]+)\.([0-9]+)\.([0-9]+) ]]; then | ||
| major="${BASH_REMATCH[1]}" | ||
| minor="${BASH_REMATCH[2]}" | ||
| patch="${BASH_REMATCH[3]}" | ||
| tags="$tags,$major,$major.$minor,$major.$minor.$patch" | ||
| fi | ||
| else | ||
| tags="latest" | ||
| fi | ||
| ;; | ||
| branch) | ||
| branch_name=$(echo "${{ github.ref_name }}" | sed 's/[^a-zA-Z0-9.-]/-/g') | ||
| tags="$branch_name" | ||
| if [ "${{ github.ref_name }}" = "main" ] || [ "${{ github.ref_name }}" = "master" ]; then | ||
| tags="$tags,latest" | ||
| fi | ||
| ;; | ||
| custom) | ||
| if [ -n "${{ inputs.custom-tags }}" ]; then | ||
| tags="${{ inputs.custom-tags }}" | ||
| else | ||
| echo "β Error: custom-tags must be provided when using custom strategy" | ||
| exit 1 | ||
| fi | ||
| ;; | ||
| esac | ||
| # Add commit SHA tag | ||
| short_sha=$(echo "${{ github.sha }}" | cut -c1-7) | ||
| tags="$tags,sha-$short_sha" | ||
| echo "tags=$tags" >> $GITHUB_OUTPUT | ||
| echo "β Tags configured: $tags" | ||
| - name: Configure build arguments | ||
| id: build-config | ||
| run: | | ||
| echo "βοΈ Configuring build arguments..." | ||
| build_args="" | ||
| # Add default build args | ||
| build_args="$build_args --build-arg BUILDKIT_INLINE_CACHE=1" | ||
| build_args="$build_args --build-arg BUILD_DATE=$(date -u +'%Y-%m-%dT%H:%M:%SZ')" | ||
| build_args="$build_args --build-arg VCS_REF=${{ github.sha }}" | ||
| build_args="$build_args --build-arg VERSION=${{ github.ref_name }}" | ||
| # Add custom build args | ||
| if [ "${{ inputs.build-args }}" != "{}" ]; then | ||
| echo '${{ inputs.build-args }}' | jq -r 'to_entries[] | "--build-arg \(.key)=\(.value)"' | while read -r arg; do | ||
| build_args="$build_args $arg" | ||
| done | ||
| fi | ||
| echo "args=$build_args" >> $GITHUB_OUTPUT | ||
| echo "β Build arguments configured" | ||
| - name: Configure cache settings | ||
| id: cache-config | ||
| run: | | ||
| echo "πΎ Configuring build cache..." | ||
| cache_from="" | ||
| if [ -n "${{ inputs.cache-from }}" ]; then | ||
| # Convert comma-separated cache sources to buildx format | ||
| IFS=',' read -ra CACHE_SOURCES <<< "${{ inputs.cache-from }}" | ||
| for source in "${CACHE_SOURCES[@]}"; do | ||
| cache_from="$cache_from --cache-from type=registry,ref=$source" | ||
| done | ||
| fi | ||
| # Add ECR cache source | ||
| ecr_uri="${{ inputs.ecr-repository }}:buildcache" | ||
| cache_from="$cache_from --cache-from type=registry,ref=$ecr_uri" | ||
| cache_to="--cache-to type=registry,ref=$ecr_uri,mode=max" | ||
| echo "setup=$cache_from $cache_to" >> $GITHUB_OUTPUT | ||
| echo "β Cache configuration prepared" | ||
| - name: Configure platforms | ||
| id: platform-config | ||
| run: | | ||
| echo "ποΈ Configuring build platforms..." | ||
| platforms="${{ inputs.platforms }}" | ||
| # Validate platforms | ||
| IFS=',' read -ra PLATFORM_LIST <<< "$platforms" | ||
| for platform in "${PLATFORM_LIST[@]}"; do | ||
| case "$platform" in | ||
| linux/amd64|linux/arm64|linux/arm/v7|linux/arm/v8) | ||
| echo "β Platform supported: $platform" | ||
| ;; | ||
| *) | ||
| echo "β οΈ Warning: Unusual platform: $platform" | ||
| ;; | ||
| esac | ||
| done | ||
| echo "platforms=$platforms" >> $GITHUB_OUTPUT | ||
| echo "β Platforms configured: $platforms" | ||
| - name: Configure security scanning | ||
| id: security-config | ||
| run: | | ||
| echo "π Configuring security settings..." | ||
| scan_enabled="${{ inputs.vulnerability-scan }}" | ||
| if [ "$scan_enabled" = "true" ]; then | ||
| echo "enabled=true" >> $GITHUB_OUTPUT | ||
| echo "β Vulnerability scanning enabled (threshold: ${{ inputs.security-threshold }})" | ||
| else | ||
| echo "enabled=false" >> $GITHUB_OUTPUT | ||
| echo "βΉοΈ Vulnerability scanning disabled" | ||
| fi | ||
| # Build and push Docker image | ||
| build: | ||
| name: ποΈ Build & Push Docker Image | ||
| runs-on: ubuntu-latest | ||
| needs: prepare | ||
| outputs: | ||
| image-uri: ${{ steps.build.outputs.image-uri }} | ||
| image-digest: ${{ steps.build.outputs.digest }} | ||
| image-tags: ${{ steps.build.outputs.tags }} | ||
| steps: | ||
| - name: Checkout | ||
| uses: actions/checkout@v4 | ||
| - name: Set up Docker Buildx | ||
| uses: docker/setup-buildx-action@v3 | ||
| with: | ||
| platforms: ${{ needs.prepare.outputs.platforms }} | ||
| driver-opts: | | ||
| network=host | ||
| - name: Configure AWS credentials | ||
| uses: aws-actions/configure-aws-credentials@v4 | ||
| with: | ||
| aws-access-key-id: ${{ secrets.aws-access-key-id }} | ||
| aws-secret-access-key: ${{ secrets.aws-secret-access-key }} | ||
| aws-region: ${{ inputs.aws-region }} | ||
| - name: Login to Amazon ECR | ||
| id: login-ecr | ||
| uses: aws-actions/amazon-ecr-login@v2 | ||
| with: | ||
| mask-password: 'true' | ||
| - name: Create ECR repository if needed | ||
| run: | | ||
| echo "ποΈ Ensuring ECR repository exists..." | ||
| aws ecr describe-repositories --repository-names ${{ inputs.ecr-repository }} --region ${{ inputs.aws-region }} 2>/dev/null || { | ||
| echo "π¦ Creating ECR repository: ${{ inputs.ecr-repository }}" | ||
| aws ecr create-repository \ | ||
| --repository-name ${{ inputs.ecr-repository }} \ | ||
| --region ${{ inputs.aws-region }} \ | ||
| --image-scanning-configuration scanOnPush=true \ | ||
| --encryption-configuration encryptionType=AES256 | ||
| # Set lifecycle policy | ||
| aws ecr put-lifecycle-policy \ | ||
| --repository-name ${{ inputs.ecr-repository }} \ | ||
| --region ${{ inputs.aws-region }} \ | ||
| --lifecycle-policy-text '{ | ||
| "rules": [ | ||
| { | ||
| "rulePriority": 1, | ||
| "description": "Keep last 30 images", | ||
| "selection": { | ||
| "tagStatus": "any", | ||
| "countType": "imageCountMoreThan", | ||
| "countNumber": 30 | ||
| }, | ||
| "action": { | ||
| "type": "expire" | ||
| } | ||
| } | ||
| ] | ||
| }' | ||
| } | ||
| echo "β ECR repository ready" | ||
| - name: Build and push Docker image | ||
| id: build | ||
| uses: docker/build-push-action@v5 | ||
| with: | ||
| context: ${{ inputs.build-context }} | ||
| file: ${{ inputs.dockerfile-path }} | ||
| platforms: ${{ needs.prepare.outputs.platforms }} | ||
| push: ${{ inputs.push-to-registry }} | ||
| tags: | | ||
| ${{ steps.login-ecr.outputs.registry }}/${{ inputs.ecr-repository }}:${{ needs.prepare.outputs.image-tags }} | ||
| target: ${{ inputs.target-stage }} | ||
| cache-from: ${{ needs.prepare.outputs.cache-config }} | ||
| cache-to: type=inline | ||
| provenance: true | ||
| sbom: true | ||
| outputs: type=image,name=${{ steps.login-ecr.outputs.registry }}/${{ inputs.ecr-repository }},push=${{ inputs.push-to-registry }} | ||
| - name: Extract build metadata | ||
| id: metadata | ||
| run: | | ||
| echo "π Extracting build metadata..." | ||
| registry="${{ steps.login-ecr.outputs.registry }}" | ||
| repository="${{ inputs.ecr-repository }}" | ||
| # Get image digest from build output | ||
| digest="${{ steps.build.outputs.digest }}" | ||
| # Construct image URI | ||
| image_uri="$registry/$repository@$digest" | ||
| # Format tags as JSON array | ||
| tags_array=$(echo "${{ needs.prepare.outputs.image-tags }}" | sed 's/,/","/g' | sed 's/^/["/' | sed 's/$/"]/') | ||
| echo "image-uri=$image_uri" >> $GITHUB_OUTPUT | ||
| echo "digest=$digest" >> $GITHUB_OUTPUT | ||
| echo "tags=$tags_array" >> $GITHUB_OUTPUT | ||
| echo "β Image built: $image_uri" | ||
| echo "π·οΈ Tags applied: ${{ needs.prepare.outputs.image-tags }}" | ||
| - name: Sign container image | ||
| if: inputs.enable-signing == true && secrets.container-signing-key != '' | ||
| uses: sigstore/cosign-installer@398d4b0eeef1380460a10c8013a76f728fb906ac # v3 | ||
| - name: Sign the container image | ||
| if: inputs.enable-signing == true && secrets.container-signing-key != '' | ||
| run: | | ||
| echo "βοΈ Signing container image..." | ||
| echo "${{ secrets.container-signing-key }}" > cosign.key | ||
| cosign sign --key cosign.key \ | ||
| ${{ steps.metadata.outputs.image-uri }} | ||
| rm cosign.key | ||
| echo "β Image signed successfully" | ||
| # Vulnerability scanning | ||
| scan: | ||
| name: π Security Vulnerability Scan | ||
| runs-on: ubuntu-latest | ||
| needs: [prepare, build] | ||
| if: needs.prepare.outputs.security-scan == 'true' && needs.build.result == 'success' | ||
| outputs: | ||
| vulnerability-report: ${{ steps.scan.outputs.report }} | ||
| security-status: ${{ steps.scan.outputs.status }} | ||
| steps: | ||
| - name: Configure AWS credentials | ||
| uses: aws-actions/configure-aws-credentials@v4 | ||
| with: | ||
| aws-access-key-id: ${{ secrets.aws-access-key-id }} | ||
| aws-secret-access-key: ${{ secrets.aws-secret-access-key }} | ||
| aws-region: ${{ inputs.aws-region }} | ||
| - name: Run Trivy vulnerability scanner | ||
| id: scan | ||
| uses: aquasecurity/trivy-action@77137e9dc3ab1b329b7c8a38c2eb7475850a14e8 # master | ||
| with: | ||
| image-ref: ${{ needs.build.outputs.image-uri }} | ||
| format: 'sarif' | ||
| output: 'trivy-results.sarif' | ||
| severity: 'CRITICAL,HIGH,MEDIUM,LOW' | ||
| - name: Analyze vulnerability results | ||
| id: analyze | ||
| run: | | ||
| echo "π Analyzing vulnerability scan results..." | ||
| # Parse SARIF results | ||
| critical_count=$(jq '[.runs[].results[] | select(.level == "error")] | length' trivy-results.sarif) | ||
| high_count=$(jq '[.runs[].results[] | select(.level == "warning")] | length' trivy-results.sarif) | ||
| medium_count=$(jq '[.runs[].results[] | select(.level == "note")] | length' trivy-results.sarif) | ||
| echo "π΄ Critical vulnerabilities: $critical_count" | ||
| echo "π High vulnerabilities: $high_count" | ||
| echo "π‘ Medium vulnerabilities: $medium_count" | ||
| # Check against threshold | ||
| case "${{ inputs.security-threshold }}" in | ||
| CRITICAL) | ||
| if [ "$critical_count" -gt 0 ]; then | ||
| echo "β Critical vulnerabilities found - blocking deployment" | ||
| echo "status=failed" >> $GITHUB_OUTPUT | ||
| exit 1 | ||
| fi | ||
| ;; | ||
| HIGH) | ||
| if [ "$critical_count" -gt 0 ] || [ "$high_count" -gt 0 ]; then | ||
| echo "β High or critical vulnerabilities found - blocking deployment" | ||
| echo "status=failed" >> $GITHUB_OUTPUT | ||
| exit 1 | ||
| fi | ||
| ;; | ||
| MEDIUM) | ||
| if [ "$critical_count" -gt 0 ] || [ "$high_count" -gt 0 ] || [ "$medium_count" -gt 0 ]; then | ||
| echo "β Medium, high, or critical vulnerabilities found - blocking deployment" | ||
| echo "status=failed" >> $GITHUB_OUTPUT | ||
| exit 1 | ||
| fi | ||
| ;; | ||
| LOW) | ||
| echo "βΉοΈ All vulnerabilities reported for awareness" | ||
| ;; | ||
| esac | ||
| echo "status=passed" >> $GITHUB_OUTPUT | ||
| echo "β Vulnerability scan passed security threshold" | ||
| # Create summary report | ||
| report=$(jq -c '{ | ||
| critical: [.runs[].results[] | select(.level == "error")] | length, | ||
| high: [.runs[].results[] | select(.level == "warning")] | length, | ||
| medium: [.runs[].results[] | select(.level == "note")] | length, | ||
| threshold: "${{ inputs.security-threshold }}", | ||
| status: "passed" | ||
| }' trivy-results.sarif) | ||
| echo "report=$report" >> $GITHUB_OUTPUT | ||
| - name: Upload Trivy scan results | ||
| uses: actions/upload-artifact@v4 | ||
| with: | ||
| name: trivy-results | ||
| path: trivy-results.sarif | ||
| retention-days: 30 | ||
| - name: Upload to GitHub Security tab | ||
| if: always() | ||
| uses: github/codeql-action/upload-sarif@v3 | ||
| with: | ||
| sarif_file: trivy-results.sarif | ||
| # Registry cleanup | ||
| cleanup: | ||
| name: π§Ή Registry Cleanup | ||
| runs-on: ubuntu-latest | ||
| needs: [prepare, build, scan] | ||
| if: inputs.cleanup-old-images == true && (needs.scan.result == 'success' || needs.scan.result == 'skipped') && needs.build.result == 'success' | ||
| steps: | ||
| - name: Configure AWS credentials | ||
| uses: aws-actions/configure-aws-credentials@v4 | ||
| with: | ||
| aws-access-key-id: ${{ secrets.aws-access-key-id }} | ||
| aws-secret-access-key: ${{ secrets.aws-secret-access-key }} | ||
| aws-region: ${{ inputs.aws-region }} | ||
| - name: Clean up old images | ||
| run: | | ||
| echo "π§Ή Cleaning up old images from ECR..." | ||
| # Get list of images sorted by pushed date | ||
| images=$(aws ecr describe-images \ | ||
| --repository-name ${{ inputs.ecr-repository }} \ | ||
| --region ${{ inputs.aws-region }} \ | ||
| --query 'sort_by(imageDetails, &imagePushedAt)[:-${{ inputs.retention-count }}].imageDigest' \ | ||
| --output text) | ||
| if [ -n "$images" ] && [ "$images" != "None" ]; then | ||
| echo "ποΈ Found $(echo $images | wc -w) images to delete" | ||
| for digest in $images; do | ||
| echo "Deleting image: $digest" | ||
| aws ecr batch-delete-image \ | ||
| --repository-name ${{ inputs.ecr-repository }} \ | ||
| --region ${{ inputs.aws-region }} \ | ||
| --image-ids imageDigest=$digest | ||
| done | ||
| echo "β Cleanup completed" | ||
| else | ||
| echo "βΉοΈ No images to clean up" | ||
| fi | ||
| # Deployment summary | ||
| summary: | ||
| name: π Deployment Summary | ||
| runs-on: ubuntu-latest | ||
| needs: [prepare, build, scan, cleanup] | ||
| if: always() && needs.build.result == 'success' | ||
| steps: | ||
| - name: Display deployment summary | ||
| run: | | ||
| echo "π Docker ECR Deployment Summary" | ||
| echo "==================================" | ||
| echo "Repository: ${{ inputs.ecr-repository }}" | ||
| echo "Region: ${{ inputs.aws-region }}" | ||
| echo "Platforms: ${{ needs.prepare.outputs.platforms }}" | ||
| echo "Image URI: ${{ needs.build.outputs.image-uri }}" | ||
| echo "Tags: ${{ needs.prepare.outputs.image-tags }}" | ||
| echo "" | ||
| if [ "${{ inputs.vulnerability-scan }}" = "true" ]; then | ||
| if [ "${{ needs.scan.result }}" = "success" ]; then | ||
| echo "π Security scan: β PASSED" | ||
| echo "Security threshold: ${{ inputs.security-threshold }}" | ||
| else | ||
| echo "π Security scan: β FAILED" | ||
| fi | ||
| else | ||
| echo "π Security scan: βοΈ SKIPPED" | ||
| fi | ||
| if [ "${{ inputs.cleanup-old-images }}" = "true" ]; then | ||
| if [ "${{ needs.cleanup.result }}" = "success" ]; then | ||
| echo "π§Ή Registry cleanup: β COMPLETED" | ||
| else | ||
| echo "π§Ή Registry cleanup: β FAILED" | ||
| fi | ||
| else | ||
| echo "π§Ή Registry cleanup: βοΈ SKIPPED" | ||
| fi | ||
| if [ "${{ inputs.enable-signing }}" = "true" ]; then | ||
| echo "βοΈ Image signing: β ENABLED" | ||
| else | ||
| echo "βοΈ Image signing: βοΈ DISABLED" | ||
| fi | ||
| echo "" | ||
| echo "π Docker deployment completed successfully!" | ||