Skip to content
Zero-Downtime Deployment on AWS: Practical Guide with GitHub Actions and GitLab CI/CD
← ← Back to Thinking Cloud

Zero-Downtime Deployment on AWS: Practical Guide with GitHub Actions and GitLab CI/CD


You've migrated your app to AWS — React on CloudFront, Node.js on EC2 with Auto Scaling, PostgreSQL on RDS, Redis on ElastiCache. The infrastructure is solid. But now comes the inevitable question: how do you deploy without taking the site down?

If every deploy means a few seconds (or minutes) of downtime, you lose users, trust, and money. Zero-downtime deployment isn't a luxury — it's a standard. In this article we explore two AWS deployment strategies (Rolling and Blue/Green), compare them, and show how to integrate them with GitHub Actions or GitLab CI/CD.

Why does the app go down during deploy?

On a classic VPS, deploy usually looks like this: you SSH in, run git pull, run npm install, restart the Node.js process. Between stopping the old process and starting the new one, the app doesn't respond. Maybe 5 seconds, maybe 30 — it depends on how long the build and startup take.

On AWS with ALB and Auto Scaling Group, things are different. You have multiple EC2 instances behind a load balancer. The trick is to update instances one at a time (or in batches) so that at least some of them are always serving traffic. There are two main strategies for this.

Strategy 1: Rolling Deployment

Rolling deployment updates instances one by one (or in batches) within the same Auto Scaling Group.

The process works like this: AWS CodeDeploy takes the new version of the code and sends it to the first EC2 instance. The ALB removes that instance from rotation (deregistration), the code is updated, the app restarts, then the ALB adds it back after it passes the health check. The process repeats for each instance.

In practice, if you have 4 instances, at any moment at least 3 are serving traffic. Users notice nothing.

Advantages: you don't need extra resources (you don't double the infrastructure), it's simple to configure, and cost stays the same. Disadvantages: deploy takes longer (each instance updates sequentially), and for a few minutes you run two versions of the app simultaneously — you need to ensure they're compatible.

CodeDeploy configuration for Rolling: in the deployment group, set the type to "In-place" and select the Auto Scaling Group. Under "Deployment settings" choose CodeDeployDefault.OneAtATime for maximum safety or CodeDeployDefault.HalfAtATime for speed. The ALB handles deregistration/registration automatically.

Strategy 2: Blue/Green Deployment

Blue/Green is the premium strategy. Instead of updating existing instances, you create a brand new set of instances (the "green" environment) with the new version, test them, then switch all traffic from the old set (the "blue" environment) to the new one.

The process looks like this: CodeDeploy creates a new Auto Scaling Group with instances running the new version. The new instances register on a separate Target Group of the ALB. Once all new instances pass health checks, the ALB redirects traffic from the old Target Group to the new one — instantly. If something goes wrong, rollback is a simple switch back to the original Target Group.

Advantages: instant rollback (old instances are still running), you can test the green environment before switching traffic, you never run two versions simultaneously. Disadvantages: for a few minutes you pay double (two sets of instances run in parallel), and setup is more complex.

Rolling vs. Blue/Green — When to use which?

Rolling deployment fits apps that can tolerate running two versions at once, small teams that want simplicity, and situations where budget matters (no extra resources).

Blue/Green is the right choice for apps with strict SLAs, when you need instant rollback (under 1 minute), or when you want to validate the new version manually before switching.

For our Node.js + PostgreSQL + Redis app, the practical recommendation is to start with Rolling deployment. It's simpler, cheaper, and covers 90% of needs. Move to Blue/Green when you have a concrete reason — for example, a database migration that requires testing before cutover.

The central piece: AWS CodeDeploy

Regardless of strategy, AWS CodeDeploy orchestrates the deployment process on EC2 instances. You configure it once, then trigger it from GitHub Actions or GitLab CI/CD.

CodeDeploy uses an appspec.yml file in the project root that defines what happens at each deployment stage:

version: 0.0
os: linux
files:
  - source: /
    destination: /home/app/myapp
hooks:
  BeforeInstall:
    - location: scripts/stop_server.sh
      timeout: 60
  AfterInstall:
    - location: scripts/install_dependencies.sh
      timeout: 120
  ApplicationStart:
    - location: scripts/start_server.sh
      timeout: 60
  ValidateService:
    - location: scripts/health_check.sh
      timeout: 60

The scripts do exactly what you'd expect: stop_server.sh stops the Node.js process (graceful shutdown), install_dependencies.sh runs npm ci --production, start_server.sh starts the app, and health_check.sh verifies the /health endpoint returns 200.

One critical detail: graceful shutdown. When the ALB removes an instance from rotation, the deregistration_delay setting (default 300 seconds) lets in-flight requests finish. Make sure your Node.js app handles the SIGTERM signal correctly — finish active requests, close DB and Redis connections, then exit.

Integration with GitHub Actions

Full flow: push to main branch → GitHub Actions runs tests → build is packaged and uploaded to S3 → CodeDeploy is triggered → EC2 instances are updated.

# .github/workflows/deploy.yml
name: Deploy to Production

on:
  push:
    branches: [main]

permissions:
  id-token: write
  contents: read

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
      - run: npm ci
      - run: npm test

  deploy:
    needs: test
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Configure AWS Credentials (OIDC)
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: ${{ secrets.AWS_DEPLOY_ROLE_ARN }}
          aws-region: eu-central-1

      - name: Package and upload to S3
        run: |
          zip -r app-${{ github.sha }}.zip . -x '.git/*'
          aws s3 cp app-${{ github.sha }}.zip \
            s3://my-deploy-bucket/app-${{ github.sha }}.zip

      - name: Trigger CodeDeploy
        run: |
          aws deploy create-deployment \
            --application-name my-app \
            --deployment-group-name production \
            --s3-location bucket=my-deploy-bucket,key=app-${{ github.sha }}.zip,bundleType=zip \
            --description "Deploy ${{ github.sha }}"

Important note on authentication: the example uses OIDC (OpenID Connect) instead of access keys stored as secrets. You configure an Identity Provider in IAM that trusts GitHub, and GitHub Actions receives temporary credentials on each run. No secrets to rotate, no leak risk.

Integration with GitLab CI/CD

Same logic, different syntax. GitLab CI/CD uses the .gitlab-ci.yml file:

# .gitlab-ci.yml
stages:
  - test
  - deploy

variables:
  AWS_DEFAULT_REGION: eu-central-1

test:
  stage: test
  image: node:20-alpine
  script:
    - npm ci
    - npm test

deploy_production:
  stage: deploy
  image: amazon/aws-cli:latest
  only:
    - main
  before_script:
    - apt-get update && apt-get install -y zip
  script:
    - zip -r app-${CI_COMMIT_SHA}.zip . -x '.git/*'
    - aws s3 cp app-${CI_COMMIT_SHA}.zip
        s3://my-deploy-bucket/app-${CI_COMMIT_SHA}.zip
    - aws deploy create-deployment
        --application-name my-app
        --deployment-group-name production
        --s3-location bucket=my-deploy-bucket,key=app-${CI_COMMIT_SHA}.zip,bundleType=zip
        --description "Deploy ${CI_COMMIT_SHA}"
  environment:
    name: production
    url: https://myapp.example.com

For authentication, GitLab also supports OIDC with AWS. The alternative is to set AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY as protected CI/CD variables in Settings → CI/CD → Variables.

GitHub Actions vs. GitLab CI/CD — Quick comparison

Both platforms do the job well. Practical differences are minor: GitHub Actions uses YAML with job and step structure, has a rich marketplace of predefined actions, and integrates natively if your code is already on GitHub. GitLab CI/CD has more compact YAML, offers environments with visual deployment tracking, includes a built-in Container Registry, and is ideal if you're already in the GitLab ecosystem.

The choice depends on where your code lives, not on CI/CD capabilities. Both can trigger CodeDeploy identically.

Checklist before first deploy

A few things to verify before going to production. The /health endpoint in your Node.js app must check the DB and Redis connection, not just return 200. The ALB health check must be configured on this endpoint, with a 15-second interval and threshold of 2 health checks. Graceful shutdown must be implemented — the app must handle SIGTERM correctly. deregistration_delay on the Target Group should be set to at least 30 seconds (default 300 is too long for most apps). Database migrations must be run separately, before deploy, and must be backward-compatible. And last but not least, an automatic rollback mechanism must be configured — CodeDeploy can roll back automatically if a deployment fails.

Conclusion

Zero-downtime deployment isn't rocket science — it's a combination of correctly configured AWS services (ALB + ASG + CodeDeploy) with a CI/CD pipeline that orchestrates them. Start with Rolling deployment, automate with GitHub Actions or GitLab CI/CD, and add complexity (Blue/Green) only when you have a real need.

Most importantly: don't try to do everything from day one. A working rolling deployment beats a half-configured blue/green every day of the week.


Published on teninvent.ro — TEN INVENT S.R.L. provides CI/CD implementation and AWS infrastructure services. Contact us for an assessment of your deployment pipeline.