How deployment works
You push to a branch, GitHub Actions builds a complete deploy revision, and AWS CodeDeploy rolls it out. This page is the single “how it ships” picture. For which environment a branch reaches and what each one is for, see Environments; for the AWS estate underneath, see Infrastructure.
The rail, end-to-end
Section titled “The rail, end-to-end”flowchart LR DEV(Developer):::person -->|PR → uat, then uat → main| GH[GitHub repo<br/>OrascomDevelopment]:::src GH -->|push on uat or main| GHA[GitHub Actions<br/>reusable laravel-deploy.yml]:::ci GHA -->|build self-contained<br/>revision .zip| S3[(S3<br/>orascom-deploy-bucket)]:::store GHA -->|create deployment| CD[AWS CodeDeploy]:::deploy S3 --> CD CD -->|blue/green backend<br/>in-place elsewhere| ASG[EC2 Auto Scaling<br/>Graviton t4g · behind ALB]:::compute classDef person fill:#e7eef6,stroke:#2563A8,color:#0b1b2b; classDef src fill:#e7eef6,stroke:#2563A8,color:#0b1b2b; classDef ci fill:#efe7f7,stroke:#7A4FB5,color:#0b1b2b; classDef deploy fill:#dff0f0,stroke:#0C7C84,color:#0b1b2b; classDef store fill:#eceff1,stroke:#5B6B7A,color:#0b1b2b; classDef compute fill:#f7efdc,stroke:#B7791F,color:#0b1b2b;
- Merge a pull request into
uat(or promoteuat→main) in the GitHub orgOrascomDevelopment. Those are the only two branches that deploy anywhere. - GitHub Actions builds the revision. For the backend and the CRM this is a reusable workflow
(
portals-workflows/laravel-deploy.yml, called at a pinned commit SHA): it installs dependencies and packages a self-contained artifact — the built code plus the CodeDeployappspec.ymland its lifecycle hooks — then uploads it to S3 (orascom-deploy-bucket). This is the key property of the rail: the revision carries the code, so a box never has togit pullto get it. - CodeDeploy deploys the revision. On the box, lifecycle hooks render the environment file from AWS Secrets Manager, run database migrations under a fleet-safe lock, and clear caches — then health-check before any traffic moves.
Per-app × environment
Section titled “Per-app × environment”Derived from the workflow files on each repo’s default branch. UAT changed on 2026-09-22 and the
old UAT fleet was deleted on 2026-10-01: every UAT row below is now the single UAT host (one EC2
instance running the backend, its workers and scheduler, the CRM middleware and the PDF service as
containers), deployed by each repo’s uat-image.yml / uat-host-deploy.yml through
portals-workflows/.github/workflows/uat-host.yml. CodeDeploy is Production only. The host’s
runbook is portals-workflows/uat-host/README.md.
| App | Source repo (GitHub) | UAT · uat |
Production · main |
|---|---|---|---|
| Backend | Orascom-Portals-Backend |
UAT host container, uat-image.yml |
Orascom-Prod-app / orascom-backend-deploy-group blue/green |
| Backend workers (queues/schedule) | (same repo, same revision) | UAT host containers (worker, worker-pdf, scheduler) |
ODH-Prod-Supervisor / prod in-place |
CRM (crms-middleware) |
Orascom-Portals-CRM |
UAT host container, uat-image.yml |
Orascom-Prod-app / orascom-crm-deploy-group blue/green |
| PDF service | Orascom-Portals-pdf-service |
UAT host container, pdf-deploy → uat-host-deploy.yml |
ODH-Pdf / prod in-place container swap |
| Frontend (4 React apps) | Orascom-Portals-Frontend |
AWS Amplify app dojdzrb1syifv |
AWS Amplify app dndjdmne9kvk6 |
| This docs site | orascom-portals-docs |
— | Cloudflare Pages |
- The CRM has no workers — its queue runs synchronously (
QUEUE=sync). - Amplify has one branch per app (
uatandmainrespectively) with branch-levelautoBuildenabled. It is not on the CodeDeploy rail. crm-mockdeploys nowhere. See the CRM mock.
What gates a pull request
Section titled “What gates a pull request”All four application repos carry an active ruleset named no prod push on refs/heads/main and
refs/heads/uat, with zero bypass actors: no force-push, no branch deletion, a pull request is
required, and one approval is required, plus one required status check.
| Repo | Required check | What runs but does not block |
|---|---|---|
| Backend | tests (PHPUnit) |
quality (php-cs-fixer, PHPStan and composer audit are advisory; shellcheck, php -l and the LOCK_NAME assertion can fail it) and semgrep |
| CRM | tests (PHPUnit) |
same |
| PDF service | tests (vitest + build) |
— |
| Frontend | checks |
within checks, lint and build block; the test target runs with continue-on-error |
Two things worth knowing before you rely on a green tick. First, only the one required check per repo can
stop a merge — but that is because the other jobs are not required checks, not because everything in
them is advisory. Inside quality, php-cs-fixer, PHPStan and composer audit are suffixed || true,
while shellcheck (over the deploy hooks), php -l and the MigrateLocked LOCK_NAME assertion can
genuinely fail the job — so a red quality is worth reading, not dismissing. That assertion is the
guard that stops a CRM deploy cross-blocking the backend on the shared database. Second,
strict_required_status_checks_policy is false, so a PR does not have to be up to date with the base
branch before merging.
Automated traffic shift, and the limits of the safety net
Section titled “Automated traffic shift, and the limits of the safety net”All three blue/green deployment groups are configured deploymentReadyOption = CONTINUE_DEPLOYMENT with
waitTimeInMinutes = 0: CodeDeploy stands up the new “green” fleet
(greenFleetProvisioningOption = COPY_AUTO_SCALING_GROUP), health-checks it behind the load balancer,
and shifts traffic automatically — there is no human step. The old “blue” fleet is kept for
terminationWaitTimeInMinutes = 10 before termination, and that ten-minute window is the rollback
window. The manual approval gate used on the first cutover deploys was removed in July and nothing has
been re-added.
Blue/green and rollback
Section titled “Blue/green and rollback”flowchart LR
REV[New revision<br/>from S3]:::store --> GREEN[Green fleet<br/>new EC2 ASG]:::compute
GREEN -->|health-check + migrate| READY{Green healthy<br/>behind ALB?}:::deploy
READY -->|auto-continue| SHIFT[ALB shifts traffic<br/>blue → green]:::ok
READY -->|hook error / health-check fail| RB[No traffic shift<br/>blue keeps serving<br/>green fleet ORPHANED]:::bad
SHIFT --> TERM[Blue terminated<br/>after 10-min window]:::compute
classDef store fill:#eceff1,stroke:#5B6B7A,color:#0b1b2b;
classDef compute fill:#f7efdc,stroke:#B7791F,color:#0b1b2b;
classDef deploy fill:#dff0f0,stroke:#0C7C84,color:#0b1b2b;
classDef ok fill:#dff0e6,stroke:#2f855a,color:#0b1b2b;
classDef bad fill:#f7e0e0,stroke:#c53030,color:#0b1b2b;- Blue/green runs for the backend and the CRM in production (UAT stopped rehearsing it on
2026-09-22: the host does a stop–migrate–start, and the
migration-safetyCI check guards expand/contract on every PR instead). CodeDeploy stands up a new Auto Scaling group running the new revision alongside the live one, shifts traffic at the ALB, and keeps blue briefly so a rollback is a traffic flip rather than a rebuild. - In-place runs everywhere else in production — the supervisor box and the PDF container.
- ⚠️ A deploy that fails before the traffic shift is safe for users but leaves a fleet running. Blue never stops serving, so CodeDeploy records that a rollback was “not required” — and nothing then owns the green Auto Scaling group. It keeps running until someone deletes it. See Known defects for the detection rule, including why the obvious version of it would delete production.
- To roll back, redeploy the previous revision. Because the revision carries the code, that genuinely restores the previous build. This is the rollback lever; there is no pipeline to re-run.
- Database migrations run inside a deploy hook under a fleet-safe lock. The lock serialises,
it does not de-duplicate: whichever box gets there first migrates, and the others wait and then
re-run
migrate, which is a no-op. Seeding is opt-in and off by default. Migrations are expand/contract by discipline so blue and green can run against the same schema during the shift — nothing automated enforces that, so a reviewer has to check the pending diff on every production deploy. - Two hooks guard correctness, and they are not equivalent: the CRM’s
validate_passportruns atBeforeAllowTrafficand is a genuine traffic gate, while the backend’svalidate_pdf_tokenruns atAfterInstall— it fails the deployment early if the backend↔PDF shared token has drifted, but it is not a traffic gate.
How long a deploy takes
Section titled “How long a deploy takes”Measured across successful runs since the 2026-08-13 cutover:
| Rail | Typical duration |
|---|---|
| In-place (CRM on UAT) | ≈ 1 minute |
| Blue/green | 21–61 minutes, median ≈ 25 |
| PDF container swap | 6–8 minutes |
| Frontend (Amplify) | 4–6 minutes |
| This docs site | ≈ 1 minute |
The blue/green figure is the traffic shift, not the build — it is the same artifact for the whole window. The rail is safe by default, and the safety is what costs the time.
Where config comes from
Section titled “Where config comes from”Deploy notifications
Section titled “Deploy notifications”Every deployment posts a status card (✅ / ❌) to the Portals deployments feed in Microsoft Teams — app, environment, status, commit, and a link to the build. (The workflows post to an opaque webhook URL and name no channel, so the destination is described here by function rather than by channel name.)
⚠️ A backend deploy posts two cards, not one. The notify step lives inside the shared reusable workflow,
and the backend invokes that workflow twice — once for web, once for workers — so each stage posts its
own result, and the two can disagree. Seeing two cards does not mean something ran twice. Tracked as
869enhrxj. Every other app posts one.
| Deploy | How the card fires | Where the webhook lives |
|---|---|---|
| Backend & CRM | a notify step in the deploy workflow | Secrets Manager portals/shared/notify-webhook |
| Frontend | an EventBridge rule on Amplify build events → the portals-amplify-teams-notify Lambda |
Secrets Manager portals/shared/notify-webhook |
| PDF service | a notify step in its own workflow | GitHub repo secret NOTIFY_WEBHOOK |
| This docs site | a workflow that fires when Cloudflare’s build check completes | GitHub repo secret NOTIFY_WEBHOOK |
The two GitHub-secret cases are deliberate, not drift: reading the webhook from a repo secret means the
card still fires when an early step fails before AWS credentials are available. The practical
consequence is that rotating the webhook means touching three places: the Secrets Manager entry, and the
NOTIFY_WEBHOOK repo secret in each of the two repositories that carry their own.
What replaced what
Section titled “What replaced what”The old production CodePipelines were kept — disconnected but not deleted — as a manual rollback path for about four weeks after the cutover, which is what earlier versions of this page described. They were deleted on 2026-08-16, along with their artifact bucket. There is no pipeline to fall back to; the rollback lever is a CodeDeploy redeploy of the previous revision.
GitLab has no runtime role. The one repository never migrated is crm-mock, which deploys nowhere at
all.
Operational runbooks
Section titled “Operational runbooks”The blow-by-blow operator procedures (rebake steps, exact deployment-group and instance identifiers,
rollback levers) live in the repo, not on this site. They are in handoff/handoff-vault/ — note the
path, there is no top-level docs/ directory in this repository:
runbook-prod-cutover.md— the production cutover procedure (backend + CRM).runbook-uat-backend-bluegreen.md— the UAT backend blue/green deploy. Historical since 2026-10-01: the old UAT fleet it describes was deleted; UAT is the host, seeportals-workflows/uat-host/README.md.deploy-logic-decoded.md— the old on-box pull logic, decoded.runbook-staging-deploy.md— historical only. Staging was decommissioned on 2026-08-16.
