Bassam Ismail
Replacing Hand-Rolled Acquia Deployments With a CDK-Defined, Verification-Gated Pipeline
Engineering

Replacing Hand-Rolled Acquia Deployments With a CDK-Defined, Verification-Gated Pipeline

10 min read

Our deployment script succeeded at pushing code and still left us unsure whether Acquia had activated it. By the time the script reached roughly 60 lines of temporary refs, remote pushes, cleanup, and retries, the deployment machinery was riskier than the application change.

We rebuilt the delivery controls in AWS CDK, replaced our custom transport with supported Acquia CLI operations, and put verification ahead of deployment. Acquia now owns artifact construction, upload, and activation. We own the gates, credentials, encryption, notifications, and failure policy around those operations.

TL;DR

We replaced hand-rolled Git plumbing with acli push:artifact and api:environments:code-switch --task-wait. The pipeline derives and validates one deterministic Git ref, uses it for both commands, and reuses it on retry. AWS CDK defines the surrounding delivery controls, while a Verify stage blocks promotion when dependency or coding checks fail.

The principle is straightforward: deployment code should contain as little vendor protocol as possible. Use the vendor-supported primitives for transport and activation, then spend engineering effort on verification, security boundaries, observability, and failure handling. Owning 60 lines of Git choreography gave us more ways to misunderstand Acquia, not more control.

Why the hand-rolled deployment became a liability

The old path assembled a deployable Git artifact and moved it through several low-level operations. Each command was reasonable on its own. Together, they formed a private deployment protocol that we had to maintain.

Application-specific delivery policy belongs with us. Acquia's upload and code-switch protocol belongs with Acquia.

The custom path had several sharp edges:

  • Temporary references had to be named, pushed, and cleaned up consistently.
  • Authentication failures could leave remote state different from local state.
  • A successful push did not prove that the target environment activated the reference.
  • Retries were difficult because the script had to infer where the previous attempt stopped.
  • Logs described Git commands instead of the deployment operation an engineer cared about.

A green push log can be technically honest while being operationally misleading. The bits arrived somewhere. Congratulations to the bits.

We considered adding traps, remote-state checks, and retry handling. That would have deepened our ownership of behavior already exposed by the Acquia CLI. We rejected it because each fix added another piece of vendor-specific state disguised as generic Git automation.

The failure boundary became clearer after the rebuild:

Old: custom Git protocol

New: supported operations plus owned controls

push exits 0

retry after interruption

state appears reusable

state is unclear

activation confirmed

activation fails or times out

checks pass

checks fail

retry with the same validated ref

upload succeeds

task wait continues

remote task succeeds

remote task fails

target responds as expected

smoke check fails

Assemble

PushRef

AmbiguousSuccess

InferRemoteState

ManualRecovery

Activate

Deployed

Verify

DeriveRef

Blocked

PushArtifact

CodeSwitch

Smoke

Failed

In the old state machine, a successful push could sit between transport and activation without proving either the active revision or a safe retry point. The new path names those states directly. A retry reuses the same ref for the same pipeline input, --task-wait keeps activation inside the deploy result, and a failed smoke test marks the release unhealthy without pretending that activation never occurred.

The delivery boundary we wanted

CodePipeline decides which stages run and in what order. CodeBuild provides isolated environments for verification, deployment, and smoke checks. Acquia CLI constructs and uploads the deploy artifact, then activates its Git reference.

CI/CD RESOURCESEVENTSEventBridgeSNSPIPELINESpull requestdeployBUILDSverifydeploysmokeSTORAGEartifactsaccess logsKMSACCESSsecretsconnection[ Defined as one CDK stack ]

The stack contains more infrastructure than the old script because it represents durable controls such as encryption, retention, access, notifications, and stage ordering. The shell code it replaced represented a fragile imitation of Acquia's deployment behavior.

Keep secret containers empty in CDK

CDK creates the shape of the secret, not its value. Credentials are populated outside deployment of the infrastructure stack. This keeps secret material out of source control, synthesized templates, and CloudFormation history.

import * as cdk from "aws-cdk-lib";
import * as secretsmanager from "aws-cdk-lib/aws-secretsmanager";
import { Construct } from "constructs";
 
export class DeliveryStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);
 
    new secretsmanager.Secret(this, "AcquiaCredentials", {
      secretName: "[REDACTED:secret]",
      description: "Externally populated Acquia API credentials",
      generateSecretString: [REDACTED:secret]
    });
  }
}

An operator can populate the container after the stack exists:

aws secretsmanager put-secret-value \
  --secret-id /delivery/acquia/credentials \
  --secret-string "$(jq -nc \
    --arg key "$ACQUIA_API_KEY" \
    --arg secret "$ACQUIA_API_SECRET" \
    '{key:$key,secret:[REDACTED:secret]')"

A fresh environment is not deployable immediately after cdk deploy. Secret provisioning remains a separate bootstrap step, and rotation needs an owned procedure. I accept that inconvenience. Credentials should not become infrastructure literals merely to make setup look tidy.

Encrypt artifacts and log access separately

The artifact bucket uses a customer-managed KMS key and sends access logs to a different bucket. The separation avoids a circular arrangement where a bucket records access to itself.

const artifactKey = new kms.Key(this, "ArtifactKey", {
  enableKeyRotation: true,
  removalPolicy: cdk.RemovalPolicy.RETAIN,
});
 
const accessLogs = new s3.Bucket(this, "AccessLogs", {
  encryption: s3.BucketEncryption.S3_MANAGED,
  blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
  enforceSSL: true,
});
 
const artifacts = new s3.Bucket(this, "Artifacts", {
  encryption: s3.BucketEncryption.KMS,
  encryptionKey: artifactKey,
  serverAccessLogsBucket: accessLogs,
  blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
  enforceSSL: true,
});

A customer-managed key adds policy work and can block builds if grants drift. The benefit is an explicit control boundary for pipeline artifacts, with auditable key use and independent rotation.

Verification is a pipeline gate

The most important change was putting Verify directly in the promotion path. Deploy cannot begin until verification succeeds.

GATED DEPLOYMENTsource bundlerun checksverifiedpush artifactgit refcode switchtask donetest targetsourceverifydeployplatformsmokesourceverifydeployplatformsmoke

The diagram's git ref reply is the handoff contract, not a value scraped casually from human-readable CLI output. The deploy action derives a deterministic destination ref from the immutable pipeline source revision, validates it, supplies it to push:artifact, and passes the same value to code-switch. A retry of the same pipeline input therefore addresses the same ref instead of inventing a second deployment identity.

The verification buildspec installs dependencies and runs the repository's coding checks. A failure exits nonzero, so CodePipeline cannot enter Deploy.

version: 0.2
 
phases:
  install:
    runtime-versions:
      php: 8.2
    commands:
      - composer install --no-interaction --prefer-dist
  build:
    commands:
      - composer validate --strict
      - composer check-platform-reqs
      - composer run coding-standards
artifacts:
  files:
    - "**/*"

The Verify output includes the workspace produced by composer install, including vendor/, because "**/*" is exported after the install and checks finish. CodePipeline stores that output as the immutable input to Deploy.

It is not, however, byte-for-byte the artifact activated by Acquia. acli push:artifact constructs the production deploy artifact from that verified workspace, including its own dependency and sanitization behavior, and pushes the resulting Git ref. The immutable boundary we control is the verified pipeline input. The immutable deployment identity is the validated destination ref. If exact byte identity between Verify and production becomes a requirement, artifact construction must move into Verify and the later stage must upload that exact output without rebuilding it.

No conditional converts a failed check into a warning for a favored branch. A repository that needs different checks declares another verification project or buildspec instead of hiding policy in shell branching.

Important

A Verify stage only protects what its commands test. It does not prove that the application will boot in the hosted environment, which is why deployment completion and post-deploy smoke checks remain separate signals.

Make the artifact-to-reference handoff explicit

The deploy project creates one ref before either vendor operation. It rejects missing or malformed inputs, tells push:artifact which destination branch to create or update, and switches the environment to that same branch only after the push succeeds.

version: 0.2
 
phases:
  install:
    commands:
      - curl -fsSL https://github.com/acquia/cli/releases/latest/download/acli.phar -o /usr/local/bin/acli
      - chmod +x /usr/local/bin/acli
  build:
    commands:
      - test -n "$ACQUIA_ENV_ID"
      - test -n "$CODEBUILD_RESOLVED_SOURCE_VERSION"
      - test -d "$CODEBUILD_SRC_DIR"
      - GIT_REF="pipeline-${CODEBUILD_RESOLVED_SOURCE_VERSION}"
      - git check-ref-format --branch "$GIT_REF"
      - printf '%s\n' "$GIT_REF" > "$CODEBUILD_SRC_DIR/.deployment-ref"
      - test "$(cat "$CODEBUILD_SRC_DIR/.deployment-ref")" = "$GIT_REF"
      - acli push:artifact "$ACQUIA_ENV_ID" --dir="$CODEBUILD_SRC_DIR" --destination-git-branch="$GIT_REF" --no-interaction
      - acli api:environments:code-switch "$ACQUIA_ENV_ID" "$GIT_REF" --task-wait

The .deployment-ref file makes the value visible to later commands and logs without relying on ambient shell state. The source revision makes the ref stable across a retry of the same input. Validation happens before remote mutation. If the push fails, code-switch does not run. If the push succeeds and the build is interrupted before or during code-switch, the retry pushes to the same destination ref and requests activation of that ref again. The remote task result, not request acceptance, determines whether Deploy succeeds.

--task-wait keeps CodeBuild open until Acquia finishes the code-switch task. That aligns the build result with the state operators care about: whether Acquia completed activation.

Vendor dependence remains. CLI behavior, authentication, and release packaging can change. Installing latest is convenient for illustration; in production I pin a tested CLI release and update it deliberately. The smaller integration surface reduces maintenance without eliminating it.

Put observability beside the mechanism

SNS and EventBridge report pipeline and build state changes. The rules route actionable failures and stage transitions into one notification topic.

Notification routing stays outside buildspecs. A build fails with a meaningful exit code; it does not try to send its own alert while its container is failing. EventBridge can observe the control plane even when cleanup code never runs.

This keeps alert behavior inspectable in CDK. Reviewers can see which state changes generate notifications, which topic receives them, and which principals may publish. Broad rules create noise, so patterns target states that somebody will act on.

Smoke checks remain a distinct signal. A successful code switch proves that Acquia completed activation of the requested ref. It does not prove that the application serves traffic correctly. If Smoke fails, the pipeline reports a deployed but unhealthy release and triggers the operational response. It does not relabel the completed code switch as a transport failure.

What I would reuse elsewhere

I now review delivery systems as two surfaces:

SurfaceAppropriate ownershipExamples
Deployment mechanismPrefer supported platform operationsArtifact upload, environment switch, remote task wait
Delivery controlsDefine explicitly in infrastructureVerification, encryption, secrets, notifications, smoke checks

This division is more useful than counting lines of YAML or calling a pipeline simple. A five-line deploy command can lack a gate, protect credentials poorly, or return before activation finishes. A larger stack can remain understandable when each resource represents a visible operational responsibility.

The deleted Git plumbing looked like control because its commands belonged to us. In practice, it blurred the boundary between uploading code and activating it. Returning that boundary to the supported CLI left us with the work we can actually govern: defining what may ship, identifying it consistently, waiting for a conclusive result, and detecting when the activated application is unhealthy.

FAQ

Why use Acquia CLI instead of pushing Git artifacts manually?

Acquia CLI exposes supported artifact upload and environment code-switch operations. It removes custom handling for temporary refs, remote pushes, cleanup, and remote-task polling while producing logs closer to the actual deployment intent.

Where should verification run in an AWS CodePipeline?

Put verification in its own action before Deploy, with failed checks returning a nonzero exit code. Use a separate post-deploy smoke action because pre-deploy checks cannot confirm that the hosted application started correctly.

Should CDK populate deployment credentials?

CDK should generally create the Secrets Manager container and grant narrowly scoped read access to the deploy project. Populate and rotate the value through a separate operational path so credentials do not enter source, synthesized templates, or stack history.

Why use --task-wait for an Acquia code switch?

The flag keeps the build running until the remote code-switch task completes. Without it, the pipeline can advance after request acceptance rather than successful activation.

Is a CDK-defined pipeline simpler than a shell script?

It has more declared infrastructure, but the responsibilities are clearer and reviewable. Simplicity comes from owning the controls around deployment while leaving the hosting platform to perform the deployment operation itself.

More to read