Skip to content

From your laptop to
an EC2 box, seven
steps, no SSH.

A hands-on course in continuous delivery on AWS. Build the same pipeline twice — once with GitHub Actions, once with Jenkins — and see exactly where the two differ. This page runs both in front of you. Break the tests and watch the deploy never reach AWS.

Six modules · 5 hours · you finish with a workflow file, a Jenkinsfile, and a running deploy.

You'll build Local commit → build → test → registry → server
Two ways GitHub Actions with OIDC, Jenkins on your own EC2
AWS used EC2, ECR, ALB, SSM, IAM, CloudWatch
Prerequisites Git, a Dockerfile, one AWS account
Sample app PHP 8.3 · Composer · phpunit
a1f9c2

The same commit, two very different maps

The steps look identical on the rail above. What actually changes is where the build happens and how it proves its identity to AWS.

AWS · ap-south-1 Your machinegit push GitHub repomain · a1f9c2 Hosted runner ubuntu-24.04 build · test ECRapp:a1f9c2 EC2 · web tier2 instances Application LBhealth check /health CloudWatchlogs · alarms push image ssm send-command docker pull metrics AWS · ap-south-1 · your VPC Your machinegit push GitHub repomain · a1f9c2 Jenkins controller · EC2 t3.small agent: linux-1 ECRapp:a1f9c2 EC2 · web tierprivate subnet Application LBhealth check /health CloudWatchlogs · alarms webhook push image ssh + compose up docker pull metrics
Solid line — traffic you control with IAM Dashed line — the moment the pipeline reaches into your infrastructure

Picking one, honestly

Neither is better. One removes a server from your life, the other gives you a server you fully control. Read down the column that matches your constraints.

Same app, same AWS account, two operating models.
  GitHub Actions Jenkins
Where the build runs GitHub's cloud, outside your VPC An EC2 instance you own, inside the VPC
You maintain A YAML file OS patches, Java, plugins, backups, agents
AWS authentication OIDC role assumption — no stored keys Instance role plus credentials in Jenkins
Reaching a private server Via SSM, or a self-hosted runner in the VPC Direct — it is already on the private network
Cost shape Free minutes, then per build minute EC2 hours, whether you build or not
Config drift risk Low — everything is in the repo Real — UI settings live outside the Jenkinsfile
Approval gates Environment protection rules An input step, which pauses the job
Choose it when Your code is on GitHub and the stack is ordinary Private networks, odd toolchains, existing plugins

Both pipelines, written down

This is the run you watched, in the file it actually lives in. Same steps, same AWS account.

name: deploy-to-aws
on:
  push: { branches: [main] }
  pull_request:

permissions:
  id-token: write
  contents: read

env:
  AWS_REGION: ap-south-1
  ECR: 4155.dkr.ecr.ap-south-1.amazonaws.com/app

jobs:
  build:
    runs-on: ubuntu-24.04
    steps:
      - uses: actions/checkout@v4
      - uses: shivammathur/setup-php@v2
        with: { php-version: 8.3 }
      - run: composer install --no-dev -o
      - run: vendor/bin/phpunit --testdox
      - uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::4155:role/gha-deploy
          aws-region: ap-south-1
      - uses: aws-actions/amazon-ecr-login@v2
      - run: docker build -t app .
      - run: docker push app

  deploy:
    needs: build
    environment: production
    runs-on: ubuntu-24.04
    steps:
      - run: aws ssm send-command --targets Key=tag:App,Values=web
      - run: curl -fsS https://app.example.com/health
pipeline {
  agent { label 'linux-1' }

  environment {
    AWS_REGION = 'ap-south-1'
    ECR        = '4155.dkr.ecr.ap-south-1.amazonaws.com/app'
    EC2_HOST   = '10.0.2.14'
  }

  triggers { githubPush() }

  stages {
    stage('Build') {
      steps {
        sh 'composer install --no-dev -o'
        sh 'docker build -t app .'
      }
    }
    stage('Test') {
      steps { sh 'vendor/bin/phpunit --log-junit reports/junit.xml' }
      post { always { junit 'reports/junit.xml' } }
    }
    stage('Push to ECR') {
      steps {
        withAWS(role: 'jenkins-deploy', region: 'ap-south-1') {
          sh 'docker push app'
        }
      }
    }
    stage('Approve') {
      when { branch 'main' }
      steps { input message: 'Deploy to production?', ok: 'Ship it' }
    }
    stage('Deploy') {
      steps {
        sshagent(['ec2-deploy-key']) {
          sh 'ssh ubuntu@10.0.2.14 docker compose up -d'
        }
      }
    }
  }

  post {
    failure { sh 'ssh ubuntu@10.0.2.14 ./rollback.sh' }
    always  { cleanWs() }
  }
}
Triggers decide your feedback loop Running on pull requests as well as pushes is the difference between catching a break before merge and after.
The id-token permission is the whole trick It lets GitHub mint a short-lived token AWS will trust. No secret access key in your repo, ever.
needs is the contract Deploy cannot start unless build finished green. That line is what makes this a pipeline instead of two scripts.
SSM instead of SSH Run Command reaches instances in a private subnet with no inbound port open and no key to leak.
The Jenkinsfile is the source of truth Anything configured in the Jenkins UI instead of this file is drift you will not notice until it breaks a release.
agent decides where work runs Never build on the controller. Give it agents, so a runaway build cannot take your CI server down with it.
input pauses the job, and holds an executor A useful approval gate, but a job waiting on input is occupying a slot. Add a timeout.
The failure block is your rollback Jenkins gives you a real place to put recovery logic that runs whatever went wrong upstream.

What you wire up once

Everything below happens before your first green pipeline. Do it in this order — each step needs the one above it.

GitHub Actions route

  1. Prepare AWS Create a registry repository, and launch the instances with the SSM agent running and a tag such as App=web.
  2. Add GitHub as an identity provider In IAM, register the GitHub OIDC provider. This is what removes access keys from your repo.
  3. Create the deploy role A role trusted by that provider, scoped to your repo and branch, allowed to push images and run SSM commands. Nothing else.
  4. Store the role ARN Repository variable, not a secret — it is not sensitive. Secrets are for things the role cannot replace.
  5. Commit the workflow Drop deploy.yml into .github/workflows/ and push. The first run is your test.
  6. Close the door Protect main, require the build check, add a production environment with a reviewer.

Jenkins route

  1. Launch the controller A small instance is enough to start. Install Jenkins, Docker and the AWS CLI on it.
  2. Put it behind HTTPS A reverse proxy and a certificate, security group locked to your IPs plus the webhook ranges. An open Jenkins is an open shell.
  3. Attach an instance role Give the controller registry push rights, so no AWS keys are ever typed into Jenkins.
  4. Add credentials The deploy SSH key goes in the Jenkins credentials store, referenced by ID only — never inline in the Jenkinsfile.
  5. Point the webhook at it Add the Jenkins webhook URL in your repository settings. Polling works, but it wastes cycles and lags.
  6. Create a multibranch pipeline Point it at the repo. Jenkins finds the Jenkinsfile per branch and builds pull requests automatically.
0 /wk Deploys, up from one a month Deployment frequency
0 min Commit on your laptop to live on the server Lead time for changes
0 % Releases that need a fix Change failure rate
0 min To restore service after a bad deploy Time to recovery

Six modules, in the order the pipeline runs

Every module is built twice — once per tool — against the same AWS account. Open one to see what is inside.

Where the definition lives, what a runner or agent actually is, and how a push becomes a job.

  • Workflow YAML next to Jenkinsfile — same ideas, different vocabulary
  • Webhooks versus polling, and why polling costs you
  • Jobs, stages and the dependency graph
  • Matrix builds across two PHP versions

One immutable image, built on your CI, tagged with the commit, pushed to a registry.

  • Multi-stage Dockerfiles and layer ordering
  • Caching dependencies between runs
  • Registry lifecycle policies so storage does not creep
  • Tagging with the SHA instead of latest

The gate only works if red means something. Most broken pipelines break here.

  • Unit, integration and a thin end-to-end layer
  • Publishing JUnit reports in both tools
  • Quarantining flaky tests instead of retrying them
  • Coverage as a floor you raise, not a number you chase

How your pipeline proves who it is without leaving keys lying around.

  • OIDC federation and a trust policy scoped to one branch
  • Instance roles for a self-hosted controller
  • Least privilege in practice
  • SSH keys, Session Manager, and why SSM usually wins

Getting the new image onto running instances while requests keep being served.

  • Load balancer connection draining and health check tuning
  • Rolling updates across a scaling group
  • Migrations that survive a rollback
  • A rollback you can run in one command

A pipeline that ends at deploy is half a pipeline.

  • Alarms that trigger the rollback automatically
  • The four DORA metrics, measured honestly
  • Alerts that page a human versus alerts that annoy one
  • Postmortems that produce pipeline changes

Push a commit. Let the pipeline reach your server for you.

Free, self-paced, built around a repo and an AWS account you keep. Start with module one, or scroll back up, switch tools, and break the tests again.