<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Emmanuel Ubani]]></title><description><![CDATA[Emmanuel Ubani]]></description><link>https://emmanuelubani.hashnode.dev</link><image><url>https://cdn.hashnode.com/uploads/logos/69a6e08356428acc6fe423a5/305e9623-d6ed-441e-a397-1e5cbbb2de5f.jpg</url><title>Emmanuel Ubani</title><link>https://emmanuelubani.hashnode.dev</link></image><generator>RSS for Node</generator><lastBuildDate>Wed, 28 Oct 2026 04:42:50 GMT</lastBuildDate><atom:link href="https://emmanuelubani.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[I Built a Production MLOps Pipeline for Fraud Detection That Automatically Retrains When It Detects Data Drift — Full Implementation]]></title><description><![CDATA[It was the quarterly business review when the CFO asked a question nobody on the engineering team could answer. "Our fraud loss rate has been climbing for…]]></description><link>https://emmanuelubani.hashnode.dev/i-built-a-production-mlops-pipeline-for-fraud-detection-that-automatically-retrains-when-it-detects-data-drift-full-implementation</link><guid isPermaLink="true">https://emmanuelubani.hashnode.dev/i-built-a-production-mlops-pipeline-for-fraud-detection-that-automatically-retrains-when-it-detects-data-drift-full-implementation</guid><category><![CDATA[mlops]]></category><category><![CDATA[Machine Learning]]></category><category><![CDATA[Python]]></category><category><![CDATA[AWS]]></category><category><![CDATA[Devops]]></category><category><![CDATA[fraud detection]]></category><category><![CDATA[mlflow]]></category><dc:creator><![CDATA[Ubani Obiajulum Emmanuel]]></dc:creator><pubDate>Sat, 22 Aug 2026 20:10:53 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/69a6e08356428acc6fe423a5/d25b2a40-aa8a-44ab-bf35-35408ce10b63.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<h2>A Fraud Detection Model That Nobody Was Watching</h2>
<p>It was the quarterly business review when the CFO asked a question nobody on the engineering team could answer.</p>
<p><strong>"Our fraud loss rate has been climbing for six months. Is the model still working?"</strong></p>
<p>The model was working. It was running. It was accepting requests and returning predictions. But was it still accurate? Nobody knew. The model had been trained eight months ago on data from twelve months before that. Transaction patterns had shifted. New fraud vectors had emerged. The feature distributions the model was trained on looked nothing like the data flowing through it in production.</p>
<p>The model was confident. The predictions were smooth. The fraud was getting through.</p>
<p>This is the silent failure mode of ML in production. Not a crash. Not an error. Just a <strong>gradual, invisible degradation</strong> that nobody catches until the business metric reveals it.</p>
<p>I built a pipeline that catches it automatically.</p>
<hr />
<h2>📺 Watch the Full Build on YouTube</h2>
<p>I recorded this entire project live from SSH into EC2 via MobaXterm. Every command. Every file written with heredoc. Every error and fix. If you learn by watching before reading, start here.</p>
<p><a href="https://youtu.be/rHJQ9BKZ9_k?si=2PvJmmSAoEcZnZ4S">Watch the Full Build on YouTube</a></p>
<p>The written breakdown below covers every architectural decision, every debugging story, and every lesson I would only learn by building this for real.</p>
<hr />
<h2>What This Pipeline Actually Does</h2>
<p>A fraud detection model for financial transactions is only useful if it stays accurate over time. Transaction patterns change seasonally. New merchant categories emerge. Fraud techniques evolve. The feature distributions a model was trained on drift gradually away from what the model sees in production.</p>
<p>This pipeline addresses that problem at every stage of the ML lifecycle:</p>
<table>
<thead>
<tr>
<th>Stage</th>
<th>Tool</th>
<th>What It Does</th>
</tr>
</thead>
<tbody><tr>
<td><strong>Data Validation</strong></td>
<td>Great Expectations</td>
<td>Validates raw data before training — fails immediately on corrupted input</td>
</tr>
<tr>
<td><strong>Data Versioning</strong></td>
<td>DVC + S3</td>
<td>Tracks every dataset version — training runs are fully reproducible</td>
</tr>
<tr>
<td><strong>Experiment Tracking</strong></td>
<td>MLflow</td>
<td>Logs every parameter, metric, artifact, and model across all runs</td>
</tr>
<tr>
<td><strong>Hyperparameter Tuning</strong></td>
<td>Optuna</td>
<td>50 TPE-sampled trials with Hyperband pruning</td>
</tr>
<tr>
<td><strong>Drift Detection</strong></td>
<td>Evidently AI</td>
<td>Daily comparison of production data against training reference</td>
</tr>
<tr>
<td><strong>Auto-Retraining</strong></td>
<td>GitHub Actions + SNS</td>
<td>Triggers retraining automatically when drift exceeds threshold</td>
</tr>
<tr>
<td><strong>Model Serving</strong></td>
<td>FastAPI + MLflow Registry</td>
<td>Loads Production-stage model at startup — zero-deployment updates</td>
</tr>
</tbody></table>
<p>🔗 Full code: <a href="https://github.com/Eaglewings966/mlops-pipeline">github.com/Eaglewings966/mlops-pipeline</a></p>
<p>This is <strong>Project 14</strong> in my 150 Enterprise DevOps Projects challenge.</p>
<hr />
<h2>Why Fraud Detection Is the Right Use Case for MLOps</h2>
<p>Fraud detection is the canonical MLOps use case for three reasons that make it harder than a standard classification problem.</p>
<p><strong>Severe class imbalance.</strong> The IEEE-CIS Fraud Detection dataset has a 3.5% fraud rate. A model that labels every transaction as legitimate achieves 96.5% accuracy and catches zero fraud. AUPRC (area under the precision-recall curve) is the correct metric — it measures performance across the full range of classification thresholds and is sensitive to minority class performance.</p>
<p><strong>Concept drift.</strong> Fraud patterns change constantly. Fraudsters adapt to detection models. New fraud vectors emerge. Seasonal patterns shift. A model trained in Q1 may be significantly less effective by Q3 without any change to the code or infrastructure. Drift detection is not optional — it is essential.</p>
<p><strong>Business stakes.</strong> False negatives cost money in fraud losses. False positives cost money in declined legitimate transactions and customer churn. The threshold between them is a business decision that must be monitored and adjusted as model performance drifts.</p>
<p>Every enterprise fintech that processes payments at scale has this problem. Every bank has this problem. The pipeline I built addresses all three dimensions.</p>
<hr />
<h2>The Architecture</h2>
<hr />
<img src="https://cdn.hashnode.com/uploads/covers/69a6e08356428acc6fe423a5/005cae32-a7ee-4958-b2c8-f03bbb61acb8.png" alt="" style="display:block;margin:0 auto" />

<hr />
<h3>Why XGBoost Instead of a Neural Network</h3>
<p>XGBoost is the right model for tabular fraud detection data for three reasons:</p>
<table>
<thead>
<tr>
<th>Property</th>
<th>Why It Matters for Fraud</th>
</tr>
</thead>
<tbody><tr>
<td>Handles missing values natively</td>
<td>Fraud datasets have many unpopulated fields per transaction type</td>
</tr>
<tr>
<td>Well-calibrated probabilities</td>
<td>Essential for setting business decision thresholds</td>
</tr>
<tr>
<td>Interpretable via SHAP</td>
<td>Non-negotiable in regulated financial environments</td>
</tr>
</tbody></table>
<p>Neural networks on tabular data with this feature set and volume consistently underperform well-tuned gradient boosted trees. The research literature on this is unambiguous.</p>
<hr />
<h3>Why Optuna Instead of GridSearch or RandomSearch</h3>
<p>Optuna uses Tree-structured Parzen Estimator (TPE) for sampling. TPE builds a probabilistic model of which hyperparameter regions have produced good results and samples from promising regions preferentially. This converges to good configurations faster than random search and is more efficient than grid search on large hyperparameter spaces.</p>
<p>The Hyperband pruner kills unpromising trials early based on intermediate results — reducing compute cost by approximately 40%:</p>
<pre><code class="language-python">study = optuna.create_study(
    direction="maximize",
    sampler=optuna.samplers.TPESampler(seed=42),
    pruner=optuna.pruners.HyperbandPruner()
)
study.optimize(objective, n_trials=50)
</code></pre>
<hr />
<h3>Why SMOTE Instead of Class Weighting</h3>
<p>XGBoost has a <code>scale_pos_weight</code> parameter that handles class imbalance. I tested both approaches. SMOTE produced consistently better AUPRC scores across cross-validation folds for this specific dataset.</p>
<p>SMOTE generates synthetic fraud samples in feature space, forcing the model to learn more robust decision boundaries around the minority class. Class weighting makes the model pay more attention to existing fraud samples but does not increase the diversity of the fraud examples it sees.</p>
<pre><code class="language-python">smote = SMOTE(
    random_state=42,
    sampling_strategy=0.1  # 10:1 ratio after oversampling
)
X_train_resampled, y_train_resampled = smote.fit_resample(
    X_train, y_train
)
</code></pre>
<blockquote>
<p>⚠️ <strong>Critical rule: SMOTE must be applied after the train/test split.</strong> Applying it before the split leaks synthetic samples into the test set. I will cover the exact debugging session where I caught this mistake in the challenges section — it cost me 3 hours.</p>
</blockquote>
<hr />
<h2>Implementation — The Code That Matters Most</h2>
<h3>Great Expectations Data Validation</h3>
<p>The validation stage answers one question before every training run: <strong>is this data actually valid for training a fraud detection model?</strong></p>
<pre><code class="language-python"># Five checks that must all pass before training proceeds

# Check 1 — Target column is binary
unique_values = df["isFraud"].dropna().unique()
assert set(unique_values).issubset({0, 1})

# Check 2 — Transaction amounts are positive
assert (df["TransactionAmt"] &gt; 0).all()

# Check 3 — Fraud rate is in expected range
fraud_rate = df["isFraud"].mean()
assert 0.005 &lt;= fraud_rate &lt;= 0.05

# Check 4 — No completely empty rows
assert df.isnull().all(axis=1).sum() == 0

# Check 5 — Minimum row count for statistical validity
assert len(df) &gt;= 10000
</code></pre>
<p>The validation runs in under 30 seconds on 100,000 rows. If any check fails the pipeline raises a <code>ValueError</code> with the specific check name. No error message says "something went wrong." <strong>Every error says exactly what went wrong.</strong></p>
<hr />
<img src="https://cdn.hashnode.com/uploads/covers/69a6e08356428acc6fe423a5/64417024-806f-41c1-86f8-162a5c199abb.png" alt="" style="display:block;margin:0 auto" />

<img src="https://cdn.hashnode.com/uploads/covers/69a6e08356428acc6fe423a5/ed2ca490-2b1b-4ab0-a3d8-d9462a144f04.png" alt="" style="display:block;margin:0 auto" />

<hr />
<h3>MLflow Experiment Tracking</h3>
<p>Every training run logs the complete experimental context:</p>
<pre><code class="language-python">with mlflow.start_run(run_name="fraud-detection-xgboost") as run:

    # Dataset metadata
    mlflow.log_param("dataset_rows", len(df))
    mlflow.log_param("feature_count", len(feature_cols))
    mlflow.log_param("train_fraud_rate", float(y_train.mean()))

    # All params.yaml values
    mlflow.log_params(params["xgboost_defaults"])

    # After training — metrics
    mlflow.log_metrics({
        "average_precision": 0.8234,
        "roc_auc": 0.9187,
        "f1_score": 0.7412,
        "precision": 0.8124,
        "recall": 0.6821
    })

    # Artifacts — confusion matrix, SHAP plots
    mlflow.log_artifact("/tmp/confusion_matrix.png")
    mlflow.log_artifact("/tmp/shap_summary.png")

    # Register model with signature
    mlflow.xgboost.log_model(
        model,
        "fraud-detection-model",
        registered_model_name="fraud-detection-xgboost",
        signature=infer_signature(X_test[:100], probas)
    )
</code></pre>
<p>Every experiment is reproducible from the MLflow UI. Click any run, see every parameter that was used, every metric that was produced, every artifact that was generated. The confusion matrix from run 7 versus run 12 is one click away.</p>
<hr />
<img src="https://cdn.hashnode.com/uploads/covers/69a6e08356428acc6fe423a5/1a06b0a8-cc0e-4410-b1ef-853cab52cc73.png" alt="" style="display:block;margin:0 auto" />

<img src="https://cdn.hashnode.com/uploads/covers/69a6e08356428acc6fe423a5/396cf37f-4574-4946-a4f9-021859c1e822.png" alt="" style="display:block;margin:0 auto" />

<hr />
<h3>Evidently AI Drift Detection</h3>
<p>The drift detector runs daily and compares the current production data distribution against the reference dataset captured at training time:</p>
<pre><code class="language-python">report = Report(metrics=[
    DatasetDriftMetric(),                              # Share of drifted columns
    DataDriftTable(),                                  # Per-column drift table
    ColumnDriftMetric(column_name="TransactionAmt"),   # Amount distribution
    ColumnDriftMetric(column_name="C1"),               # Card feature
])

report.run(
    reference_data=reference_df,
    current_data=current_df,
    column_mapping=column_mapping
)

# Extract drift result
drift_detected = share_drifted &gt; config.drift_threshold

if drift_detected:
    # Send SNS alert
    sns.publish(
        TopicArn=config.sns_topic_arn,
        Subject="DRIFT ALERT — Model Requires Retraining",
        Message=detailed_drift_report
    )
    # GitHub Actions workflow automatically triggers retraining
</code></pre>
<p>The Evidently report is an HTML document showing the distribution of every feature in the reference data versus the current data, with a traffic-light indicator for drift status per column.</p>
<hr />
<img src="https://cdn.hashnode.com/uploads/covers/69a6e08356428acc6fe423a5/c9680637-c787-464d-bfd5-84f6fb4d3a24.png" alt="" style="display:block;margin:0 auto" />

<img src="https://cdn.hashnode.com/uploads/covers/69a6e08356428acc6fe423a5/744aa9cd-e575-4f35-bad4-f0256788d31b.png" alt="" style="display:block;margin:0 auto" />

<img src="https://cdn.hashnode.com/uploads/covers/69a6e08356428acc6fe423a5/6fed12d4-b35f-4921-9e01-41c0daeb0566.png" alt="" style="display:block;margin:0 auto" />

<hr />
<h3>The FastAPI Serving API</h3>
<p>The serving API loads the Production-stage model from the MLflow registry once at startup and holds it in memory for all predictions:</p>
<pre><code class="language-python">@asynccontextmanager
async def lifespan(app: FastAPI):
    global model, model_version

    model_uri = f"models:/fraud-detection-xgboost/Production"
    model = mlflow.xgboost.load_model(model_uri)

    client = mlflow.MlflowClient()
    versions = client.get_latest_versions(
        "fraud-detection-xgboost", stages=["Production"]
    )
    model_version = versions[0].version
    yield
</code></pre>
<p>When a new model is trained and promoted to Production, the next request after a server restart serves the new model automatically. <strong>No redeployment. No configuration change.</strong> The model registry is the source of truth.</p>
<pre><code class="language-python">@app.post("/predict")
async def predict(request: TransactionRequest):
    X = prepare_features(request)
    fraud_probability = float(model.predict_proba(X)[0, 1])

    return {
        "is_fraud": fraud_probability &gt;= 0.5,
        "fraud_probability": round(fraud_probability, 4),
        "risk_level": get_risk_level(fraud_probability),
        "latency_ms": round((time.time() - start_time) * 1000, 2)
    }
</code></pre>
<hr />
<h2>Challenges and Debugging — The Parts That Actually Hurt</h2>
<h3>Challenge 1 — SMOTE Before Split: The Silent Data Leakage Bug</h3>
<p>This is the most expensive debugging session of the entire project because <strong>the mistake produced results that looked correct.</strong></p>
<p>On my first implementation I applied SMOTE before splitting the data:</p>
<pre><code class="language-python"># WRONG — data leakage
X_resampled, y_resampled = smote.fit_resample(X, y)
X_train, X_test, y_train, y_test = train_test_split(
    X_resampled, y_resampled, test_size=0.2
)
</code></pre>
<p>The model trained this way achieved AUPRC of <strong>0.91</strong> on the test set. Excellent result. I logged it to MLflow and was ready to move on.</p>
<p>Then I noticed something unusual. The model performed significantly worse on a holdout validation dataset I had separately set aside before touching anything.</p>
<table>
<thead>
<tr>
<th>Dataset</th>
<th>AUPRC</th>
</tr>
</thead>
<tbody><tr>
<td>Test set (contaminated)</td>
<td>0.91</td>
</tr>
<tr>
<td>Holdout (clean)</td>
<td>0.73</td>
</tr>
</tbody></table>
<p>A gap of 0.18 is not noise. SMOTE generates synthetic samples by interpolating between existing samples in feature space. When applied before the split, synthetic samples generated from real training data points end up in the test set. <strong>The test set is contaminated.</strong></p>
<p>The fix:</p>
<pre><code class="language-python"># CORRECT — split first, then apply SMOTE only to training data
X_train, X_test, y_train, y_test = train_test_split(
    X, y, test_size=0.2, stratify=y
)

smote = SMOTE(random_state=42, sampling_strategy=0.1)
X_train_resampled, y_train_resampled = smote.fit_resample(
    X_train, y_train
)
# X_test is NEVER touched by SMOTE
</code></pre>
<p>After fixing: AUPRC on test set dropped to <strong>0.82</strong>. AUPRC on holdout: <strong>0.80</strong>. The two numbers now agree. The model's real performance was 0.82, not 0.91.</p>
<blockquote>
<p>💡 <strong>Always validate ML performance on a holdout dataset set aside before any preprocessing is applied.</strong> If test and holdout scores disagree significantly, look for data leakage.</p>
</blockquote>
<hr />
<h3>Challenge 2 — MLflow Model Loading Fails in Serving</h3>
<p>After training the model and registering it, the FastAPI serving API failed to start with:</p>
<pre><code class="language-plaintext">mlflow.exceptions.MlflowException:
The model at models:/fraud-detection-xgboost/Production
was not found. No model with name 'fraud-detection-xgboost'
was found in the registry.
</code></pre>
<p>The model was definitely registered — visible in the MLflow UI. The problem was that the serving EC2 instance was using a different <code>MLFLOW_TRACKING_URI</code> than the training instance.</p>
<pre><code class="language-bash"># On the serving server — point to training server's private IP
export MLFLOW_TRACKING_URI=http://TRAINING_SERVER_PRIVATE_IP:5000
</code></pre>
<p>After fixing the URI, loading still failed with:</p>
<pre><code class="language-plaintext">OSError: [Errno 2] No such file or directory:
'/tmp/tmpXXXXXX/fraud-detection-model/xgboost.model'
</code></pre>
<p>MLflow was trying to download model artifacts from S3 but the serving EC2 instance was missing the S3 read permission for the MLflow artifacts bucket.</p>
<pre><code class="language-bash">aws iam put-role-policy \
  --role-name mlops-pipeline-serving-role \
  --policy-name mlflow-artifacts-read \
  --policy-document '{
    "Statement": [{
      "Effect": "Allow",
      "Action": ["s3:GetObject", "s3:ListBucket"],
      "Resource": ["arn:aws:s3:::mlops-pipeline-mlflow-*",
                   "arn:aws:s3:::mlops-pipeline-mlflow-*/*"]
    }]
  }'
</code></pre>
<blockquote>
<p>⚠️ <strong>Two separate problems, same confusing error message.</strong> IAM permission issues in MLflow surface as file-not-found errors because the S3 download silently fails and MLflow looks for the file locally.</p>
</blockquote>
<hr />
<h3>Challenge 3 — Optuna Trials Timing Out on EC2</h3>
<p>Running 50 Optuna trials with 5-fold cross-validation on 590,000 rows exceeded the GitHub Actions job budget. Each trial took approximately 4 minutes. 50 trials × 4 minutes = over 3 hours.</p>
<p>Three fixes combined to solve this:</p>
<p><strong>Fix 1 — Run Optuna on 20% of training data:</strong></p>
<pre><code class="language-python"># Hyperparameter search on 20% sample
X_sample = X_train[:int(len(X_train) * 0.2)]
y_sample = y_train[:int(len(y_train) * 0.2)]
best_params, study = run_hyperparameter_tuning(X_sample, y_sample)

# Final model trained on full dataset with best params
model = xgb.XGBClassifier(**best_params)
model.fit(X_train_resampled, y_train_resampled)
</code></pre>
<p><strong>Fix 2 — Enable the Hyperband pruner.</strong> Unpromising trials are stopped after 2 folds instead of running all 5. Average trial time: 4 minutes → 2.4 minutes.</p>
<p><strong>Fix 3 — Add</strong> <code>--no-tuning</code> <strong>flag.</strong> For automated retraining triggered by drift, use the best parameters from the previous run stored in SSM Parameter Store rather than running 50 new trials from scratch.</p>
<blockquote>
<p>⏱️ Time for 50 trials after all three fixes: <strong>82 minutes.</strong> Fits within the CI budget.</p>
</blockquote>
<hr />
<h3>Challenge 4 — Evidently Drift Report Fails on Missing Columns</h3>
<pre><code class="language-plaintext">ValueError: Column 'V35' is in column_mapping but not in current data
</code></pre>
<p>The reference data had all 339 feature columns after feature engineering. The production data was only sending 52 columns. Evidently requires matching columns when <code>column_mapping</code> is specified explicitly.</p>
<p>The fix was to only include columns present in both datasets:</p>
<pre><code class="language-python">feature_cols = [
    c for c in config.feature_columns
    if c in reference_df.columns and c in current_df.columns
]

column_mapping = ColumnMapping(
    target=target_col,
    numerical_features=[c for c in feature_cols if ...],
    categorical_features=[c for c in feature_cols if ...]
)
</code></pre>
<blockquote>
<p>💡 This also made the drift report more meaningful — a feature that is missing from production data cannot drift. It is simply absent.</p>
</blockquote>
<hr />
<h3>Challenge 5 — DVC Push Failing Due to S3 Permissions</h3>
<pre><code class="language-plaintext">ERROR: failed to push data to the cloud
AuthorizationError checking head object
An error occurred (403) when calling the HeadObject operation: Forbidden
</code></pre>
<p>The IAM role had S3 permissions scoped to a specific prefix, but DVC needs to check bucket existence at the root level before writing to the cache prefix. The S3 permissions model requires both separately:</p>
<pre><code class="language-json">{
  "Effect": "Allow",
  "Action": ["s3:ListBucket", "s3:GetBucketLocation"],
  "Resource": ["arn:aws:s3:::mlops-pipeline-dvc-*"]
},
{
  "Effect": "Allow",
  "Action": ["s3:GetObject", "s3:PutObject", "s3:DeleteObject"],
  "Resource": ["arn:aws:s3:::mlops-pipeline-dvc-*/*"]
}
</code></pre>
<blockquote>
<p>⚠️ <strong>Bucket-level actions apply to the bucket ARN without a trailing slash. Object-level actions apply to the ARN with</strong> <code>/*</code> <strong>appended.</strong> Both are needed for DVC to function correctly.</p>
</blockquote>
<hr />
<h2>Validation — Proving the Pipeline Works</h2>
<h3>Validate Training Metrics</h3>
<pre><code class="language-bash">cat data/processed/training_metrics.json
</code></pre>
<p>Expected output:</p>
<pre><code class="language-json">{
  "average_precision": 0.82,
  "roc_auc": 0.92,
  "f1_score": 0.74,
  "precision": 0.81,
  "recall": 0.68,
  "test_fraud_count": 4123,
  "test_total": 118000
}
</code></pre>
<hr />
<h3>Validate Model Registry</h3>
<pre><code class="language-bash">python3.11 &lt;&lt; 'EOF'
import mlflow
import os

mlflow.set_tracking_uri(
    os.environ.get("MLFLOW_TRACKING_URI", "http://localhost:5000")
)
client = mlflow.MlflowClient()

versions = client.get_latest_versions(
    "fraud-detection-xgboost",
    stages=["Production"]
)
for v in versions:
    print(f"Version: {v.version}")
    print(f"Stage: {v.current_stage}")
    print(f"Run ID: {v.run_id}")
    print(f"Source: {v.source}")
EOF
</code></pre>
<hr />
<h3>Validate API Prediction</h3>
<pre><code class="language-bash"># Test a high-risk transaction — should return CRITICAL or HIGH
curl -s -X POST http://localhost:8000/predict \
  -H "Content-Type: application/json" \
  -d '{
    "TransactionAmt": 8500.0,
    "TransactionHour": 3,
    "TransactionDay": 6
  }' | python3.11 -m json.tool

# Test a low-risk transaction — should return LOW
curl -s -X POST http://localhost:8000/predict \
  -H "Content-Type: application/json" \
  -d '{
    "TransactionAmt": 25.0,
    "TransactionHour": 14,
    "TransactionDay": 2
  }' | python3.11 -m json.tool
</code></pre>
<hr />
<img src="https://cdn.hashnode.com/uploads/covers/69a6e08356428acc6fe423a5/af49f28a-6cdf-4374-8aab-773b380e4072.png" alt="" style="display:block;margin:0 auto" />

<img src="https://cdn.hashnode.com/uploads/covers/69a6e08356428acc6fe423a5/eb26fa7c-51b6-481f-be75-b4cbfd6f796e.png" alt="" style="display:block;margin:0 auto" />

<hr />
<h3>Validate Drift Detection</h3>
<pre><code class="language-bash">python3.11 -c "
from src.monitoring.drift_detector import detect_drift
import pandas as pd

# Load and modify reference data to simulate drift
ref = pd.read_csv('data/reference/reference_data.csv')
current = ref.copy()
current['TransactionAmt'] = current['TransactionAmt'] * 3.5
current.to_csv('/tmp/drifted_current.csv', index=False)

results = detect_drift(
    'data/reference/reference_data.csv',
    '/tmp/drifted_current.csv'
)
print(f'Drift detected: {results[\"drift_detected\"]}')
print(f'Share drifted: {results[\"share_drifted_columns\"]:.1%}')
"
</code></pre>
<p>Expected: <code>drift_detected = True</code></p>
<hr />
<h2>Key Lessons From This Build</h2>
<p><strong>AUPRC is the only metric that matters for imbalanced fraud data.</strong> A model with 97% accuracy that catches zero fraud is worse than useless in production — it provides false confidence. AUPRC measures exactly the thing you care about: across all possible classification thresholds, how well does the model separate fraud from legitimate transactions?</p>
<p><strong>MLflow model stages are not optional in production.</strong> Registering a model is not the same as deploying it. The Production stage is the signal the serving API uses to know which model version to load. When you need to roll back, you archive the bad version and promote the previous one. This workflow is only possible if you use stages consistently from the beginning.</p>
<p><strong>Drift detection is only as good as your reference data.</strong> I initially used the full training dataset as reference — including SMOTE-generated synthetic samples, which gave Evidently a distorted view of the real data distribution. The correct reference dataset is the <strong>pre-SMOTE training data — real samples only.</strong></p>
<p><strong>DVC pipeline caching can mask stale results.</strong> If you forget to list a file in a stage's <code>deps</code> section and then modify it, DVC will serve stale output without warning. I modified a feature engineering function once without adding it to the deps list. The model trained on stale features and the metrics looked normal. I only caught it when I ran the pipeline from scratch on a clean machine.</p>
<hr />
<h2>Security Posture at a Glance</h2>
<table>
<thead>
<tr>
<th>Security Property</th>
<th>Implementation</th>
</tr>
</thead>
<tbody><tr>
<td>IAM least privilege</td>
<td>Training server: S3 write to DVC + MLflow buckets only. Serving server: S3 read to MLflow artifacts only</td>
</tr>
<tr>
<td>No credentials in code</td>
<td>Database credentials in Secrets Manager. S3 bucket names in SSM Parameter Store</td>
</tr>
<tr>
<td>Encrypted S3 storage</td>
<td>All three S3 buckets use <code>aws:kms</code> server-side encryption</td>
</tr>
<tr>
<td>Private RDS instance</td>
<td>MLflow backend in private subnet — not publicly accessible</td>
</tr>
<tr>
<td>Model signature enforcement</td>
<td>Input validated against MLflow signature + Pydantic schema before reaching the model</td>
</tr>
</tbody></table>
<hr />
<h2>Production Gap Analysis</h2>
<p><strong>Feature Store.</strong> The preprocessing pipeline currently runs inline at training time. In production, features should be computed once and stored in a feature store (Feast or AWS SageMaker Feature Store) to ensure training and serving use identical feature computation logic.</p>
<p><strong>Champion/Challenger Testing.</strong> Currently the pipeline promotes every new model to Production automatically if metrics exceed the previous version. A more robust approach sends 10% of traffic to the new model, compares metrics over 24 hours, then promotes or rolls back.</p>
<p><strong>Online Retraining.</strong> The current pipeline runs batch retraining when drift is detected — creating a lag between when drift appears and when the new model is deployed. Online learning (incrementally updating the model on new transactions) reduces this lag significantly for high-velocity fraud scenarios.</p>
<p><strong>Model Explainability in the API.</strong> The current API returns a fraud probability and a risk level. Regulated financial environments require explanations: "This transaction was flagged because the amount is 4 standard deviations above the mean for this card type and it occurred at 3am." Adding SHAP values to the <code>/predict</code> response enables this.</p>
<hr />
<h2>Cleanup</h2>
<pre><code class="language-bash">bash scripts/destroy_all.sh
</code></pre>
<p>This empties all S3 buckets, stops all running processes, runs <code>terraform destroy</code>, and terminates EC2 instances.</p>
<blockquote>
<p>⚠️ <strong>Always run this.</strong> The RDS instance, ElastiCache, and EC2 instances will continue billing until explicitly terminated.</p>
</blockquote>
<hr />
<h2>What's Next</h2>
<p>Project 15 is a <strong>RAG (Retrieval Augmented Generation) pipeline</strong> — document ingestion, self-hosted embedding model, Qdrant vector database, and a FastAPI query interface backed by Claude.</p>
<p>Follow me at <a href="https://emmanuelubani.hashnode.dev">emmanuelubani.hashnode.dev</a> for every project as it ships.</p>
<hr />
<h2>References</h2>
<ul>
<li><p><a href="https://mlflow.org/docs/latest/index.html">MLflow Documentation</a></p>
</li>
<li><p><a href="https://dvc.org/doc">DVC Documentation</a></p>
</li>
<li><p><a href="https://docs.evidentlyai.com/">Evidently AI Documentation</a></p>
</li>
<li><p><a href="https://docs.greatexpectations.io/">Great Expectations Documentation</a></p>
</li>
<li><p><a href="https://xgboost.readthedocs.io/">XGBoost Documentation</a></p>
</li>
<li><p><a href="https://optuna.readthedocs.io/">Optuna Documentation</a></p>
</li>
<li><p><a href="https://imbalanced-learn.org/stable/references/generated/imblearn.over_sampling.SMOTE.html">SMOTE — imbalanced-learn</a></p>
</li>
<li><p><a href="https://fastapi.tiangolo.com/">FastAPI Documentation</a></p>
</li>
<li><p><a href="https://www.kaggle.com/c/ieee-fraud-detection">IEEE-CIS Fraud Detection Dataset</a></p>
</li>
<li><p><a href="https://shap.readthedocs.io/">SHAP Documentation</a></p>
</li>
<li><p><a href="https://machinelearningmastery.com/roc-curves-and-precision-recall-curves-for-imbalanced-classification/">Why AUPRC for Imbalanced Classification</a></p>
</li>
</ul>
<hr />
<p><em>Emmanuel Ubani is a Cloud and DevOps Engineer based in Lagos, Nigeria. He writes about real infrastructure builds, career transitions into tech, and lessons learned the hard way.</em></p>
<ul>
<li><p>🔗 <a href="https://linkedin.com/in/ubaniemmanuel">LinkedIn</a></p>
</li>
<li><p>💻 <a href="https://github.com/Eaglewings966">GitHub</a></p>
</li>
<li><p>✍️ <a href="https://emmanuelubani.hashnode.dev">Hashnode</a></p>
</li>
<li><p>📝 <a href="https://medium.com/@emmaubani966">Medium</a></p>
</li>
<li><p>🌐 <a href="https://ops-run.lovable.app">Portfolio</a></p>
</li>
</ul>
]]></content:encoded></item><item><title><![CDATA[I Built an Automated CIS Compliance Pipeline That Saved a Company From a $3.2 Million Contract Loss — Here Is How It Works]]></title><description><![CDATA[A production-grade Python tool with dark-themed HTML reports, Slack alerts, SES email delivery, and S3 archival — all from a single command]]></description><link>https://emmanuelubani.hashnode.dev/i-built-an-automated-python-cis-compliance-pipeline-that-saved-a-company-from-a-40-million-contract-loss-here-is-how-it-works</link><guid isPermaLink="true">https://emmanuelubani.hashnode.dev/i-built-an-automated-python-cis-compliance-pipeline-that-saved-a-company-from-a-40-million-contract-loss-here-is-how-it-works</guid><category><![CDATA[AWS]]></category><category><![CDATA[software development]]></category><category><![CDATA[Python]]></category><category><![CDATA[Devops]]></category><category><![CDATA[Cloud Computing]]></category><dc:creator><![CDATA[Ubani Obiajulum Emmanuel]]></dc:creator><pubDate>Tue, 04 Aug 2026 17:05:11 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/69a6e08356428acc6fe423a5/d278a250-ce62-4b6e-aea7-3fc07ed3ba41.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<h2>It Was 847 SSH Keys Last Week. This Week It Was Something Else.</h2>
<p>I was running a routine check across our AWS account when I noticed something that made my stomach drop. An S3 bucket with public access not fully blocked. One of 47 buckets. The kind of thing nobody checks manually because there are 47 of them and checking each one by hand takes an afternoon.</p>
<p>But that was not what made me stop.</p>
<p>What made me stop was realizing I did not know how many other findings like this were sitting in the account:</p>
<ul>
<li><p>Unencrypted EBS volumes</p>
</li>
<li><p>IAM access keys unused for 14 months</p>
</li>
<li><p>EC2 instances without any IAM role</p>
</li>
<li><p>RDS databases publicly accessible</p>
</li>
<li><p>Resources with zero tags making cost attribution impossible</p>
</li>
</ul>
<p>None of these were catastrophic individually. Combined they represented <strong>a compliance audit waiting to happen.</strong></p>
<p>I spent two days building a tool that finds all of it. In under 60 seconds.</p>
<hr />
<h2>📺 Watch the Full Build on YouTube</h2>
<p>I recorded this entire build live from an SSH session on EC2 via MobaXterm. Every command. Every file. Every error. If you learn better by watching before reading, start here.</p>
<p><a href="https://youtu.be/n5n53je_SwY?si=1PGJ2jC91elZ4qUl">Watch the Full Build on YouTube</a></p>
<p>The rest of this article covers every file and every decision in writing with extra context that did not fit in the video.</p>
<hr />
<h2>What This Tool Does</h2>
<p>The AWS Resource Inventory CLI is a production-grade Python tool that runs entirely from an SSH session on EC2. You type one command. It:</p>
<table>
<thead>
<tr>
<th>Action</th>
<th>Output</th>
</tr>
</thead>
<tbody><tr>
<td>Scans your entire AWS account across 14 service categories in parallel</td>
<td>JSON + CSV findings files</td>
</tr>
<tr>
<td>Generates a styled dark-themed HTML report</td>
<td>Readable by any browser</td>
</tr>
<tr>
<td>Sends a Slack summary with top findings highlighted in red</td>
<td>Real-time team notification</td>
</tr>
<tr>
<td>Emails the full report via SES to your security team</td>
<td>Inbox-ready HTML attachment</td>
</tr>
<tr>
<td>Archives everything to an encrypted S3 bucket with a timestamp</td>
<td>Immutable compliance history</td>
</tr>
</tbody></table>
<p>🔗 Full code: <a href="https://github.com/Eaglewings966/aws-resource-inventory">github.com/Eaglewings966/aws-resource-inventory</a></p>
<p>This is <strong>Project 13</strong> in my 100 Enterprise DevOps Projects challenge. The previous project was a full Privileged Access Management platform. This one is the visibility layer that tells you what is in your account before someone else finds it first.</p>
<hr />
<h2>Why This Matters for Fintech and Enterprise Teams</h2>
<p>Every fintech organization above a certain scale has the same problem. Infrastructure grows faster than governance. An engineer spins up an EC2 instance for a proof of concept. The PoC becomes a feature. The feature ships. The instance stays running for 18 months with no tags, no IAM role, and an unencrypted root volume. Nobody is negligent. <strong>The system just has no automated visibility layer.</strong></p>
<p>The first time most teams see their real infrastructure state is during a compliance audit. That is the worst possible time to find out.</p>
<p>PCI-DSS, SOC2, and ISO 27001 all require continuous monitoring of your cloud environment. This tool does not replace a proper CSPM solution at enterprise scale, but for teams building toward compliance it is a working implementation of the principle that <strong>you cannot secure what you cannot see.</strong></p>
<hr />
<h2>The Architecture — How the Tool Is Structured</h2>
<p>The tool is organized as a Python package with three layers.</p>
<hr />
<img src="https://cdn.hashnode.com/uploads/covers/69a6e08356428acc6fe423a5/8b4a4452-2a3e-49e7-9def-75de48563415.png" alt="" style="display:block;margin:0 auto" />

<hr />
<h3>Layer 1 — Scanners</h3>
<p>Each AWS service has its own scanner module: EC2, S3, RDS, Lambda, EKS, VPC, IAM, CloudWatch, Security Groups, SNS, DynamoDB, ElastiCache, ALB, and a cross-service Tagging Compliance scanner that uses the AWS Resource Groups Tagging API to check every taggable resource in the account simultaneously.</p>
<p>Each scanner returns a standardized dictionary with four keys: service name, region, a list of resource objects, a list of findings with severity and recommendation, and a summary object with counts. This consistent structure means the output layer can consume any scanner's results without knowing which service produced them.</p>
<h3>Layer 2 — Outputs</h3>
<p>Six output modules each receive the combined results from all scanners and produce a different artifact:</p>
<table>
<thead>
<tr>
<th>Module</th>
<th>Output</th>
<th>Purpose</th>
</tr>
</thead>
<tbody><tr>
<td>JSON</td>
<td><code>.json</code> file</td>
<td>Machine consumption</td>
</tr>
<tr>
<td>CSV</td>
<td><code>.csv</code> file</td>
<td>Spreadsheet analysis</td>
</tr>
<tr>
<td>HTML</td>
<td><code>.html</code> report</td>
<td>Human reading</td>
</tr>
<tr>
<td>Slack</td>
<td>Webhook message</td>
<td>Real-time notification</td>
</tr>
<tr>
<td>SES</td>
<td>Email with attachment</td>
<td>Security team delivery</td>
</tr>
<tr>
<td>S3</td>
<td>Encrypted archive</td>
<td>Compliance history</td>
</tr>
</tbody></table>
<p>Each output module is independent. Failing to send a Slack message does not prevent the HTML report from being generated.</p>
<h3>Layer 3 — CLI</h3>
<p>Typer provides the CLI interface with Rich for beautiful terminal output. Progress spinners show each scanner running. A color-coded findings table shows severity counts on completion. File paths are printed clearly so you know exactly where every output landed.</p>
<hr />
<h2>The Implementation — Every Decision Explained</h2>
<h3>Why SSH Into EC2 Instead of Running Locally</h3>
<p>Every command in this project runs from inside an SSH session on EC2 via MobaXterm. This is not a personal preference. It is the <strong>correct execution model</strong> for tools that need IAM permissions to scan AWS resources.</p>
<p>When you run this tool on EC2 with an IAM instance role attached, the credentials come from the instance metadata service:</p>
<ul>
<li><p>No access keys stored anywhere</p>
</li>
<li><p>No <code>~/.aws/credentials</code> file</p>
</li>
<li><p>No environment variables with sensitive values</p>
</li>
</ul>
<p>Running this locally requires storing AWS credentials somewhere on your development machine. At some point those credentials end up in a git commit, a dotfile backup, or a screenshot. <strong>EC2 instance roles eliminate that entire risk class.</strong></p>
<hr />
<h3>The IAM Least Privilege Design</h3>
<p>The IAM policy for the runner instance is split into seven permission groups, each covering exactly the API calls each scanner needs:</p>
<ul>
<li><p>EC2 scanning needs <code>DescribeInstances</code> — not <code>CreateInstances</code> or <code>TerminateInstances</code></p>
</li>
<li><p>IAM scanning needs <code>ListUsers</code> and <code>ListRoles</code> — not <code>CreateUser</code> or <code>AttachRolePolicy</code></p>
</li>
</ul>
<pre><code class="language-bash"># The policy document is created with explicit Action lists
# Not managed policies like ReadOnlyAccess which are too broad
aws iam create-policy \
  --policy-name inventory-runner-policy \
  --policy-document file:///tmp/inventory-policy.json
</code></pre>
<p>This matters because the inventory tool has read access to sensitive data. If the EC2 instance were compromised, an overly permissive IAM role would let an attacker use the tool's permissions to make destructive changes. <strong>Least privilege limits the blast radius to read operations only.</strong></p>
<img src="https://cdn.hashnode.com/uploads/covers/69a6e08356428acc6fe423a5/0713cf10-b01a-4717-819e-3ec3e6ebfd97.png" alt="" style="display:block;margin:0 auto" />

<img src="https://cdn.hashnode.com/uploads/covers/69a6e08356428acc6fe423a5/404fe3f0-6c89-4a40-8442-d73695f1f4b8.png" alt="" style="display:block;margin:0 auto" />

<hr />
<h3>The IAM Scanner — The Most Valuable Scanner in the Tool</h3>
<p>The IAM scanner uses the IAM Credential Report — a CSV document that AWS generates showing the complete security posture of every IAM user in the account. Root account MFA status. Access key ages. Last usage dates. Password rotation status. It is the document a security auditor asks for first.</p>
<pre><code class="language-python"># Generate the credential report
iam.generate_credential_report()
time.sleep(3)

# Parse it as CSV
report_response = iam.get_credential_report()
report_csv = report_response["Content"].decode("utf-8")
reader = csv.DictReader(io.StringIO(report_csv))
</code></pre>
<p>The scanner processes every row and flags:</p>
<table>
<thead>
<tr>
<th>Finding</th>
<th>Severity</th>
<th>Why It Matters</th>
</tr>
</thead>
<tbody><tr>
<td>Root account with active access keys</td>
<td><strong>CRITICAL</strong></td>
<td>Root accounts should never have access keys — immediate action required</td>
</tr>
<tr>
<td>Console users without MFA</td>
<td><strong>HIGH</strong></td>
<td>Required by every security framework without exception</td>
</tr>
<tr>
<td>Access keys unused for 90+ days</td>
<td><strong>HIGH</strong></td>
<td>Abandoned key or departed engineer — both require action</td>
</tr>
</tbody></table>
<pre><code class="language-python"># Check for stale access keys
if key_active and last_used_dt &lt; stale_threshold:
    days_stale = (now - last_used_dt).days
    result["findings"].append({
        "severity": "HIGH",
        "resource_id": row["user"],
        "resource_type": f"IAM Access Key {key_num}",
        "finding": f"Access key unused for {days_stale} days",
        "recommendation": f"Deactivate or delete this key"
    })
</code></pre>
<hr />
<img src="https://cdn.hashnode.com/uploads/covers/69a6e08356428acc6fe423a5/72fe8eca-f8c1-4ae3-8639-8a387e7c20ed.png" alt="" style="display:block;margin:0 auto" />

<hr />
<h3>The Tagging Scanner — Visibility Across Everything</h3>
<p>The tagging scanner uses the AWS Resource Groups Tagging API — the only API that can return all taggable resources across all services in a single set of API calls. EC2 instances, RDS databases, Lambda functions, S3 buckets, EKS clusters, ElastiCache clusters — one paginator covers all of them.</p>
<pre><code class="language-python">paginator = tagging.get_paginator("get_resources")
for page in paginator.paginate():
    for resource in page["ResourceTagMappingList"]:
        existing_tags = {
            t["Key"]: t["Value"]
            for t in resource.get("Tags", [])
        }
        missing = [t for t in required_tags if t not in existing_tags]
        if missing:
            non_compliant += 1
</code></pre>
<p>The required tags are configurable via environment variable. The default set is <code>Project</code>, <code>Environment</code>, <code>Owner</code>, and <code>ManagedBy</code>. These four tags are the minimum needed to answer the three questions every cloud finance team asks: what is this? what environment is it in? who owns it?</p>
<blockquote>
<p>💡 <strong>The first time I ran the tagging scanner on a real account, the compliance number was 34%.</strong> Every engineer on the team believed they were tagging resources correctly. The number was 34%. Without an automated check, tagging discipline degrades invisibly over time.</p>
</blockquote>
<img src="https://cdn.hashnode.com/uploads/covers/69a6e08356428acc6fe423a5/1828fe7b-6630-4ac9-9e49-17c7df0a855e.png" alt="" style="display:block;margin:0 auto" />

<hr />
<h3>The HTML Report — Why Dark Theme and Why Jinja2</h3>
<p>The HTML report uses a dark theme matching the Tech with Emma brand colors:</p>
<table>
<thead>
<tr>
<th>Color</th>
<th>Hex</th>
<th>Used For</th>
</tr>
</thead>
<tbody><tr>
<td>Background</td>
<td><code>#05070D</code></td>
<td>Deep black base</td>
</tr>
<tr>
<td>Neon green</td>
<td><code>#00FFB2</code></td>
<td>Metric values and headings</td>
</tr>
<tr>
<td>Electric blue</td>
<td><code>#4CC9F0</code></td>
<td>Section headers</td>
</tr>
<tr>
<td>Red</td>
<td><code>#ff4444</code></td>
<td>Critical findings</td>
</tr>
</tbody></table>
<p>The reason I used Jinja2 for templating instead of f-strings or string concatenation is <strong>separation of concerns</strong>. The template defines how the report looks. The Python code defines what data goes into it. If I want to change the report layout I edit the Jinja2 template without touching the scanner logic.</p>
<hr />
<img src="https://cdn.hashnode.com/uploads/covers/69a6e08356428acc6fe423a5/c6b4a650-cd93-4801-b3f4-64e67b4820c8.png" alt="" style="display:block;margin:0 auto" />

<img src="https://cdn.hashnode.com/uploads/covers/69a6e08356428acc6fe423a5/f904b6d8-dbd3-4cae-8337-9843ff4f2015.png" alt="" style="display:block;margin:0 auto" />

<hr />
<h3>The Slack Output — Why Blocks API</h3>
<p>The Slack notification uses the Blocks API rather than simple text formatting. Blocks give you structured layout — a header section, a fields section with key-value pairs in a grid, and a context section for attribution.</p>
<pre><code class="language-python">payload = {
    "attachments": [{
        "color": color,  # Red for critical, orange for high, green for clean
        "blocks": [
            {"type": "header", "text": {...}},
            {"type": "section", "fields": [...]},
            {"type": "context", "elements": [...]}
        ]
    }]
}
</code></pre>
<p>The color on the attachment border changes based on severity:</p>
<table>
<thead>
<tr>
<th>Border Color</th>
<th>Meaning</th>
</tr>
</thead>
<tbody><tr>
<td>🔴 Red</td>
<td>Someone needs to open Slack right now</td>
</tr>
<tr>
<td>🟠 Orange</td>
<td>Action required today</td>
</tr>
<tr>
<td>🟢 Green</td>
<td>Daily scan came back clean</td>
</tr>
</tbody></table>
<p>Engineers can see the status at a glance from the channel list without opening the message.</p>
<img src="https://cdn.hashnode.com/uploads/covers/69a6e08356428acc6fe423a5/77b26ee5-e98a-4827-b0e8-8073cee43dcc.png" alt="" style="display:block;margin:0 auto" />

<img src="https://cdn.hashnode.com/uploads/covers/69a6e08356428acc6fe423a5/310560dd-ee50-4ac2-97e5-ad1edf88ea02.png" alt="" style="display:block;margin:0 auto" />

<img src="https://cdn.hashnode.com/uploads/covers/69a6e08356428acc6fe423a5/24c6e180-315d-4774-807f-860dd19f334a.png" alt="" style="display:block;margin:0 auto" />

<img src="https://cdn.hashnode.com/uploads/covers/69a6e08356428acc6fe423a5/84c4dec9-0e0d-4d85-9ec1-5e78abfe14f3.png" alt="" style="display:block;margin:0 auto" />

<h2>Challenges and Debugging — What Actually Went Wrong</h2>
<h3>Challenge 1 — The Credential Report 503 Error</h3>
<p>The IAM credential report generation is an asynchronous operation. On the first attempt:</p>
<pre><code class="language-plaintext">botocore.exceptions.ClientError: An error occurred (ReportNotPresent)
when calling the GetCredentialReport operation:
No credential report exists. Starting a new report generation task
</code></pre>
<p>Added <code>sleep(2)</code> and ran again:</p>
<pre><code class="language-plaintext">botocore.exceptions.ClientError: An error occurred (ReportInProgress)
when calling the GetCredentialReport operation:
Report generation is still in progress
</code></pre>
<p>Two seconds was not enough. For accounts with many IAM users the generation can take 10–15 seconds. The fix was a polling loop:</p>
<pre><code class="language-python">def wait_for_credential_report(iam_client, max_wait: int = 30) -&gt; str:
    """Poll until the credential report is ready."""
    start = time.time()
    iam_client.generate_credential_report()

    while time.time() - start &lt; max_wait:
        try:
            response = iam_client.get_credential_report()
            return response["Content"].decode("utf-8")
        except iam_client.exceptions.ReportInProgress:
            time.sleep(2)
        except iam_client.exceptions.ReportNotPresent:
            time.sleep(1)

    raise TimeoutError("Credential report did not complete in time")
</code></pre>
<blockquote>
<p>⏱️ Time spent debugging: 25 minutes. The fix itself took 3 minutes.</p>
</blockquote>
<hr />
<h3>Challenge 2 — The S3 Public Access Check False Positive</h3>
<p>The S3 scanner was picking up buckets from all regions but only successfully checking the ones in <code>us-east-1</code>. Buckets in <code>eu-west-1</code> were returning:</p>
<pre><code class="language-plaintext">botocore.exceptions.ClientError: An error occurred (IllegalLocationConstraintException)
when calling the GetBucketPublicAccessBlock operation:
The specified location-constraint is not valid
</code></pre>
<p>The fix was to get the bucket's actual region first and create a region-specific client for each public access check:</p>
<pre><code class="language-python"># Get bucket region
location = s3.get_bucket_location(Bucket=name)
bucket_region = location["LocationConstraint"] or "us-east-1"

# Create region-specific client for this bucket
regional_s3 = get_client("s3", bucket_region)
pub = regional_s3.get_bucket_public_access_block(Bucket=name)
</code></pre>
<blockquote>
<p>⏱️ This adds approximately 400ms per bucket due to the additional API call. For an account with 100 buckets that is 40 extra seconds — acceptable for a daily scan.</p>
</blockquote>
<hr />
<h3>Challenge 3 — Moto Tests Breaking on Rich Console Output</h3>
<p>The first test run output was filled with Rich terminal formatting codes that made the test output unreadable. The Rich handler was firing even during tests because the logger was configured at module import time.</p>
<p>The fix was to check for a test environment flag:</p>
<pre><code class="language-python">def get_logger(name: str) -&gt; logging.Logger:
    logger = logging.getLogger(name)
    if not logger.handlers:
        if os.environ.get("TESTING"):
            handler = logging.StreamHandler()
        else:
            handler = RichHandler(...)
</code></pre>
<pre><code class="language-bash"># In test configuration
export TESTING=1
python3 -m pytest tests/ -v
</code></pre>
<hr />
<h3>Challenge 4 — SES Sandbox Rejection</h3>
<pre><code class="language-plaintext">botocore.exceptions.ClientError: An error occurred (MessageRejected)
when calling the SendEmail operation:
Email address is not verified. The following identities failed
the check in region US-EAST-1: devopsalert3@gmail.com
</code></pre>
<p>AWS SES starts every account in sandbox mode. Both the sender <strong>and</strong> the recipient must be verified. The fix:</p>
<pre><code class="language-bash">aws ses verify-email-identity \
  --email-address devopsalert3@gmail.com \
  --region us-east-1
</code></pre>
<p>Click the verification link in the email that arrives. For production use, request SES production access which removes the sandbox restriction.</p>
<img src="https://cdn.hashnode.com/uploads/covers/69a6e08356428acc6fe423a5/93c96899-9d40-41ba-9d99-5cc56b8c0541.png" alt="" style="display:block;margin:0 auto" />

<h3>Challenge 5 — DynamoDB ListTables Pagination</h3>
<p>On an account with 340 tables, the scanner only reported 100. The <code>list_tables</code> API uses a different cursor mechanism than most AWS list APIs:</p>
<pre><code class="language-python"># Wrong — only gets first 100 tables
response = dynamodb.list_tables()
tables = response["TableNames"]

# Correct — paginates through all tables
tables = []
last_evaluated_name = None
while True:
    kwargs = {"Limit": 100}
    if last_evaluated_name:
        kwargs["ExclusiveStartTableName"] = last_evaluated_name

    response = dynamodb.list_tables(**kwargs)
    tables.extend(response["TableNames"])

    last_evaluated_name = response.get("LastEvaluatedTableName")
    if not last_evaluated_name:
        break
</code></pre>
<blockquote>
<p>⚠️ <strong>The pagination token for</strong> <code>list_tables</code> <strong>is a table name, not an opaque token.</strong> Always check the API documentation for the specific pagination mechanism rather than assuming all AWS APIs behave identically.</p>
</blockquote>
<hr />
<h2>Validation — Proving the Tool Works</h2>
<h3>Run the Tool and Verify Each Output</h3>
<pre><code class="language-bash"># Run the full scan
python3 -m inventory.main scan --region us-east-1

# Verify JSON output was created
ls -lh reports/inventory_*.json

# Verify CSV findings were created
ls -lh reports/findings_*.csv
cat reports/findings_*.csv | head -10

# Verify HTML report was created
ls -lh reports/report_*.html
wc -l reports/report_*.html

# Verify S3 archive
aws s3 ls s3://${INVENTORY_S3_BUCKET}/inventory-reports/ \
  --recursive --region us-east-1
</code></pre>
<hr />
<img src="https://cdn.hashnode.com/uploads/covers/69a6e08356428acc6fe423a5/fbe07d75-88df-4cc4-b970-ba3ba3d68309.png" alt="" style="display:block;margin:0 auto" />

<img src="https://cdn.hashnode.com/uploads/covers/69a6e08356428acc6fe423a5/29b6a92c-e223-4eff-b82b-d22460f69043.png" alt="" style="display:block;margin:0 auto" />

<hr />
<h3>Verify a Known Finding Is Detected</h3>
<pre><code class="language-bash"># Create an unencrypted EBS volume — should trigger HIGH finding
aws ec2 create-volume \
  --availability-zone us-east-1a \
  --size 1 \
  --encrypted false \
  --region us-east-1 \
  --tag-specifications 'ResourceType=volume,Tags=[{Key=Name,Value=test-unencrypted}]'

# Run the scanner
python3 -m inventory.main scan \
  --services ec2 \
  --no-slack \
  --no-email \
  --no-s3

# Grep the findings CSV for the expected finding
grep "not encrypted" reports/findings_*.csv

# Clean up the test volume
VOLUME_ID=$(aws ec2 describe-volumes \
  --filters "Name=tag:Name,Values=test-unencrypted" \
  --query 'Volumes[0].VolumeId' \
  --output text --region us-east-1)

aws ec2 delete-volume \
  --volume-id ${VOLUME_ID} \
  --region us-east-1
</code></pre>
<p>Expected grep output:</p>
<pre><code class="language-plaintext">HIGH,EC2,EBS Volume,vol-0abc123def,EBS volume is not encrypted,Enable EBS encryption
</code></pre>
<p>The HIGH finding appeared exactly as expected.</p>
<hr />
<h2>Security Posture at a Glance</h2>
<table>
<thead>
<tr>
<th>Security Property</th>
<th>Without This Tool</th>
<th>With This Tool</th>
</tr>
</thead>
<tbody><tr>
<td>Unencrypted EBS volumes</td>
<td>Unknown until audit</td>
<td>Detected and flagged HIGH automatically</td>
</tr>
<tr>
<td>Stale IAM access keys</td>
<td>Manual quarterly review</td>
<td>Flagged after 90 days, daily</td>
</tr>
<tr>
<td>S3 public access gaps</td>
<td>Unknown across 47 buckets</td>
<td>Scanned per bucket with region-aware client</td>
</tr>
<tr>
<td>Tagging compliance</td>
<td>Unknown — estimated at 100%</td>
<td>Measured exactly — real number often 30–40%</td>
</tr>
<tr>
<td>Root account with access keys</td>
<td>Unknown</td>
<td>CRITICAL finding surfaces immediately</td>
</tr>
<tr>
<td>Audit evidence</td>
<td>Manual spreadsheet</td>
<td>Timestamped JSON, CSV, HTML archived in S3</td>
</tr>
</tbody></table>
<hr />
<h2>Key Lessons From This Build</h2>
<p><strong>The credential report is the most valuable single API call in all of AWS.</strong> It answers every question a security auditor asks about IAM user posture in one call. If you build nothing else from this article, build the part that generates, downloads, and parses the credential report daily.</p>
<p><strong>The tagging compliance number will be lower than you expect.</strong> The first real account came back at 34%. Running the tagging scanner weekly and Slack-alerting on any decrease in the compliance percentage changed the team's behaviour more than any policy document or training session.</p>
<p><strong>moto tests are worth the setup time.</strong> The moto tests caught two issues that would have produced silent incorrect results in production: the DynamoDB pagination bug and a condition in the IAM scanner where keys with a last-used date of exactly the threshold day were being classified incorrectly.</p>
<hr />
<h2>Production Gap Analysis</h2>
<p><strong>Cross-region scanning.</strong> The current implementation scans a single region. A production implementation should accept a list of regions and run scanners in parallel using <code>concurrent.futures.ThreadPoolExecutor</code>.</p>
<p><strong>Findings deduplication.</strong> If you run this daily, findings present yesterday and still present today should be tracked as a single ongoing finding, not reported as a new finding each day. DynamoDB is the right store for this — keyed by resource ARN and finding type with first-seen and last-seen timestamps.</p>
<p><strong>Auto-remediation for low-risk findings.</strong> Unassociated Elastic IPs and unattached EBS volumes are pure cost waste. For findings like these where remediation carries no risk, add an <code>--auto-remediate</code> flag that releases unassociated EIPs and snapshots then deletes unattached volumes older than 30 days.</p>
<p><strong>Ticketing system integration.</strong> A CRITICAL finding should open a P1 in your ticketing system automatically with the resource ID, the finding, and the exact AWS CLI command to fix it. The Jira API and GitHub Issues API are both workable targets.</p>
<hr />
<h2>Cleanup</h2>
<pre><code class="language-bash">bash scripts/destroy-all.sh
</code></pre>
<p>This script empties the S3 bucket, terminates the EC2 runner, and removes the IAM role and policy.</p>
<pre><code class="language-bash"># Quick verification
aws ec2 describe-instances \
  --filters "Name=tag:Project,Values=aws-resource-inventory" \
           "Name=instance-state-name,Values=running" \
  --query 'Reservations[*].Instances[*].InstanceId' \
  --output text --region us-east-1
</code></pre>
<p>Expected output: empty. No running instances.</p>
<hr />
<h2>What's Next</h2>
<p>Project 14 is a <strong>self-managed, highly available PostgreSQL cluster on Kubernetes</strong> with production load testing — watching zero transactions drop when the primary node is killed during a 500-user pgBench run.</p>
<p>Follow me at <a href="https://emmanuelubani.hashnode.dev">emmanuelubani.hashnode.dev</a> for every new project as it ships.</p>
<hr />
<h2>References</h2>
<ul>
<li><p><a href="https://docs.aws.amazon.com/IAM/latest/UserGuide/id_credentials_getting-report.html">AWS IAM Credential Report Documentation</a></p>
</li>
<li><p><a href="https://docs.aws.amazon.com/resourcegroupstagging/latest/APIReference/overview.html">AWS Resource Groups Tagging API</a></p>
</li>
<li><p><a href="https://typer.tiangolo.com/">Typer CLI Framework</a></p>
</li>
<li><p><a href="https://rich.readthedocs.io/">Rich Terminal Library</a></p>
</li>
<li><p><a href="https://jinja.palletsprojects.com/">Jinja2 Templating</a></p>
</li>
<li><p><a href="https://docs.getmoto.org/">Moto AWS Mocking Library</a></p>
</li>
<li><p><a href="https://boto3.amazonaws.com/v1/documentation/api/latest/index.html">Boto3 Documentation</a></p>
</li>
<li><p><a href="https://docs.aws.amazon.com/ses/latest/dg/request-production-access.html">AWS SES Sandbox and Production Access</a></p>
</li>
<li><p><a href="https://docs.aws.amazon.com/AmazonS3/latest/userguide/access-control-block-public-access.html">AWS S3 Public Access Block</a></p>
</li>
<li><p><a href="https://www.pcisecuritystandards.org/">PCI-DSS Cloud Security Requirements</a></p>
</li>
</ul>
<hr />
<p><em>Emmanuel Ubani is a Cloud and DevOps Engineer based in Lagos, Nigeria. He writes about real infrastructure builds, career transitions into tech, and lessons learned the hard way.</em></p>
<ul>
<li><p>🔗 <a href="https://linkedin.com/in/ubaniemmanuel">LinkedIn</a></p>
</li>
<li><p>💻 <a href="https://github.com/Eaglewings966">GitHub</a></p>
</li>
<li><p>✍️ <a href="https://emmanuelubani.hashnode.dev">Hashnode</a></p>
</li>
<li><p>📝 <a href="https://medium.com/@emmaubani966">Medium</a></p>
</li>
<li><p>🌐 <a href="https://ops-run.lovable.app">Portfolio</a></p>
</li>
</ul>
]]></content:encoded></item><item><title><![CDATA[I Eliminated 847 Static SSH Keys From Production Infrastructure Using HashiCorp Boundary, Vault SSH CA, and Okta OIDC — A Complete Implementation Guide]]></title><description><![CDATA[Privileged Access Management with just-in-time 3-minute SSH certificates, session recording, and automated audit reporting — zero persistent credentials…]]></description><link>https://emmanuelubani.hashnode.dev/i-eliminated-847-static-ssh-keys-from-production-infrastructure-using-hashicorp-boundary-vault-ssh-ca-and-okta-oidc-a-complete-implementation-guide</link><guid isPermaLink="true">https://emmanuelubani.hashnode.dev/i-eliminated-847-static-ssh-keys-from-production-infrastructure-using-hashicorp-boundary-vault-ssh-ca-and-okta-oidc-a-complete-implementation-guide</guid><category><![CDATA[DevSecOps]]></category><category><![CDATA[Devops]]></category><category><![CDATA[cybersecurity]]></category><category><![CDATA[AWS]]></category><category><![CDATA[Kubernetes]]></category><category><![CDATA[hashicorp-vault]]></category><dc:creator><![CDATA[Ubani Obiajulum Emmanuel]]></dc:creator><pubDate>Wed, 15 Jul 2026 10:12:26 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/69a6e08356428acc6fe423a5/699f436a-d813-426c-9cc0-43d27d8904ae.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<hr />
<h2>It Was 2am When the Security Audit Report Landed in My Inbox</h2>
<p>The subject line read: <strong>"CRITICAL FINDINGS — Immediate Action Required."</strong></p>
<p>I opened it half-asleep. The first table stopped me cold.</p>
<p><strong>847 active SSH keys</strong> across 63 production servers. 312 of them belonged to engineers who had left the company more than six months ago. 47 of those engineers had left over a year ago. One key was traced back to a contractor engagement that ended in 2019.</p>
<p>Every single one of those keys could still authenticate to a production server right now.</p>
<p>The security team gave us three options:</p>
<table>
<thead>
<tr>
<th>Option</th>
<th>Approach</th>
<th>Problem</th>
</tr>
</thead>
<tbody><tr>
<td><strong>Option 1</strong></td>
<td>Rotate all 847 keys manually</td>
<td>Three months of work. Breaks unknown automated processes nobody documented</td>
</tr>
<tr>
<td><strong>Option 2</strong></td>
<td>Implement a bastion host</td>
<td>Still a server with static credentials — accumulates the same problem in a different location</td>
</tr>
<tr>
<td><strong>Option 3</strong></td>
<td>Eliminate persistent SSH credentials entirely</td>
<td>This is what I built</td>
</tr>
</tbody></table>
<p>This article is the complete implementation guide.</p>
<hr />
<h2>📺 Watch the Full Build on YouTube</h2>
<p>I recorded the complete build walkthrough for this project. If you prefer watching over reading, or want to see every terminal command and every error in real time, the full video is on YouTube.</p>
<p><a href="https://youtu.be/7MW_CgnlZ9I?si=l6EAxmSCwTG70hbJ"><em>Watch the Full Build on YouTube</em></a></p>
<p>The written breakdown continues below with every command, every configuration file, every mistake I made, and the lessons I would only learn by building this in a real environment.</p>
<hr />
<h2>What Is Privileged Access Management and Why Most Teams Get It Wrong</h2>
<p>The standard approach goes like this. An engineer joins the team. Someone generates an SSH key pair for them. The public key is added to the <code>authorized_keys</code> file on every server they need access to. The engineer has access. Everything works.</p>
<p>Then the engineer leaves. Someone removes their Slack access. Someone disables their GitHub account. Someone deactivates their Okta login. But the SSH key? <strong>It sits in that</strong> <code>authorized_keys</code> <strong>file until someone specifically hunts it down and removes it.</strong> Which never happens systematically. Which is how you end up with 847 active keys belonging to people who no longer work for you.</p>
<p>The correct solution is not better key management. It is making <strong>persistent SSH keys architecturally impossible.</strong></p>
<p>In this project I built a PAM platform with three components working together:</p>
<ul>
<li><p><strong>HashiCorp Boundary</strong> — session brokering and access control</p>
</li>
<li><p><strong>HashiCorp Vault SSH CA</strong> — credential issuance with a 3-minute TTL</p>
</li>
<li><p><strong>Okta OIDC</strong> — identity and group-based authorization</p>
</li>
</ul>
<p>When an engineer leaves and their Okta account is deprovisioned, their access to every server disappears in the same instant. No key rotation. No access review. <strong>One deprovisioning action covers everything.</strong></p>
<p>🔗 Full code: <a href="https://github.com/Eaglewings966/privileged-access-management">github.com/Eaglewings966/privileged-access-management</a></p>
<hr />
<h2>The Architecture — What Gets Built and Why Each Component Exists</h2>
<hr />
<img src="https://cdn.hashnode.com/uploads/covers/69a6e08356428acc6fe423a5/9aceaea3-bf81-443c-9e0e-960abe0816e2.png" alt="" style="display:block;margin:0 auto" />

<hr />
<p>The system has six layers. Understanding why each layer exists makes the configuration decisions make sense rather than feeling like arbitrary complexity.</p>
<h3>Layer 1 — Identity (Okta OIDC)</h3>
<p>Okta is the source of truth for who you are and what groups you belong to. Group membership in Okta maps directly to access policies in Boundary:</p>
<ul>
<li><p><code>pam-admins</code> group → access to production targets</p>
</li>
<li><p><code>developers</code> group → access to development targets only</p>
</li>
</ul>
<p>When someone is removed from a group, their access disappears immediately. The reason I chose Okta over AWS IAM for identity is <strong>portability</strong> — engineers already have Okta accounts, their laptops are managed by Okta device trust, their VPN uses Okta. Adding PAM to that same identity layer means one less credential set and one less deprovisioning step when someone leaves.</p>
<hr />
<h3>Layer 2 — Session Brokering (HashiCorp Boundary)</h3>
<p>Boundary is <strong>not</strong> a bastion host. This distinction matters enormously.</p>
<table>
<thead>
<tr>
<th>Bastion Host</th>
<th>HashiCorp Boundary</th>
</tr>
</thead>
<tbody><tr>
<td>A server your SSH traffic passes through</td>
<td>A protocol-aware access proxy</td>
</tr>
<tr>
<td>Has its own credentials</td>
<td>Stateless in the connection path</td>
</tr>
<tr>
<td>Must be patched and maintained</td>
<td>Brokers connections, doesn't terminate them</td>
</tr>
<tr>
<td>Single point of failure</td>
<td>Target server authenticates your certificate directly</td>
</tr>
</tbody></table>
<p>I deployed Boundary on EKS EC2 with a PostgreSQL RDS backend for state and ElastiCache Redis for session storage. Boundary's entire access model — which targets exist, which users can access them, which sessions are active — lives in PostgreSQL. If you use an in-memory store, you lose all of this on restart.</p>
<hr />
<h3>Layer 3 — Credential Issuance (Vault SSH CA)</h3>
<p>This is the most important component in the entire platform. Vault acts as a Certificate Authority for SSH. When Boundary approves an access request, it calls Vault and asks for a signed SSH certificate for the requesting user. Vault issues that certificate with a TTL of exactly <strong>180 seconds</strong>.</p>
<p>The certificate contains:</p>
<ul>
<li><p>The user's identity (their username)</p>
</li>
<li><p>The principals they can authenticate as (<code>ec2-user</code>)</p>
</li>
<li><p>The exact validity window (now to now + 3 minutes)</p>
</li>
<li><p>A cryptographic signature from the Vault CA</p>
</li>
</ul>
<p>The target server has <strong>one line</strong> added to its <code>sshd_config</code>:</p>
<pre><code class="language-bash">TrustedUserCAKeys /etc/ssh/vault-client-ca.pub
</code></pre>
<p>That single line means the server will accept any SSH certificate signed by the Vault CA. It will not accept static SSH keys. The <code>authorized_keys</code> file is empty. Static key authentication is disabled entirely.</p>
<hr />
<h3>Layer 4 — Target Instances (EC2 in Private Subnets)</h3>
<p>Target EC2 instances sit in private subnets with no inbound rules from the internet. Port 22 is only open from within the VPC. The only way to reach them is through Boundary. Even if an attacker somehow obtained a valid Vault SSH certificate, they would still need network access to the private subnet to use it.</p>
<hr />
<h3>Layer 5 — Session Recording (S3)</h3>
<p>Every session brokered by Boundary is recorded and stored in an S3 bucket with server-side KMS encryption. The recording captures every command typed, every output returned, and full timestamps. <strong>The recording is immutable — it cannot be deleted by the engineer who created it.</strong></p>
<p>S3 lifecycle policy: <code>STANDARD_IA</code> after 30 days → <code>GLACIER</code> after 90 days. Long-term compliance retention at minimal cost.</p>
<hr />
<h3>Layer 6 — Audit and Alerting (Python + CloudWatch + SNS)</h3>
<p>A Python script reads the access log and S3 session metadata and generates a weekly HTML audit report showing every access event per user per target per environment. A CloudWatch alarm fires an SNS email whenever an access request occurs outside business hours (8am–6pm WAT, Monday–Friday).</p>
<blockquote>
<p>The weekly report that used to require a security engineer to manually correlate SSH logs across 63 servers now runs in <strong>4 seconds</strong>.</p>
</blockquote>
<hr />
<h2>The Implementation — Every Command in Sequence</h2>
<h3>Creating the EKS EC2 Cluster</h3>
<p>Everything runs from an SSH session into an EC2 bastion or directly from AWS CloudShell.</p>
<pre><code class="language-bash"># SSH into your EC2 bastion first
ssh -i bastion-key.pem ubuntu@&lt;BASTION_IP&gt;

# Create EKS cluster with EC2 node groups
eksctl create cluster \
  --name pam-cluster \
  --region us-east-1 \
  --node-type t3.medium \
  --nodes 3 \
  --nodes-min 2 \
  --nodes-max 5

# Configure kubectl
aws eks update-kubeconfig \
  --region us-east-1 \
  --name pam-cluster

# Verify
kubectl get nodes
</code></pre>
<p>Wait for all three nodes to show <code>Ready</code> status before proceeding. On EC2 node groups this takes approximately 5 minutes.</p>
<hr />
<h3>Provisioning Infrastructure with Terraform</h3>
<p>The Terraform configuration provisions nine categories of resources: RDS PostgreSQL, ElastiCache Redis, S3 for session recordings, EC2 target instances, IAM roles, security groups, SNS topics, CloudWatch alarms, and Kubernetes namespaces.</p>
<pre><code class="language-bash">cd privileged-access-management/terraform

# Initialize with S3 backend
terraform init \
  -backend-config="bucket=your-tfstate-bucket" \
  -backend-config="key=pam/terraform.tfstate" \
  -backend-config="region=us-east-1"

terraform fmt
terraform validate
terraform plan -out=tfplan
terraform apply tfplan
</code></pre>
<blockquote>
<p>⏱️ The apply takes approximately <strong>12 minutes</strong>. The longest component is the ElastiCache Redis cluster which takes around 8 minutes even for a single <code>cache.t3.micro</code> node.</p>
</blockquote>
<p>After apply, save the outputs:</p>
<pre><code class="language-bash">terraform output

PROD_IP=$(terraform output -raw target_prod_private_ip)
DEV_IP=$(terraform output -raw target_dev_private_ip)
BUCKET=$(terraform output -raw session_recordings_bucket)
RDS_ENDPOINT=$(terraform output -raw rds_endpoint)
REDIS_ENDPOINT=$(terraform output -raw redis_endpoint)
</code></pre>
<hr />
<img src="https://cdn.hashnode.com/uploads/covers/69a6e08356428acc6fe423a5/ed4d69c3-969c-4bc3-ad46-9a4cf018fe7f.png" alt="" style="display:block;margin:0 auto" />

<hr />
<h3>Installing Vault and Configuring the SSH CA</h3>
<pre><code class="language-bash"># Add the HashiCorp Helm repository
helm repo add hashicorp https://helm.releases.hashicorp.com
helm repo update

# Install Vault
helm install vault hashicorp/vault \
  --namespace vault \
  --version 0.27.0 \
  -f vault/values.yaml

# Wait for the pod to be Running
kubectl get pods -n vault --watch
</code></pre>
<p>Once the pod shows <code>1/1 Running</code>, initialize Vault:</p>
<pre><code class="language-bash"># Port-forward Vault API
kubectl port-forward svc/vault -n vault 8200:8200 &amp;
sleep 3

export VAULT_ADDR=http://localhost:8200

# Initialize with 3 key shares, threshold of 2
kubectl exec -it vault-0 -n vault -- \
  vault operator init \
    -key-shares=3 \
    -key-threshold=2 \
    -format=json &gt; vault-init.json

# Save the output immediately
cat vault-init.json

# Extract and export root token
ROOT_TOKEN=$(cat vault-init.json | jq -r '.root_token')
UNSEAL_1=$(cat vault-init.json | jq -r '.unseal_keys_b64[0]')
UNSEAL_2=$(cat vault-init.json | jq -r '.unseal_keys_b64[1]')

export VAULT_TOKEN=${ROOT_TOKEN}

# Unseal Vault
vault operator unseal ${UNSEAL_1}
vault operator unseal ${UNSEAL_2}

# Verify
vault status
</code></pre>
<hr />
<img src="https://cdn.hashnode.com/uploads/covers/69a6e08356428acc6fe423a5/a6277d19-942c-4af5-b529-afb6272a544d.png" alt="" style="display:block;margin:0 auto" />

<hr />
<p>Now configure the SSH CA:</p>
<pre><code class="language-bash"># Set the target IPs before running the script
export PROD_TARGET_IP=${PROD_IP}
export DEV_TARGET_IP=${DEV_IP}
export SSH_CERT_TTL=180

# Run the full Vault SSH CA configuration
bash vault/config/auth-setup.sh
</code></pre>
<p>The <code>auth-setup.sh</code> script performs eight configuration steps: enables the <code>ssh-client-signer</code> and <code>ssh-host-signer</code> secrets engines, generates CA key pairs for both, creates signing roles for <code>pam-admin</code> and <code>pam-developer</code> with a 180-second TTL, creates the Vault policies, enables Kubernetes auth for Boundary, and writes the target metadata to KV v2.</p>
<p>After the script completes, verify the SSH CA is working:</p>
<pre><code class="language-bash"># Test certificate signing
ssh-keygen -t ed25519 -f /tmp/test-key -N "" -q

vault write ssh-client-signer/sign/pam-admin \
  public_key=@/tmp/test-key.pub \
  valid_principals="ec2-user"

# Show the certificate details
vault write -field=signed_key \
  ssh-client-signer/sign/pam-admin \
  public_key=@/tmp/test-key.pub &gt; /tmp/test-cert.pub

ssh-keygen -L -f /tmp/test-cert.pub
</code></pre>
<p>The <code>ssh-keygen -L</code> output is the most important verification in this entire project. Look at the <code>Valid</code> line:</p>
<pre><code class="language-plaintext">Valid: from 2024-01-15T14:23:07 to 2024-01-15T14:26:07
</code></pre>
<p>The difference between <code>from</code> and <code>to</code> is <strong>exactly 3 minutes</strong>. That is your proof that the SSH CA is configured correctly.</p>
<hr />
<img src="https://cdn.hashnode.com/uploads/covers/69a6e08356428acc6fe423a5/7af2739f-ac4f-4d2f-8239-e2f3065daae0.png" alt="" style="display:block;margin:0 auto" />

<hr />
<pre><code class="language-bash"># Clean up test keys
rm -f /tmp/test-key /tmp/test-key.pub /tmp/test-cert.pub
</code></pre>
<hr />
<h3>Configuring Target Instances to Trust Vault CA</h3>
<p>Each target EC2 instance needs one configuration change: it must be told to trust SSH certificates signed by the Vault CA. The <code>setup-ssh-ca.sh</code> script handles this for all targets:</p>
<pre><code class="language-bash">bash vault/ssh-ca/setup-ssh-ca.sh \
  "${PROD_IP},${DEV_IP}" \
  targets-key.pem \
  ec2-user
</code></pre>
<p>What this script does step by step:</p>
<p><strong>1. Fetches the CA public key from Vault:</strong></p>
<pre><code class="language-bash">CA_PUBLIC_KEY=$(vault read \
  -field=public_key \
  ssh-client-signer/config/ca)
</code></pre>
<p><strong>2. Uploads the CA key and configures</strong> <code>sshd</code> <strong>on each target:</strong></p>
<pre><code class="language-bash">ssh -i targets-key.pem ec2-user@${TARGET_IP} \
  "sudo bash -c '
    echo \"${CA_PUBLIC_KEY}\" &gt; /etc/ssh/vault-client-ca.pub
    echo \"TrustedUserCAKeys /etc/ssh/vault-client-ca.pub\" \
      &gt;&gt; /etc/ssh/sshd_config
    systemctl restart sshd
  '"
</code></pre>
<p><strong>3. Removes all existing</strong> <code>authorized_keys</code> <strong>files:</strong></p>
<pre><code class="language-bash">ssh -i targets-key.pem ec2-user@${TARGET_IP} \
  "sudo find /home /root \
    -name authorized_keys \
    -delete 2&gt;/dev/null"
</code></pre>
<blockquote>
<p>⚠️ <strong>After this step, the</strong> <code>targets-key.pem</code> <strong>file cannot be used to access these servers anymore.</strong> The only valid authentication method is a Vault-signed certificate.</p>
</blockquote>
<p>Verify the configuration:</p>
<pre><code class="language-bash"># Try to use the static key — this should FAIL
ssh -i targets-key.pem ec2-user@${PROD_IP}
# Expected: Permission denied (publickey)

# Now get a Vault certificate — this should SUCCEED
bash scripts/access-request.sh ${PROD_IP} ec2-user pam-admin
</code></pre>
<hr />
<img src="https://cdn.hashnode.com/uploads/covers/69a6e08356428acc6fe423a5/8cae8e72-8429-43d1-b0e6-ff38821dc053.png" alt="" style="display:block;margin:0 auto" />

<h3>Testing the Full Access Flow</h3>
<pre><code class="language-bash">bash scripts/access-request.sh \
  ${PROD_IP} \
  ec2-user \
  pam-admin
</code></pre>
<p>Watch the output:</p>
<pre><code class="language-plaintext">================================================
PAM Access Request
================================================
Target:     10.0.1.50
User:       ec2-user
Vault Role: pam-admin
Certificate TTL: 3 minutes

Temporary key pair generated
Requesting certificate from Vault...

Certificate issued:
Type: ssh-rsa-cert-v01@openssh.com user certificate
Key ID: "vault-pam-admin-1705329787"
Valid: from 2024-01-15T14:23:07 to 2024-01-15T14:26:07
Principals: ec2-user
Extensions: permit-pty, permit-port-forwarding

Connecting to 10.0.1.50...
Certificate expires in 3 minutes. Complete your work before then.
================================================

[ec2-user@ip-10-0-1-50 ~]$
</code></pre>
<p>You are now connected via a certificate that will expire in 3 minutes. No static key was used at any point.</p>
<hr />
<img src="https://cdn.hashnode.com/uploads/covers/69a6e08356428acc6fe423a5/1fb3b1b0-bcc2-42ec-8217-2645cba9097e.png" alt="" style="display:block;margin:0 auto" />

<hr />
<h3>Generating the Audit Report</h3>
<pre><code class="language-bash">pip3 install boto3

python3 scripts/generate-audit-report.py \
  --bucket ${BUCKET} \
  --region us-east-1 \
  --output reports/audit-report.html \
  --days 7
</code></pre>
<p>Output:</p>
<pre><code class="language-plaintext">============================================================
PAM Audit Report Generator
============================================================
Period: 2024-01-08 to 2024-01-15
Bucket: pam-session-recordings-123456789

Loading session data...
  Found 12 sessions from access log
  Found 12 sessions from S3 recordings
  Total: 24 sessions

Generating HTML report...
Report generated: reports/audit-report.html
============================================================
</code></pre>
<p><strong>4 seconds.</strong> The report that used to take three days now runs in 4 seconds.</p>
<hr />
<img src="https://cdn.hashnode.com/uploads/covers/69a6e08356428acc6fe423a5/28cedf1b-0896-4f68-b896-c524b38ee3d1.png" alt="" style="display:block;margin:0 auto" />

<hr />
<h2>Challenges and Debugging — The Parts Nobody Warns You About</h2>
<h3>Challenge 1 — The SSH Lockout That Cost Me a Full Instance</h3>
<p>This is the most expensive mistake in the entire build. I am documenting it in detail because I have seen this exact error destroy hours of work for multiple engineers.</p>
<p>I was configuring the Ansible role that hardens the SSH config on target instances. The role included this task:</p>
<pre><code class="language-yaml">- name: Restrict SSH to specific users
  lineinfile:
    path: /etc/ssh/sshd_config
    line: "AllowUsers ec2-user"
    state: present
  notify: Restart sshd
</code></pre>
<p>The playbook ran against a mixed inventory that included both Amazon Linux instances (default user: <code>ec2-user</code>) and Ubuntu instances (default user: <code>ubuntu</code>). The handler fired. <code>sshd</code> restarted on the Ubuntu instance with <code>AllowUsers ec2-user</code>. The user <code>ec2-user</code> does not exist on Ubuntu. <strong>I lost SSH access permanently.</strong></p>
<p>Recovery via AWS SSM Session Manager:</p>
<pre><code class="language-bash">aws ssm start-session \
  --target i-0abc123def456 \
  --region us-east-1
</code></pre>
<p>SSM worked because it bypasses <code>sshd</code> entirely. I corrected the config:</p>
<pre><code class="language-bash">sudo sed -i '/AllowUsers ec2-user/d' /etc/ssh/sshd_config
sudo systemctl restart sshd
</code></pre>
<p>The fix — always parameterize the SSH username per OS:</p>
<pre><code class="language-yaml">vars:
  ssh_user: "{{ 'ec2-user' if ansible_distribution == 'Amazon'
                else 'ubuntu' }}"

tasks:
  - name: Restrict SSH to correct user
    lineinfile:
      path: /etc/ssh/sshd_config
      line: "AllowUsers {{ ssh_user }}"
</code></pre>
<blockquote>
<p>⚠️ <strong>Time lost: 45 minutes plus one terminated and reprovisioned instance.</strong> Before running any <code>sshd</code> configuration change, verify that AWS SSM Session Manager is enabled and working. SSM is your emergency parachute when SSH configuration changes go wrong.</p>
</blockquote>
<hr />
<h3>Challenge 2 — Vault SSH CA Not Trusted After sshd Restart</h3>
<p>After configuring the first target instance, I tested a certificate connection and got:</p>
<pre><code class="language-plaintext">sign_and_send_pubkey: signing failed: agent refused operation
ec2-user@10.0.1.50: Permission denied (publickey)
</code></pre>
<p>The certificate was valid. The CA key was on the server. The <code>TrustedUserCAKeys</code> line was in <code>sshd_config</code>. But authentication was failing.</p>
<p>I ran <code>sshd</code> in debug mode to find the actual cause:</p>
<pre><code class="language-bash">sudo /usr/sbin/sshd -d -p 2222
</code></pre>
<p>The output showed:</p>
<pre><code class="language-plaintext">debug1: Certificate type 1 not permitted by TrustedUserCAKeys
</code></pre>
<p>Certificate type <code>1</code> is a host certificate. Type <code>0</code> is a user certificate. My Vault signing role had accidentally set <code>allow_host_certificates=true</code>. The fix:</p>
<pre><code class="language-bash">vault write ssh-client-signer/roles/pam-admin \
  allow_user_certificates=true \
  allow_host_certificates=false \
  key_type="ca" \
  default_user="ec2-user" \
  ttl="180s"
</code></pre>
<blockquote>
<p>💡 <strong>Always run</strong> <code>sshd</code> <strong>in debug mode on a test port when SSH certificate authentication fails.</strong> The debug output tells you exactly which check is failing and why.</p>
</blockquote>
<hr />
<h3>Challenge 3 — ElastiCache Redis TLS Connection Error</h3>
<p>Terraform provisioned ElastiCache with <code>transit_encryption_enabled=true</code>. When Boundary tried to connect to Redis it failed with:</p>
<pre><code class="language-plaintext">ERR This Redis cluster does not support non-TLS connections
</code></pre>
<p>The Boundary configuration was using a plain Redis URL. ElastiCache with transit encryption requires the <code>rediss://</code> scheme (double <code>s</code> for SSL). Two fixes required:</p>
<pre><code class="language-bash"># Update the Redis URL scheme
REDIS_URL="rediss://pam-sessions.xxxxx.cache.amazonaws.com:6379"

# Add the AWS certificate bundle
curl -o /etc/ssl/certs/aws-elasticache-bundle.pem \
  https://www.amazontrust.com/repository/AmazonRootCA1.pem
</code></pre>
<blockquote>
<p>💡 <strong>Any time you enable transit encryption on an AWS managed service, update every client connection string to use the encrypted scheme.</strong> <code>rediss://</code> for Redis, <code>jdbc:postgresql://</code> with <code>sslmode=require</code> for PostgreSQL.</p>
</blockquote>
<hr />
<h3>Challenge 4 — OIDC Redirect URI Mismatch With Okta</h3>
<p>The first login attempt failed with an Okta error:</p>
<pre><code class="language-plaintext">The redirect URI in the request did not match a registered redirect URI.
</code></pre>
<p>The difference between what Boundary sent and what Okta had registered:</p>
<pre><code class="language-plaintext"># What Boundary sent:
http://localhost:7171/v1/auth-methods/oidc:authenticate:callback

# What Okta had registered:
http://localhost:7171/v1/auth-methods/oidc:authenticate:callback/
</code></pre>
<p><strong>One trailing slash.</strong> Okta treats these as completely different URIs.</p>
<blockquote>
<p>⚠️ <strong>OIDC redirect URI matching is exact string comparison.</strong> No trailing slash difference, no <code>http</code> vs <code>https</code> difference, no subdomain difference is tolerated. <strong>Copy and paste from the Boundary documentation. Never type it manually.</strong></p>
</blockquote>
<hr />
<h3>Challenge 5 — Boundary Database Init Must Run Before Controller Starts</h3>
<p>The first time the Boundary controller pod started it immediately entered <code>CrashLoopBackOff</code>:</p>
<pre><code class="language-plaintext">[ERROR] controller: error connecting to database: 
  ERROR: relation "boundary_schema_version" does not exist
</code></pre>
<p>Boundary requires the database schema to be initialized before the controller can start. The fix was a Kubernetes Job:</p>
<pre><code class="language-yaml">apiVersion: batch/v1
kind: Job
metadata:
  name: boundary-db-init
  namespace: boundary
spec:
  template:
    spec:
      containers:
      - name: boundary-init
        image: hashicorp/boundary:latest
        command:
          - boundary
          - database
          - init
          - -config
          - /boundary/config/boundary.hcl
        env:
          - name: BOUNDARY_POSTGRES_URL
            valueFrom:
              secretKeyRef:
                name: boundary-db-credentials
                key: connection_string
      restartPolicy: OnFailure
</code></pre>
<blockquote>
<p>💡 <strong>Any Boundary deployment must include a database initialization Job that completes before the controller Deployment starts.</strong> The <code>CrashLoopBackOff</code> error message is not helpful — always check the full log output for the actual cause.</p>
</blockquote>
<hr />
<h2>Validation — Proving Every Component Works</h2>
<h3>Validating Static Key Authentication Is Completely Disabled</h3>
<pre><code class="language-bash">ssh -i targets-key.pem ec2-user@${PROD_IP}
# Expected: Permission denied (publickey)
</code></pre>
<p>Static keys are rejected. The target server will only accept certificates signed by the Vault CA.</p>
<hr />
<h3>Validating the 3-Minute TTL Enforcement</h3>
<pre><code class="language-bash"># Generate and use a certificate
ssh-keygen -t ed25519 -f /tmp/demo-key -N "" -q

vault write -field=signed_key \
  ssh-client-signer/sign/pam-admin \
  public_key=@/tmp/demo-key.pub &gt; /tmp/demo-cert.pub

# Connect successfully
ssh -i /tmp/demo-key \
    -i /tmp/demo-cert.pub \
    -o StrictHostKeyChecking=no \
    ec2-user@${PROD_IP}

# Wait 4 minutes then try the SAME certificate
sleep 240

ssh -i /tmp/demo-key \
    -i /tmp/demo-cert.pub \
    -o StrictHostKeyChecking=no \
    ec2-user@${PROD_IP}
# Expected: Permission denied (publickey)
</code></pre>
<p>The certificate expired. The cryptographic signature is now outside its validity window. The server rejects it.</p>
<hr />
<img src="https://cdn.hashnode.com/uploads/covers/69a6e08356428acc6fe423a5/ccadd97f-9484-4645-8d1d-b0b7d66b26dc.png" alt="" style="display:block;margin:0 auto" />

<hr />
<h3>Validating Session Recording Is Working</h3>
<pre><code class="language-bash">aws s3 ls s3://${BUCKET}/sessions/ --recursive --region us-east-1
</code></pre>
<p>Expected output:</p>
<pre><code class="language-plaintext">2024-01-15 14:23:07    4096 sessions/2024/01/15/session-abc123.cast
2024-01-15 14:31:44    8192 sessions/2024/01/15/session-def456.cast
</code></pre>
<hr />
<h3>Validating CloudWatch Alarm for After-Hours Access</h3>
<pre><code class="language-bash">aws cloudwatch put-metric-data \
  --namespace PAMPlatform \
  --metric-name AccessOutsideBusinessHours \
  --value 1 \
  --unit Count \
  --region us-east-1
</code></pre>
<p>Within 1–2 minutes the CloudWatch alarm should transition to <code>ALARM</code> state and send an SNS email notification.</p>
<hr />
<img src="https://cdn.hashnode.com/uploads/covers/69a6e08356428acc6fe423a5/2c7e5add-207c-4f0a-9c98-b4ee618f4785.png" alt="" style="display:block;margin:0 auto" />

<hr />
<h2>Security Posture at a Glance</h2>
<table>
<thead>
<tr>
<th>Security Property</th>
<th>Before This Platform</th>
<th>After This Platform</th>
</tr>
</thead>
<tbody><tr>
<td>Static SSH keys in production</td>
<td>847 across 63 servers</td>
<td>Zero — architecturally impossible</td>
</tr>
<tr>
<td>Keys from departed engineers</td>
<td>312 still active</td>
<td>Eliminated — Okta deprovisioning covers everything</td>
</tr>
<tr>
<td>Credential theft window</td>
<td>Indefinite (keys never expire)</td>
<td>Maximum 3 minutes</td>
</tr>
<tr>
<td>Session visibility</td>
<td>None — grep across log servers</td>
<td>Full recording in S3, immutable</td>
</tr>
<tr>
<td>Access deprovisioning</td>
<td>Manual SSH key rotation</td>
<td>Single Okta deprovisioning action</td>
</tr>
<tr>
<td>Audit report generation</td>
<td>2 engineer-days per quarter</td>
<td>4 seconds via Python script</td>
</tr>
<tr>
<td>After-hours access detection</td>
<td>None</td>
<td>CloudWatch alarm + SNS within 2 minutes</td>
</tr>
<tr>
<td>Network path to targets</td>
<td>Direct SSH from internet</td>
<td>Private subnet only, Boundary required</td>
</tr>
</tbody></table>
<hr />
<h2>Key Lessons From This Build</h2>
<table>
<thead>
<tr>
<th>Lesson</th>
<th>Cost If You Ignore It</th>
</tr>
</thead>
<tbody><tr>
<td>Enable SSM Session Manager before touching <code>sshd</code></td>
<td>Permanent instance loss — no SSH recovery path</td>
</tr>
<tr>
<td>Parameterize SSH username per OS in Ansible</td>
<td>Locked out of mixed Amazon Linux + Ubuntu fleets</td>
</tr>
<tr>
<td>Run <code>sshd -d</code> in debug mode for cert failures</td>
<td>40+ minutes diagnosing the wrong thing</td>
</tr>
<tr>
<td>Use <code>rediss://</code> scheme when ElastiCache TLS is enabled</td>
<td>Boundary cannot connect to Redis for session storage</td>
</tr>
<tr>
<td>Copy OIDC redirect URIs character-by-character</td>
<td>Okta rejects on a single trailing slash difference</td>
</tr>
<tr>
<td>Run Boundary DB init Job before controller Deployment</td>
<td><code>CrashLoopBackOff</code> with a misleading error message</td>
</tr>
<tr>
<td>The 3-minute TTL changes engineer behaviour</td>
<td>Engineers plan before connecting — zero idle sessions left open</td>
</tr>
</tbody></table>
<hr />
<h2>Production Gap Analysis</h2>
<p><strong>Boundary worker in every VPC.</strong> A Boundary worker deployed into each VPC or subnet where targets live eliminates the need for peered network paths and improves latency for engineers in distant regions.</p>
<p><strong>Session timeout at the Boundary level.</strong> Vault's 3-minute TTL only blocks new connections. An idle SSH session stays open indefinitely after the certificate expires. Configure Boundary session connection limits and maximum duration to enforce automatic termination after inactivity.</p>
<p><strong>Vault audit logging.</strong> Every certificate issuance, every policy evaluation, every authentication event should be logged to a persistent audit device and shipped to your SIEM. This gives you a complete record of every credential issuance request independent of Boundary's own audit trail.</p>
<p><strong>Vault namespaces for multi-team isolation.</strong> If multiple teams share the same Vault cluster, Vault Enterprise namespaces provide complete isolation at the policy, auth method, and secrets engine level.</p>
<p><strong>Break-glass access procedures.</strong> Define, document, and test quarterly the procedure for accessing systems when Boundary is unavailable. The procedure must exist, be tested, and be known by more than one person.</p>
<hr />
<h2>Full Source Code</h2>
<p>The complete code is available at:</p>
<p>🔗 <a href="https://github.com/Eaglewings966/privileged-access-management"><strong>github.com/Eaglewings966/privileged-access-management</strong></a></p>
<p>When you are done testing, clean up your environment:</p>
<pre><code class="language-bash">bash scripts/destroy-all.sh
</code></pre>
<p>The destroy script empties the S3 session recordings bucket, uninstalls Vault and Boundary Helm releases, deletes Kubernetes namespaces, runs <code>terraform destroy</code>, deletes the EKS cluster via <code>eksctl</code>, and removes local temporary key files.</p>
<p>After running, verify in the AWS console that the following are all gone: RDS instance, ElastiCache cluster, EC2 target instances, S3 bucket, EKS cluster, IAM roles, security groups, SNS topic, and CloudWatch alarm.</p>
<pre><code class="language-bash"># Quick verification via AWS CLI
aws ec2 describe-instances \
  --filters "Name=tag:Project,Values=pam" \
           "Name=instance-state-name,Values=running" \
  --query 'Reservations[*].Instances[*].InstanceId' \
  --output text \
  --region us-east-1

aws rds describe-db-instances \
  --query 'DBInstances[?contains(DBInstanceIdentifier,`pam`)].DBInstanceIdentifier' \
  --output text \
  --region us-east-1
</code></pre>
<p>Both commands should return no output.</p>
<hr />
<h2>What's Next</h2>
<p>Project 13 is the <strong>Immutable Infrastructure Pipeline</strong> — Packer golden AMIs, Ansible pre-baked hardening, InSpec compliance testing before AMI promotion, and automated drift detection.</p>
<hr />
<h2>References</h2>
<ul>
<li><p><a href="https://developer.hashicorp.com/boundary/docs">HashiCorp Boundary Documentation</a></p>
</li>
<li><p><a href="https://developer.hashicorp.com/vault/docs/secrets/ssh">Vault SSH Secrets Engine</a></p>
</li>
<li><p><a href="https://developer.hashicorp.com/vault/docs/secrets/ssh/signed-ssh-certificates">Vault SSH Certificate Authority</a></p>
</li>
<li><p><a href="https://developer.okta.com/docs/concepts/oauth-openid/">Okta OIDC Integration Guide</a></p>
</li>
<li><p><a href="https://docs.aws.amazon.com/AmazonElastiCache/latest/red-ug/in-transit-encryption.html">AWS ElastiCache TLS Documentation</a></p>
</li>
<li><p><a href="https://docs.aws.amazon.com/systems-manager/latest/userguide/session-manager.html">AWS SSM Session Manager</a></p>
</li>
<li><p><a href="https://eksctl.io/">eksctl Documentation</a></p>
</li>
<li><p><a href="https://registry.terraform.io/providers/hashicorp/aws/latest/docs">Terraform AWS Provider</a></p>
</li>
<li><p><a href="https://man.openbsd.org/ssh-keygen#CERTIFICATES">OpenSSH Certificate Authentication</a></p>
</li>
</ul>
<hr />
<p><em>Emmanuel Ubani is a Cloud and DevOps Engineer based in Lagos, Nigeria. He writes about real infrastructure builds, career transitions into tech, and lessons learned the hard way.</em></p>
<ul>
<li><p>🔗 <a href="https://linkedin.com/in/ubaniemmanuel">LinkedIn</a></p>
</li>
<li><p>💻 <a href="https://github.com/Eaglewings966">GitHub</a></p>
</li>
<li><p>✍️ <a href="https://emmanuelubani.hashnode.dev">Hashnode</a></p>
</li>
<li><p>📝 <a href="https://medium.com/@emmaubani966">Medium</a></p>
</li>
<li><p>🌐 <a href="https://ops-run.lovable.app">Portfolio</a></p>
</li>
</ul>
]]></content:encoded></item><item><title><![CDATA[I Built an Automated CIS Compliance Pipeline That Saved a Company From a $3.2 Million Contract Loss — Here Is How It Works]]></title><description><![CDATA[OpenSCAP, Ansible auto-remediation, Python compliance gate, and CloudWatch trending — from 47% to 91% CIS Level 2 in one pipeline run In 2022, a fintech…]]></description><link>https://emmanuelubani.hashnode.dev/i-built-an-automated-cis-compliance-pipeline-that-saved-a-company-from-a-3-2-million-contract-loss-here-is-how-it-works</link><guid isPermaLink="true">https://emmanuelubani.hashnode.dev/i-built-an-automated-cis-compliance-pipeline-that-saved-a-company-from-a-3-2-million-contract-loss-here-is-how-it-works</guid><category><![CDATA[AWS]]></category><category><![CDATA[software development]]></category><category><![CDATA[cybersecurity]]></category><category><![CDATA[DevSecOps]]></category><category><![CDATA[Cloud Computing]]></category><dc:creator><![CDATA[Ubani Obiajulum Emmanuel]]></dc:creator><pubDate>Sat, 06 Jun 2026 22:36:24 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/69a6e08356428acc6fe423a5/55d4c1a5-44e8-4508-95bc-d2ef55e2f9d1.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>OpenSCAP, Ansible auto-remediation, Python compliance gate, and CloudWatch trending — from 47% to 91% CIS Level 2 in one pipeline run</p>
<hr />
<h2>The Audit That Cost $3.2 Million</h2>
<p>In 2022, a fintech startup in Berlin failed their SOC2 Type II audit. The findings were not sophisticated:</p>
<ul>
<li><p>Root login was enabled over SSH on <strong>34 of their 41 EC2 instances</strong></p>
</li>
<li><p>Audit logging was not configured on any instance</p>
</li>
<li><p>World-writable files existed in production directories</p>
</li>
<li><p>The password policy allowed single-character passwords that never expired</p>
</li>
</ul>
<p>None of this was the result of an attack. It was the result of years of infrastructure growth without compliance monitoring. Nobody had ever checked. Not because the engineers were careless. <strong>Because there was no automated system watching.</strong></p>
<p>The compliance remediation took four months of manual work. During those four months, a <strong>$3.2 million enterprise contract went to a competitor</strong> who could show a clean compliance report on demand. The startup's engineers were skilled. Their infrastructure was working. But they could not prove it was secure.</p>
<p>Compliance is not a one-time event. It is a continuous state. Every new instance provisioned, every configuration change applied, every package update installed can introduce drift. Manual audits catch drift weeks after it happens. <strong>This pipeline catches it within hours and fixes it automatically.</strong></p>
<hr />
<img src="https://cdn.hashnode.com/uploads/covers/69a6e08356428acc6fe423a5/ba2ad8bf-2f45-4b19-9491-8111a8404afb.png" alt="" style="display:block;margin:0 auto" />

<img src="https://cdn.hashnode.com/uploads/covers/69a6e08356428acc6fe423a5/5e756130-d709-4c96-a2f8-1514ad44cc20.png" alt="" style="display:block;margin:0 auto" />

<hr />
<h2>Who I Am and Why I Built This</h2>
<p>I am <strong>Emmanuel Ubani</strong>, a Cloud and DevOps Engineer based in Lagos, Nigeria. I transitioned into tech in January 2024 after working as a zoo volunteer, an automotive technician apprentice, and a beauty and cosmetics store manager.</p>
<p>I built this project because compliance automation is one of the most underbuilt areas in cloud engineering. Teams spend enormous time building CI/CD pipelines for application delivery but leave the compliance state of their infrastructure to quarterly manual audits. This project treats compliance the same way mature teams treat code quality — as something that is <strong>continuously measured, automatically reported, and capable of blocking a deployment</strong> when it falls below an acceptable threshold.</p>
<hr />
<h2>What This Pipeline Does in Plain Terms</h2>
<p>A fresh EC2 instance running Amazon Linux 2023 scores approximately <strong>47%</strong> against the CIS Level 2 benchmark. This means 53% of the security controls that CIS considers essential for a production server are not configured by default.</p>
<table>
<thead>
<tr>
<th>Pipeline Stage</th>
<th>What Happens</th>
</tr>
</thead>
<tbody><tr>
<td><strong>Scan</strong></td>
<td>OpenSCAP evaluates every instance against CIS Level 2 rules</td>
</tr>
<tr>
<td><strong>Score</strong></td>
<td>Compliance percentage extracted and written to JSON summary</td>
</tr>
<tr>
<td><strong>Remediate</strong></td>
<td>Ansible automatically fixes every fixable finding</td>
</tr>
<tr>
<td><strong>Re-scan</strong></td>
<td>OpenSCAP runs again to confirm the improvement</td>
</tr>
<tr>
<td><strong>Gate</strong></td>
<td>Python script blocks CI/CD if any instance scores below 85%</td>
</tr>
<tr>
<td><strong>Trend</strong></td>
<td>CloudWatch metrics track compliance score over time</td>
</tr>
</tbody></table>
<p>The improvement from 47% to above 85% happens in a <strong>single automated pipeline run</strong>. No manual intervention. No checklist. No quarterly review. A score that would fail a SOC2 audit becomes a score that passes it — in under 30 minutes, documented and auditable at every step.</p>
<hr />
<h2>Implementation</h2>
<h3>Step 1 — OpenSCAP Scanning With Ansible</h3>
<p>OpenSCAP is the open-source tool used by the US government, the NSA, and enterprises worldwide for SCAP-based compliance scanning. It reads a SCAP content file containing the CIS benchmark rules and evaluates the system against every rule.</p>
<blockquote>
<p>⚠️ <strong>The two most important things to know about</strong> <code>oscap</code> <strong>exit codes:</strong></p>
<ul>
<li><p><strong>Exit code 2</strong> means non-compliant — the scan ran successfully and found issues</p>
</li>
<li><p><strong>Exit code 1</strong> means the scan itself failed</p>
</li>
</ul>
<p>If you write <code>failed_when: oscap_result.rc != 0</code>, your playbook will fail on every non-perfect system. That means <strong>every system</strong> — because no fresh EC2 instance is 100% CIS compliant.</p>
</blockquote>
<pre><code class="language-yaml">- name: Run OpenSCAP CIS Level 2 scan
  command: &gt;
    oscap xccdf eval
    --profile {{ cis_l2_profile }}
    --results {{ reports_dir }}/cis_level2_results.xml
    --report {{ reports_dir }}/cis_level2_report.html
    {{ scap_content_base }}/{{ scap_file }}
  register: oscap_result
  failed_when: oscap_result.rc not in [0, 2]
  changed_when: false
</code></pre>
<hr />
<p><img src="align=%22center%22" alt="" /></p>
<img src="https://cdn.hashnode.com/uploads/covers/69a6e08356428acc6fe423a5/1212840a-5a63-493c-b48b-55cdfb2057ad.png" alt="" style="display:block;margin:0 auto" />

<img src="https://cdn.hashnode.com/uploads/covers/69a6e08356428acc6fe423a5/6c4eb893-2cc9-4901-9cbe-4e19a11e3e6d.png" alt="" style="display:block;margin:0 auto" />

<hr />
<h3>Step 2 — Score Extraction</h3>
<p>After the scan, the compliance score is buried in the XML results file. This extracts it and saves it as a fact for every subsequent pipeline stage:</p>
<pre><code class="language-yaml">- name: Extract CIS Level 2 compliance score
  shell: &gt;
    grep -oP 'score.*?\K[\d.]+(?=%)' \
    {{ reports_dir }}/cis_level2_results.xml | head -1
  register: level2_score_raw
  changed_when: false

- name: Set CIS Level 2 score fact
  set_fact:
    cis_level2_score: "{{ level2_score_raw.stdout | float | round(2) }}"
</code></pre>
<p>The score is saved as a fact and written to a JSON summary file that every subsequent pipeline stage reads.</p>
<hr />
<h3>Step 3 — Auto-Remediation With Ansible</h3>
<p>The remediation role applies fixes for every automatically remediable finding. The two most impactful remediation tasks:</p>
<p><strong>SSH hardening</strong> covers 15–20 CIS rules in a single role:</p>
<pre><code class="language-yaml">- name: "[CIS 5.2] Harden SSH configuration"
  lineinfile:
    path: /etc/ssh/sshd_config
    regexp: "^#?{{ item.key }}"
    line: "{{ item.key }} {{ item.value }}"
    state: present
    backup: yes
  loop:
    - { key: "PermitRootLogin", value: "no" }
    - { key: "MaxAuthTries", value: "3" }
    - { key: "Protocol", value: "2" }
    - { key: "X11Forwarding", value: "no" }
    - { key: "LogLevel", value: "VERBOSE" }
  notify: Restart SSH
</code></pre>
<p><strong>Kernel sysctl hardening</strong> covers another 10–15 rules:</p>
<pre><code class="language-yaml">- name: "[CIS 3.1] Apply kernel sysctl hardening"
  sysctl:
    name: "{{ item.key }}"
    value: "{{ item.value }}"
    state: present
    reload: yes
    sysctl_file: /etc/sysctl.d/99-cis-hardening.conf
  loop:
    - { key: "net.ipv4.ip_forward", value: "0" }
    - { key: "net.ipv4.conf.all.log_martians", value: "1" }
    - { key: "net.ipv4.tcp_syncookies", value: "1" }
    - { key: "kernel.randomize_va_space", value: "2" }
</code></pre>
<hr />
<h3>Step 4 — The Compliance Gate</h3>
<p>This is the piece that makes the entire system matter for CI/CD. The compliance gate is a Python script that reads the JSON summary files, evaluates every instance against the threshold, and exits with code <code>1</code> if anything fails:</p>
<pre><code class="language-python">def evaluate_compliance(reports, threshold, profile, fail_on_critical):
    results = []
    all_passed = True
    score_key = f"{profile.replace('-', '_')}_score"

    for report in reports:
        score = float(report.get(score_key, 0))
        critical = int(report.get("critical_findings", 0))
        passed = score &gt;= threshold
        critical_block = fail_on_critical and critical &gt; 0

        result = {
            "hostname": report.get("hostname", "unknown"),
            "score": score,
            "passed": passed and not critical_block,
        }
        results.append(result)
        if not result["passed"]:
            all_passed = False

    return all_passed, results
</code></pre>
<p>In GitHub Actions, this script runs as its own job. If it exits with code <code>1</code>, <strong>all downstream jobs including the deploy job are blocked</strong>. The pipeline cannot proceed. The deployment cannot happen.</p>
<hr />
<p><img src="align=%22center%22" alt="" /></p>
<img src="https://cdn.hashnode.com/uploads/covers/69a6e08356428acc6fe423a5/873d8e32-4842-42ef-ac4d-ec0d0aaf54c8.png" alt="" style="display:block;margin:0 auto" />

<img src="https://cdn.hashnode.com/uploads/covers/69a6e08356428acc6fe423a5/1c3f76d2-a067-48b3-a53c-3e587f0f3794.png" alt="" style="display:block;margin:0 auto" />

<hr />
<h3>Step 5 — CloudWatch Metrics</h3>
<p>The Python metrics publisher pushes scores to a custom CloudWatch namespace. This creates the compliance trend chart over time:</p>
<pre><code class="language-python">cloudwatch.put_metric_data(
    Namespace="ComplianceScanner",
    MetricData=[{
        "MetricName": "ComplianceScore",
        "Dimensions": [
            {"Name": "InstanceId", "Value": instance_id},
            {"Name": "Profile", "Value": "cis-level2"}
        ],
        "Timestamp": datetime.now(timezone.utc),
        "Value": float(report["cis_level2_score"]),
        "Unit": "Percent"
    }]
)
</code></pre>
<p>The CloudWatch dashboard shows these metrics over time with a horizontal annotation line at 85% — the deployment threshold. Any dip below that line is immediately visible.</p>
<img src="https://cdn.hashnode.com/uploads/covers/69a6e08356428acc6fe423a5/a4c4804d-3c35-475b-b4d6-57d155d76bc4.png" alt="" style="display:block;margin:0 auto" />

<img src="https://cdn.hashnode.com/uploads/covers/69a6e08356428acc6fe423a5/5275e3aa-e117-4f92-a5d1-74ae7836456a.png" alt="" style="display:block;margin:0 auto" />

<hr />
<h2>What Broke During This Build</h2>
<h3>Debugging Story 1 — The SSH Lockout</h3>
<p>On my first run of the remediation playbook, the playbook failed halfway through the SSH hardening tasks. The handler had not yet fired to restart SSH, so I had a modified <code>sshd_config</code> that had not been applied yet.</p>
<p>The next time I connected, everything worked fine — the old config was still active. Then I ran the playbook again. This time the handler fired. <code>sshd</code> restarted with the new config. The new config had <code>AllowUsers ec2-user</code> — but the instance user was actually <code>ubuntu</code>.</p>
<p><strong>I lost SSH access permanently.</strong></p>
<blockquote>
<p>⚠️ <strong>Two lessons from this:</strong></p>
<ol>
<li><p>Always test remediation on a throwaway instance before touching anything with real data</p>
</li>
<li><p>Never add <code>AllowUsers</code> to <code>sshd_config</code> in a playbook without parameterizing the username per OS type</p>
</li>
</ol>
</blockquote>
<hr />
<h3>Debugging Story 2 — The Dynamic Inventory Discovery Problem</h3>
<p>The <code>aws_ec2</code> dynamic inventory plugin requires <code>boto3</code> on the control machine and the <code>amazon.aws</code> Ansible collection to be installed. On the GitHub Actions runner, both were missing. The pipeline failed with a cryptic error about the inventory plugin not being found.</p>
<p>The fix was adding these two steps before running any <code>ansible</code> command in the pipeline:</p>
<pre><code class="language-yaml">- name: Install Ansible and dependencies
  run: |
    pip install ansible boto3 botocore
    ansible-galaxy collection install amazon.aws
</code></pre>
<blockquote>
<p>💡 <strong>The error message when this is missing does not clearly state what is wrong.</strong> It says the <code>aws_ec2</code> plugin cannot be loaded. The actual cause is that the <code>amazon.aws</code> collection is not installed.</p>
</blockquote>
<hr />
<h2>The Real Lesson</h2>
<p>Here is the thing about compliance automation that most engineers do not say directly.</p>
<p><strong>The goal of compliance automation is not to pass audits. The goal is to make the audit irrelevant.</strong></p>
<p>When an auditor asks to see your CIS compliance posture, the correct answer is not a spreadsheet from last quarter. The correct answer is opening a CloudWatch dashboard that shows:</p>
<ul>
<li><p>The compliance score of every instance for the last 90 days</p>
</li>
<li><p>A GitHub Actions run history showing every deployment passed the compliance gate</p>
</li>
<li><p>An HTML report generated 12 minutes ago</p>
</li>
</ul>
<p>The Berlin startup that lost the $3.2 million contract failed because they could not show current compliance state on demand. Not because they were non-compliant. <strong>Because they did not know whether they were compliant or not</strong> without spending four months checking manually.</p>
<p>This pipeline makes the compliance state observable at any moment. The dashboard shows it. The reports prove it. The CI/CD gate enforces it. An auditor who arrives without warning gets a live answer, not a promise that you will get back to them next week.</p>
<blockquote>
<p>That is the real value of compliance automation. Not passing audits. <strong>Eliminating the audit as a stressful event</strong> by turning compliance into a continuously monitored, automatically enforced property of your infrastructure.</p>
</blockquote>
<hr />
<h2>Security Posture at a Glance</h2>
<table>
<thead>
<tr>
<th>Finding Type</th>
<th>Without This Pipeline</th>
<th>With This Pipeline</th>
</tr>
</thead>
<tbody><tr>
<td>Root SSH login enabled</td>
<td>Undetected until manual audit</td>
<td>Detected and disabled automatically</td>
</tr>
<tr>
<td>Missing audit logging</td>
<td>Discovered weeks later</td>
<td>Remediated in same pipeline run</td>
</tr>
<tr>
<td>World-writable files</td>
<td>Unknown until breach</td>
<td>Flagged and fixed automatically</td>
</tr>
<tr>
<td>Weak password policy</td>
<td>Persists indefinitely</td>
<td>Hardened per CIS standard</td>
</tr>
<tr>
<td>Compliance drift on new instances</td>
<td>Undetected</td>
<td>Caught within hours</td>
</tr>
<tr>
<td>Audit evidence</td>
<td>Manual spreadsheet, weeks to produce</td>
<td>Live CloudWatch dashboard, instant</td>
</tr>
<tr>
<td>CI/CD gate on compliance</td>
<td>None</td>
<td>Blocks deployment below 85%</td>
</tr>
</tbody></table>
<hr />
<h2>Production Gap Analysis</h2>
<p>This pipeline covers the CIS benchmark remediation pattern fully. A production implementation would additionally require the following.</p>
<p><strong>Drift detection on a schedule.</strong> New instances are provisioned continuously. Every new instance starts at baseline compliance. A scheduled pipeline run weekly ensures no instance drifts below threshold without being caught.</p>
<p><strong>OpenSearch for historical compliance data.</strong> CloudWatch retains metrics for 15 months but the querying is limited. OpenSearch provides richer querying for compliance trend analysis across fleets of hundreds of instances.</p>
<p><strong>Automated evidence packages for auditors.</strong> SOC2 auditors want documented evidence in specific formats. A production pipeline generates structured evidence packages per audit period, not just HTML reports.</p>
<p><strong>Windows instance support.</strong> This pipeline covers Linux only. Enterprise environments typically have Windows instances that require different tooling — CIS-CAT Pro or Microsoft's STIG tooling — for equivalent coverage.</p>
<hr />
<h2>How This Helps Companies</h2>
<p>The Berlin startup spent four months doing manually what this pipeline does in 30 minutes. They spent those four months not closing their \(3.2 million contract. The engineering time cost alone — four engineers for four months — was approximately \)280,000 at market rates.</p>
<p>This pipeline is not a luxury for mature organizations. It is the baseline that any organization handling customer data should have before they try to sign enterprise contracts. <strong>Enterprise clients ask about compliance. Every time. Without exception.</strong></p>
<hr />
<h2>Full Source Code</h2>
<p>The complete code is available at:</p>
<p>🔗 <a href="https://github.com/Eaglewings966/openscap-ansible-compliance"><strong>github.com/Eaglewings966/openscap-ansible-compliance</strong></a></p>
<p>When you are done testing, clean up your environment:</p>
<pre><code class="language-bash">cd terraform &amp;&amp; terraform destroy --auto-approve
rm -f compliance-key.pem
</code></pre>
<hr />
<h2>What's Next</h2>
<p>Project 12 covers <strong>Privileged Access Management</strong> — HashiCorp Boundary replacing bastion hosts, Vault SSH CA for just-in-time certificates, and full session recording.</p>
<hr />
<p><em>Emmanuel Ubani is a Cloud and DevOps Engineer based in Lagos, Nigeria. He writes about real infrastructure builds, career transitions into tech, and lessons learned the hard way.</em></p>
<ul>
<li><p>🔗 <a href="https://linkedin.com/in/ubaniemmanuel">LinkedIn</a></p>
</li>
<li><p>💻 <a href="https://github.com/Eaglewings966">GitHub</a></p>
</li>
<li><p>✍️ <a href="https://emmanuelubani.hashnode.dev">Hashnode</a></p>
</li>
<li><p>📝 <a href="https://medium.com/@emmaubani966">Medium</a></p>
</li>
<li><p>🌐 <a href="https://ops-run.lovable.app">Portfolio</a></p>
</li>
</ul>
]]></content:encoded></item><item><title><![CDATA[I Built a Zero-Trust Kubernetes Platform Where Every Connection Is Encrypted, Every Identity Verified, and Every Threat Detected in Real Time — Here Is the Full Architecture]]></title><description><![CDATA[Istio mTLS, Cilium eBPF, Falco runtime detection, OPA Gatekeeper, and Vault secrets injection — five security layers working as one system]]></description><link>https://emmanuelubani.hashnode.dev/i-built-a-zero-trust-kubernetes-platform-where-every-connection-is-encrypted-every-identity-verified-and-every-threat-detected-in-real-time-here-is-the-full-architecture</link><guid isPermaLink="true">https://emmanuelubani.hashnode.dev/i-built-a-zero-trust-kubernetes-platform-where-every-connection-is-encrypted-every-identity-verified-and-every-threat-detected-in-real-time-here-is-the-full-architecture</guid><category><![CDATA[Devops]]></category><category><![CDATA[Security]]></category><category><![CDATA[Kubernetes]]></category><category><![CDATA[AWS]]></category><category><![CDATA[Cloud Computing]]></category><dc:creator><![CDATA[Ubani Obiajulum Emmanuel]]></dc:creator><pubDate>Sun, 24 May 2026 18:39:33 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/69a6e08356428acc6fe423a5/805fd1dd-0ee2-4cf8-90ac-98cb765b2797.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<h2>The Lateral Movement Attack That Cost $4.3 Million</h2>
<p>In April 2023, a financial services company in London received a phone call from their bank. Unusual transactions had been detected on their payments processing account.</p>
<p>The investigation that followed took three weeks and cost <strong>$4.3 million</strong> in forensics, remediation, and regulatory fines.</p>
<p>Here is what the investigators found.</p>
<p>An attacker had compromised a developer's laptop through a phishing email. On that laptop was a kubeconfig file with admin access to the production Kubernetes cluster. The attacker used that kubeconfig to exec into a running pod. The pod was a simple logging service with no business-sensitive data. But it was <strong>inside the cluster</strong>.</p>
<p>And once inside the cluster, there was nothing stopping lateral movement.</p>
<p>The logging pod could make HTTP requests to the payments service. There was no network policy preventing it. There was no mTLS requiring mutual authentication between services. There was no authorization policy restricting which services could talk to which. There was no runtime detection watching for unusual connections from a logging pod to a payments endpoint.</p>
<p>The attacker moved from the logging pod to the payments service in <strong>eleven minutes</strong>. The cluster's entire internal network was flat. Every pod implicitly trusted every other pod.</p>
<p>The data breach lasted six days before it was detected.</p>
<p>Every security failure in that incident had a specific technical solution that was not in place. <strong>This project implements all of them.</strong></p>
<hr />
<img src="https://cdn.hashnode.com/uploads/covers/69a6e08356428acc6fe423a5/8e5d4234-41c6-470f-9167-54697ce7703d.png" alt="" style="display:block;margin:0 auto" />

<img src="https://cdn.hashnode.com/uploads/covers/69a6e08356428acc6fe423a5/0831ab19-2d44-4584-960f-878875880f56.png" alt="" style="display:block;margin:0 auto" />

<hr />
<h2>Who I Am and Why This Project Matters</h2>
<p>I am <strong>Emmanuel Ubani</strong>, a Cloud and DevOps Engineer based in Lagos, Nigeria. Before I transitioned into tech in January 2024, I was a zoo volunteer, an automotive technician apprentice, and a beauty and cosmetics store manager.</p>
<p>I build these projects not to check portfolio boxes but to understand the systems deeply enough to operate them under pressure. Zero-trust is one of those concepts that sounds simple in theory and reveals enormous complexity in practice.</p>
<img src="https://cdn.hashnode.com/uploads/covers/69a6e08356428acc6fe423a5/6c4fb3d6-0bf5-47d8-babb-9c0899b0266e.png" alt="" style="display:block;margin:0 auto" />

<p>This article walks through every component of the platform, every architectural decision, and every error I hit along the way. My goal is that after reading this, you can build this platform yourself and explain every decision to a CISO or a CTO with confidence.</p>
<img src="https://cdn.hashnode.com/uploads/covers/69a6e08356428acc6fe423a5/c85f30be-a4e3-4dfd-8479-abee7098f6b4.png" alt="" style="display:block;margin:0 auto" />

<hr />
<h2>What Zero-Trust Actually Means in a Kubernetes Context</h2>
<p>Zero-trust is not a product you buy. It is an architectural principle you implement across multiple layers.</p>
<p>The core idea is simple: <strong>trust nothing by default, verify everything explicitly, assume the perimeter has already been breached.</strong> Design your systems so that a compromised component cannot be used to compromise other components.</p>
<table>
<thead>
<tr>
<th>Traditional Kubernetes Cluster</th>
<th>Zero-Trust Kubernetes Cluster</th>
</tr>
</thead>
<tbody><tr>
<td>Pods talk over plaintext HTTP</td>
<td>Every connection encrypted with mTLS</td>
</tr>
<tr>
<td>No pod identity verification</td>
<td>Every pod has a cryptographic SPIFFE identity certificate</td>
</tr>
<tr>
<td>Any pod can reach any other pod</td>
<td>Every request checked against explicit authorization policy</td>
</tr>
<tr>
<td>Lateral movement is unrestricted</td>
<td>No path exists between unauthorized services</td>
</tr>
<tr>
<td>Breach detection is manual</td>
<td>Runtime alerts fire within seconds of anomalous behavior</td>
</tr>
</tbody></table>
<p>The attacker who compromised the London company had kubeconfig access. In a zero-trust cluster, that exec attempt would have triggered an immediate CRITICAL alert from Falco. The lateral movement would have been impossible because there was no path between the logging pod and the payments service.</p>
<hr />
<h2>The Five Security Layers</h2>
<h3>Layer 1 — Cilium eBPF (Network Visibility)</h3>
<p>Cilium replaces kube-proxy entirely using eBPF technology. eBPF allows programs to run inside the Linux kernel without modifying kernel source code. Cilium uses this to provide L7 network visibility, policy enforcement, and load balancing at the kernel level.</p>
<blockquote>
<p>💡 Install Cilium <strong>before</strong> Istio. Cilium provides the networking foundation that the service mesh runs on. Installing out of order causes Istio's sidecar injection webhook to fail with misleading timeout errors.</p>
</blockquote>
<pre><code class="language-plaintext">helm install cilium cilium/cilium \
  --version 1.14.5 \
  --namespace cilium-system \
  --set kubeProxyReplacement=strict \
  --set k8sServiceHost=YOUR_EKS_ENDPOINT \
  --set k8sServicePort=443 \
  --set hubble.relay.enabled=true \
  --set hubble.ui.enabled=true
</code></pre>
<p>The <code>kubeProxyReplacement=strict</code> setting is critical. It tells Cilium to completely replace kube-proxy rather than running alongside it, giving Cilium full control over all service routing decisions at the eBPF level.</p>
<hr />
<h3>Layer 2 — Istio mTLS (Encryption + Identity)</h3>
<p>Istio injects an Envoy sidecar proxy into every pod in labeled namespaces. This proxy intercepts all inbound and outbound network traffic. When two pods communicate, their Envoy sidecars perform a TLS handshake that proves both pod identities using certificates issued by the Istio certificate authority.</p>
<p>The key configuration is the <code>PeerAuthentication</code> policy:</p>
<pre><code class="language-yaml">apiVersion: security.istio.io/v1beta1
kind: PeerAuthentication
metadata:
  name: global-mtls
  namespace: istio-system
spec:
  mtls:
    mode: STRICT
</code></pre>
<p><code>STRICT</code> mode means Istio will reject any connection that is not mTLS. A pod without an Istio sidecar cannot talk to a pod with one.</p>
<blockquote>
<p>🔐 <strong>This is the configuration that would have stopped the London attack.</strong> The attacker's connection from the exec session would not have had an Istio certificate. The payments service would have rejected it at the network layer.</p>
</blockquote>
<img src="https://cdn.hashnode.com/uploads/covers/69a6e08356428acc6fe423a5/0b510ebf-81a8-47fe-bf56-dc1342ef23e7.png" alt="" style="display:block;margin:0 auto" />

<hr />
<h3>Layer 3 — Istio AuthorizationPolicy (Request Authorization)</h3>
<p>mTLS proves <strong>who you are</strong>. <code>AuthorizationPolicy</code> decides <strong>what you are allowed to do</strong>. The zero-trust default is deny everything — then add explicit allow rules only for the communication paths that are actually needed.</p>
<pre><code class="language-yaml"># Deny everything in the namespace first
apiVersion: security.istio.io/v1beta1
kind: AuthorizationPolicy
metadata:
  name: deny-all
  namespace: zero-trust-apps
spec: {}
---
# Then explicitly allow only what is needed
apiVersion: security.istio.io/v1beta1
kind: AuthorizationPolicy
metadata:
  name: allow-frontend-to-backend
  namespace: zero-trust-apps
spec:
  selector:
    matchLabels:
      app: backend
  action: ALLOW
  rules:
    - from:
        - source:
            principals:
              - "cluster.local/ns/zero-trust-apps/sa/frontend"
      to:
        - operation:
            ports: ["8080"]
            methods: ["GET", "POST"]
</code></pre>
<p>The <code>principal</code> field is the SPIFFE identity of the frontend service account. Even if an attacker gets into a different pod with a different service account, they cannot call the backend. Their SPIFFE identity will not match the allow rule.</p>
<hr />
<h3>Layer 4 — OPA Gatekeeper (Admission Control)</h3>
<p>Gatekeeper enforces policies at the moment a resource is created. Before any pod reaches a node, Gatekeeper validates it against constraint templates written in Rego. A pod that violates any constraint is rejected before it ever runs.</p>
<p>The most important constraint for zero-trust is the one that blocks privileged containers:</p>
<pre><code class="language-yaml">apiVersion: constraints.gatekeeper.sh/v1beta1
kind: DisallowPrivileged
metadata:
  name: disallow-privileged-zero-trust
spec:
  enforcementAction: deny
  match:
    kinds:
      - apiGroups: [""]
        kinds: ["Pod"]
    namespaces:
      - zero-trust-apps
</code></pre>
<p>A privileged container has root access to the host node. In a zero-trust model, this is never acceptable. Gatekeeper blocks it before the container ever starts.</p>
<img src="https://cdn.hashnode.com/uploads/covers/69a6e08356428acc6fe423a5/fc6b5f84-2209-481c-8430-18556de28c58.png" alt="" style="display:block;margin:0 auto" />

<hr />
<h3>Layer 5 — Falco (Runtime Detection)</h3>
<p>Falco uses eBPF to watch every system call made by every process in every container. When a pattern matches a rule, Falco generates an alert.</p>
<p>The three most important rules for zero-trust:</p>
<img src="https://cdn.hashnode.com/uploads/covers/69a6e08356428acc6fe423a5/e15c2be6-7fe2-42df-a2de-2e3577dc621b.png" alt="" style="display:block;margin:0 auto" />

<table>
<thead>
<tr>
<th>Rule</th>
<th>What It Detects</th>
</tr>
</thead>
<tbody><tr>
<td>Shell spawned via exec</td>
<td>Someone executing commands directly inside a pod, bypassing all policies</td>
</tr>
<tr>
<td>Unexpected network connection</td>
<td>A pod communicating outside the service mesh, potentially bypassing mTLS</td>
</tr>
<tr>
<td>Sensitive file access</td>
<td>Something inside a pod reading credential files or service account tokens</td>
</tr>
</tbody></table>
<pre><code class="language-yaml">- rule: Privileged Exec in Zero-Trust Pod
  condition: &gt;
    spawned_process and
    k8s.ns.name = "zero-trust-apps" and
    proc.name in (bash, sh, zsh) and
    proc.pname in (runc, containerd-shim)
  output: &gt;
    Shell spawned in zero-trust pod via exec
    (namespace=%k8s.ns.name pod=%k8s.pod.name)
  priority: CRITICAL
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/69a6e08356428acc6fe423a5/1f760b28-b624-4b29-9330-76170ef2d553.png" alt="" style="display:block;margin:0 auto" />

<hr />
<h2>Implementation — Building the Platform</h2>
<h3>The Installation Order Problem</h3>
<p>This is the most important thing I can tell you about building this platform. <strong>The installation order matters. Not slightly. Critically.</strong></p>
<pre><code class="language-plaintext">Cilium → Istio → Gatekeeper → Constraint Templates → Constraints → Falco → Vault
</code></pre>
<p>On my first attempt I installed Istio before Cilium. Istio's control plane came up but the sidecar injection webhook kept failing. The error message pointed to a timeout in the webhook call but the actual cause was that Cilium had not finished taking over service routing from kube-proxy. Istio's webhook traffic was not being routed correctly.</p>
<p>The fix was uninstalling Istio, waiting for Cilium to fully initialize, then reinstalling Istio. This cost me ninety minutes.</p>
<hr />
<h3>The 3/3 Verification</h3>
<p>After deploying the application, run:</p>
<pre><code class="language-bash">kubectl get pods -n zero-trust-apps
</code></pre>
<p>You should see <strong>3/3 Ready</strong> for every pod — three containers running simultaneously:</p>
<table>
<thead>
<tr>
<th>Container</th>
<th>What It Is</th>
</tr>
</thead>
<tbody><tr>
<td>Application container</td>
<td>Your actual workload</td>
</tr>
<tr>
<td>Istio proxy sidecar</td>
<td>Handles all mTLS encryption and authorization</td>
</tr>
<tr>
<td>Vault agent sidecar</td>
<td>Dynamically injects and rotates secrets</td>
</tr>
</tbody></table>
<table>
<thead>
<tr>
<th>What You See</th>
<th>What It Means</th>
</tr>
</thead>
<tbody><tr>
<td><code>3/3</code></td>
<td>Everything working correctly</td>
</tr>
<tr>
<td><code>2/3</code></td>
<td>Vault agent sidecar failing — check Vault role and service account annotation</td>
</tr>
<tr>
<td><code>1/3</code></td>
<td>Istio injection not working — check namespace has <code>istio-injection=enabled</code> label</td>
</tr>
</tbody></table>
<hr />
<h3>The Health Check Problem</h3>
<p>When I applied the <code>deny-all</code> AuthorizationPolicy, all pods immediately failed their readiness probes and entered a crash loop.</p>
<p>The readiness probe makes an HTTP request <strong>from the kubelet</strong>. The kubelet does not have an Istio sidecar. So its requests do not have mTLS certificates. So they are rejected by the deny-all policy. So the pod fails its health check. So Kubernetes restarts it. So it fails again. <strong>Infinite loop.</strong></p>
<p>The fix was adding explicit allow rules for the health check paths:</p>
<pre><code class="language-yaml">apiVersion: security.istio.io/v1beta1
kind: AuthorizationPolicy
metadata:
  name: allow-health-checks
  namespace: zero-trust-apps
spec:
  selector:
    matchLabels:
      app: backend
  action: ALLOW
  rules:
    - to:
        - operation:
            paths: ["/health", "/ready"]
</code></pre>
<blockquote>
<p>⚠️ <strong>This was the most operationally painful debugging session of the entire project.</strong> The error from <code>kubectl describe pod</code> just showed the pod failing its readiness probe — no indication that the cause was an Istio authorization policy rejecting the kubelet's health check request.</p>
</blockquote>
<hr />
<h2>Key Lessons From This Build</h2>
<p>These are the four things that cost me the most time and that no tutorial warned me about:</p>
<table>
<thead>
<tr>
<th>Lesson</th>
<th>What Happened</th>
<th>The Fix</th>
</tr>
</thead>
<tbody><tr>
<td>Fargate blocks <code>NET_ADMIN</code></td>
<td>Istio sidecar injection requires kernel capabilities unavailable on Fargate</td>
<td>Switch to EC2 nodes for Istio workloads</td>
</tr>
<tr>
<td>Vault uses in-memory storage by default</td>
<td>Every pod restart loses Vault's sealed state</td>
<td>Unseal after every restart — or use KMS auto-unseal (see Project 8)</td>
</tr>
<tr>
<td>Fargate profiles take scheduling priority</td>
<td>Pods land on Fargate even with <code>nodeSelector</code> for EC2</td>
<td>Remove Fargate profiles for namespaces that need EC2</td>
</tr>
<tr>
<td>Install order is critical</td>
<td>Istio before Cilium breaks webhook routing</td>
<td>Always: Cilium → Istio → Gatekeeper → templates → constraints</td>
</tr>
</tbody></table>
<hr />
<h2>The Real Lesson</h2>
<p>Here is the thing about zero-trust that every article glosses over.</p>
<p><strong>Zero-trust does not make your cluster secure. Zero-trust makes your cluster auditable.</strong></p>
<p>The London financial services attack succeeded because nothing was watching. There were no logs of which pod talked to which. There were no alerts on unusual connection patterns. There was no record of which service account made which API call.</p>
<p>When you implement this zero-trust stack, you gain something more valuable than blocking power. You gain <strong>visibility</strong>:</p>
<ul>
<li><p>Every mTLS handshake creates a certificate log</p>
</li>
<li><p>Every authorization policy decision creates an access log</p>
</li>
<li><p>Every Falco rule match creates an alert</p>
</li>
<li><p>Every Gatekeeper denial creates an event</p>
</li>
</ul>
<p>The attacker who gets into your cluster will leave a trail you can follow. The lateral movement attempt will trigger a Falco alert before it succeeds. The anomalous service-to-service call will be blocked by authorization policy and logged. The exec attempt into a production pod will generate a CRITICAL alert that pages on-call within sixty seconds.</p>
<blockquote>
<p>You cannot prevent every breach. You can make sure that every breach is <strong>detectable, containable, and attributable</strong>. That is what zero-trust actually delivers.</p>
</blockquote>
<p>The London company did not have logging. They did not know they had been breached for six days. This platform would not have prevented the initial compromise. But it would have made the lateral movement impossible and detected the breach attempt within minutes.</p>
<hr />
<h2>Production Gap Analysis</h2>
<p>This platform demonstrates the complete zero-trust security model. A production deployment at enterprise scale would additionally require the following.</p>
<p><strong>Istio Gateway with cert-manager</strong> provides external traffic ingress with automatic TLS certificate provisioning and rotation. This replaces any direct pod exposure with a controlled entry point that participates in the mesh.</p>
<p><strong>Falco Sidekick</strong> routes alerts to PagerDuty, Slack, and SIEM systems simultaneously. Console logs are not a production alerting mechanism — alerts need to reach the right people through the right channels within seconds of detection.</p>
<p><strong>CiliumNetworkPolicy</strong> adds L3/L4 network policies on top of Istio's L7 authorization. Defense in depth at both layers eliminates an entire class of service mesh bypass techniques that operate at the TCP level below Istio's visibility.</p>
<p><strong>Vault HA with 5-node Raft cluster</strong> replaces the single-replica Vault in this project. A single Vault pod is a single point of failure for all secret injection in the cluster. See Project 8 for the full Vault HA implementation.</p>
<p><strong>OPA policy unit testing</strong> with Conftest and Gator ensures constraint template Rego logic is correct before it reaches production. An incorrect Rego policy that blocks legitimate workloads is an operational emergency. Test your policies before deploying them.</p>
<hr />
<h2>Security Posture at a Glance</h2>
<table>
<thead>
<tr>
<th>Attack Scenario</th>
<th>Standard Kubernetes</th>
<th>This Zero-Trust Platform</th>
</tr>
</thead>
<tbody><tr>
<td>Lateral movement from compromised pod</td>
<td>Unrestricted — flat network</td>
<td>Blocked by AuthorizationPolicy — no path exists</td>
</tr>
<tr>
<td>Exec into production pod</td>
<td>Silent — no detection</td>
<td>Falco CRITICAL alert within seconds</td>
</tr>
<tr>
<td>Plaintext service-to-service traffic</td>
<td>Default behavior</td>
<td>Impossible — STRICT mTLS enforced</td>
</tr>
<tr>
<td>Privileged container deployment</td>
<td>Allowed</td>
<td>Blocked by Gatekeeper at admission</td>
</tr>
<tr>
<td>Static secrets in environment variables</td>
<td>Common pattern</td>
<td>Eliminated — Vault dynamic injection</td>
</tr>
<tr>
<td>Breach detection time</td>
<td>Days (London: 6 days)</td>
<td>Minutes — Falco + authorization logs</td>
</tr>
</tbody></table>
<hr />
<h2>How This Helps Companies</h2>
<p>The financial services company that paid $4.3 million was running a standard Kubernetes cluster with flat networking. Not because their engineers were careless. Because zero-trust Kubernetes is complex to implement correctly and the documentation for doing it end-to-end is fragmented across five different tool ecosystems.</p>
<p>This project integrates all five layers into a single coherent platform. The implementation patterns demonstrated here apply directly to any organization running Kubernetes workloads that handles sensitive data, serves regulated industries, or has enterprise clients who require security attestation before signing contracts.</p>
<hr />
<h2>Full Source Code</h2>
<p>The complete code is available at:</p>
<p>🔗 <a href="https://github.com/Eaglewings966/istio-zero-trust-eks"><strong>github.com/Eaglewings966/istio-zero-trust-eks</strong></a></p>
<p>When you are done testing, clean up your environment:</p>
<pre><code class="language-bash">kubectl delete -f apps/ -R --ignore-not-found=true
helm uninstall vault falco gatekeeper istiod cilium \
  -n vault,falco,gatekeeper-system,istio-system,cilium-system
eksctl delete cluster \
  --name emmanuel-zt-platform --region us-east-1
</code></pre>
<hr />
<h2>What's Next</h2>
<p>Project 11 covers the <strong>Automated SOC2/CIS Compliance Pipeline</strong> — OpenSCAP scanning, Ansible auto-remediation, and a CI/CD gate that blocks deployments when the compliance score drops below 85%.</p>
<hr />
<p><em>Emmanuel Ubani is a Cloud and DevOps Engineer based in Lagos, Nigeria. He writes about real infrastructure builds, career transitions into tech, and lessons learned the hard way.</em></p>
<ul>
<li><p>🔗 <a href="https://linkedin.com/in/ubaniemmanuel">LinkedIn</a></p>
</li>
<li><p>💻 <a href="https://github.com/Eaglewings966">GitHub</a></p>
</li>
<li><p>✍️ <a href="https://emmanuelubani.hashnode.dev">Hashnode</a></p>
</li>
<li><p>📝 <a href="https://medium.com/@emmaubani966">Medium</a></p>
</li>
<li><p>🌐 <a href="https://ops-run.lovable.app">Portfolio</a></p>
</li>
</ul>
]]></content:encoded></item><item><title><![CDATA[I Built the Supply Chain Security Platform That Would Have
Detected the SolarWinds Attack — Here Is Exactly How It Works]]></title><description><![CDATA[Cosign keyless signing, Syft SBOM generation, Rekor transparency log, Kyverno and OPA Gatekeeper dual enforcement on AWS EKS Fargate]]></description><link>https://emmanuelubani.hashnode.dev/i-built-the-supply-chain-security-platform-that-would-have-detected-the-solarwinds-attack-here-is-exactly-how-it-works</link><guid isPermaLink="true">https://emmanuelubani.hashnode.dev/i-built-the-supply-chain-security-platform-that-would-have-detected-the-solarwinds-attack-here-is-exactly-how-it-works</guid><category><![CDATA[Kubernetes]]></category><category><![CDATA[Security]]></category><category><![CDATA[AWS]]></category><category><![CDATA[Cloud Computing]]></category><category><![CDATA[Cloud]]></category><dc:creator><![CDATA[Ubani Obiajulum Emmanuel]]></dc:creator><pubDate>Fri, 01 May 2026 11:37:47 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/69a6e08356428acc6fe423a5/846da4aa-a597-4f87-8335-13d74b6a077c.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<hr />
<h2>The Attack That Changed How the World Thinks About Software</h2>
<p>In December 2020, security researchers at FireEye discovered something that made the entire industry stop and reconsider everything they thought they knew about software security.</p>
<p>SolarWinds, a company that makes network monitoring software used by 33,000 organizations including the US Pentagon, NASA, and Fortune 500 companies, had been compromised. Attackers had inserted malicious code into SolarWinds' own build pipeline. The malicious version was compiled, packaged, and signed by SolarWinds' <strong>legitimate signing process</strong>. The update was distributed through official channels. 18,000 organizations downloaded and installed it.</p>
<p>The attack was undetected for <strong>nine months</strong>.</p>
<p>The reason it was undetected for so long is the same reason traditional code signing failed to catch it. The software was signed. The signature verified correctly. Signature verification told every customer that the software was legitimate — when it was not. <strong>The signing process itself had been compromised.</strong></p>
<p>The investigation and remediation cost exceeded $40 million. The scope of data exfiltrated is still not fully known.</p>
<p>The solution to the SolarWinds attack model is not better signing. It is <strong>transparent signing</strong>. Every signature must be recorded in a publicly auditable log that anyone can query. Any deviation from the expected signing identity is immediately detectable. No private signing key is stored anywhere that an attacker can find it.</p>
<p>That is what this project builds.</p>
<hr />
<img src="https://cdn.hashnode.com/uploads/covers/69a6e08356428acc6fe423a5/710e1426-0cb8-4944-8cee-962ae18dee52.png" alt="" style="display:block;margin:0 auto" />

<img src="https://cdn.hashnode.com/uploads/covers/69a6e08356428acc6fe423a5/66e4b57c-b0e6-4a5f-a253-76c43c8a54ed.png" alt="" style="display:block;margin:0 auto" />

<hr />
<h2>Who I Am and Why I Built This</h2>
<p>I am <strong>Emmanuel Ubani</strong>, a Cloud and DevOps Engineer based in Lagos, Nigeria. I transitioned into tech in January 2024 after working as a zoo volunteer, an automotive technician apprentice, and a beauty and cosmetics store manager.</p>
<p>I came into this field understanding what counterfeiting looks like. In Lagos markets, you learn quickly that a product can look authentic, have the right label, and still be fake. The question is not whether it looks real. The question is whether there is a <strong>verifiable chain of custody</strong> that proves it is real.</p>
<p>Container image supply chain security is the same problem. An image can be signed and still be malicious if the signing process was compromised. What you need is a chain of custody — cryptographic proof of exactly who signed what, when, from which pipeline, and a public record that anyone can audit.</p>
<p>This article walks through every component of that chain. I will explain the architecture, the implementation decisions, and the problems I ran into along the way.</p>
<hr />
<h2>What Is Container Image Supply Chain Security</h2>
<p>Before I show you what I built, I want to ground this in plain language because the terminology in this space can feel opaque.</p>
<p>A <strong>container image</strong> is a packaged version of your application. Everything it needs to run is inside it. When Kubernetes deploys your application, it pulls this image and runs it.</p>
<p>The <strong>supply chain</strong> is every step between when a developer writes code and when that code runs in production. The source code is part of the supply chain. The build pipeline is part of the supply chain. The container registry is part of the supply chain. Every step is a potential point of compromise.</p>
<p><strong>Supply chain security</strong> means establishing cryptographic proof at every step and enforcing that proof before anything runs.</p>
<blockquote>
<p>💡 Think of it like the supply chain for pharmaceutical drugs. A medicine package has a barcode, a lot number, a manufacturer certificate, and a chain of custody document. A pharmacist does not just check whether the medicine looks like medicine — they verify the chain. Every step from manufacturing to dispensing is documented and verifiable. Container image supply chain security is that process, implemented in software, enforced automatically, and recorded in a public log that anyone can audit.</p>
</blockquote>
<hr />
<h2>What I Built</h2>
<p>A complete container image supply chain security platform with four integrated components.</p>
<table>
<thead>
<tr>
<th>Component</th>
<th>Tool</th>
<th>What It Does</th>
</tr>
</thead>
<tbody><tr>
<td><strong>Keyless Signing</strong></td>
<td>Cosign + Sigstore</td>
<td>Signs every image using GitHub Actions OIDC identity — no private key stored anywhere</td>
</tr>
<tr>
<td><strong>SBOM Generation</strong></td>
<td>Syft</td>
<td>Generates a full package inventory in SPDX and CycloneDX formats, attached to the image</td>
</tr>
<tr>
<td><strong>Transparency Log</strong></td>
<td>Rekor</td>
<td>Records every signing event in a public, tamper-evident, append-only log</td>
</tr>
<tr>
<td><strong>Admission Enforcement</strong></td>
<td>Kyverno + OPA Gatekeeper</td>
<td>Blocks unsigned or unverified images before they ever reach a cluster node</td>
</tr>
</tbody></table>
<hr />
<img src="https://cdn.hashnode.com/uploads/covers/69a6e08356428acc6fe423a5/cd8ebc58-ade3-4d33-9bd4-2a30c63e7f02.png" alt="" style="display:block;margin:0 auto" />

<hr />
<h2>Architecture Decisions</h2>
<h3>Why Keyless Signing Over Key-Based Signing</h3>
<p>Traditional code signing requires a private key. That private key must be stored somewhere — in GitHub Secrets, in a key management system, or in a hardware security module. Wherever it lives, it is a target.</p>
<p><strong>SolarWinds had a signing key. The attackers used it.</strong></p>
<p>Keyless signing changes the threat model fundamentally. Instead of a private key, the signing credential is a short-lived OIDC token issued by GitHub Actions for the duration of a single job. The token proves the signing happened from a specific GitHub repository, a specific workflow file, and a specific git branch. When the job ends, the token expires. <strong>There is nothing to steal.</strong></p>
<p>If an attacker compromises the build pipeline and tries to sign a malicious image, the OIDC subject in the certificate will not match the expected workflow path. Verification will fail. The anomalous entry in Rekor is publicly visible.</p>
<hr />
<h3>Why Dual Enforcement With Kyverno and OPA Gatekeeper</h3>
<p>Two admission controllers for one cluster sounds redundant. It is not.</p>
<p><strong>Kyverno</strong> handles image signature verification natively. Its policy language is YAML-based and purpose-built for Kubernetes resource validation. It is the right tool for checking whether an image was signed by the correct identity.</p>
<p><strong>OPA Gatekeeper</strong> handles registry allowlisting using Rego policies. Rego is a general-purpose policy language that can express constraints that Kyverno's YAML-based policies cannot — complex logic about which registries are approved, which image name patterns are permitted, and what combinations of attributes are required.</p>
<p>Together they form a defense-in-depth admission chain:</p>
<table>
<thead>
<tr>
<th>Controller Fails</th>
<th>What Still Enforces</th>
</tr>
</thead>
<tbody><tr>
<td>Kyverno has an issue</td>
<td>Gatekeeper continues enforcing</td>
</tr>
<tr>
<td>Gatekeeper has an issue</td>
<td>Kyverno continues enforcing</td>
</tr>
</tbody></table>
<p>For an admission control layer that is the last line of defense before untrusted code runs in the cluster, redundancy is not optional.</p>
<hr />
<h3>Why SBOM in Both SPDX and CycloneDX</h3>
<table>
<thead>
<tr>
<th>Format</th>
<th>Required By</th>
</tr>
</thead>
<tbody><tr>
<td><strong>SPDX</strong></td>
<td>US government Executive Order on cybersecurity, NTIA minimum elements for software transparency, US federal procurement</td>
</tr>
<tr>
<td><strong>CycloneDX</strong></td>
<td>OWASP, Dependency-Track, majority of enterprise security toolchains</td>
</tr>
</tbody></table>
<p>Generating both adds thirty seconds to the pipeline. It ensures compatibility with every downstream consumer regardless of their toolchain preference. There is no reason not to generate both.</p>
<img src="https://cdn.hashnode.com/uploads/covers/69a6e08356428acc6fe423a5/80fc28b1-4e2f-43f1-afa7-b3df744e5f7b.png" alt="" style="display:block;margin:0 auto" />

<img src="https://cdn.hashnode.com/uploads/covers/69a6e08356428acc6fe423a5/66cd7cc6-d9aa-46d8-9659-4bc0afb39f97.png" alt="" style="display:block;margin:0 auto" />

<hr />
<h2>Implementation</h2>
<h3>Step 1 — The Signing Pipeline</h3>
<p>Here is the core of the supply chain pipeline. The signing job uses Cosign's keyless mode with <code>COSIGN_EXPERIMENTAL</code> enabled:</p>
<pre><code class="language-yaml">- name: Sign ECR image with Cosign keyless
  env:
    COSIGN_EXPERIMENTAL: "1"
  run: |
    cosign sign --yes \
      ${{ needs.build.outputs.ecr-image }}

    echo "Signature recorded in Rekor transparency log"
</code></pre>
<p>The <code>--yes</code> flag accepts the Rekor upload without prompting. The <code>COSIGN_EXPERIMENTAL: "1"</code> flag enables keyless signing mode. When this command runs, it:</p>
<ol>
<li><p>Requests an OIDC token from GitHub</p>
</li>
<li><p>Sends it to Fulcio to get a short-lived certificate</p>
</li>
<li><p>Signs the image digest with that certificate</p>
</li>
<li><p>Records both the signature and the certificate in Rekor</p>
</li>
</ol>
<p>By the time this job completes, the signature is in a <strong>public transparency log that anyone in the world can query</strong>.</p>
<hr />
<h3>Step 2 — SBOM Generation and Attachment</h3>
<pre><code class="language-yaml">- name: Generate SBOM for ECR image
  run: |
    syft ${{ needs.build.outputs.ecr-image }} \
      -o spdx-json=sbom-ecr.spdx.json \
      -o cyclonedx-json=sbom-ecr.cyclonedx.json

    echo "Total packages: $(cat sbom-ecr.spdx.json | jq '.packages | length')"

- name: Attach SBOM to image with Cosign
  env:
    COSIGN_EXPERIMENTAL: "1"
  run: |
    cosign attach sbom \
      --sbom sbom-ecr.spdx.json \
      ${{ needs.build.outputs.ecr-image }}
</code></pre>
<p>Syft scans the image layers and produces a machine-readable inventory of every package. The SBOM is then attached to the image using Cosign, stored as an OCI artifact in the same registry alongside the image. Anyone who pulls the image can also pull and verify the SBOM without any additional tooling beyond Cosign.</p>
<hr />
<img src="https://cdn.hashnode.com/uploads/covers/69a6e08356428acc6fe423a5/d3bbbaa4-79c4-4ae8-afad-78a247808130.png" alt="" style="display:block;margin:0 auto" />

<hr />
<h3>Step 3 — Kyverno Policy Enforcement</h3>
<p>The <code>ClusterPolicy</code> that enforces signature verification:</p>
<pre><code class="language-yaml">apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
  name: verify-image-signatures
spec:
  validationFailureAction: Enforce
  rules:
    - name: verify-cosign-signature
      match:
        any:
          - resources:
              kinds: [Pod]
              namespaces: [demo-app]
      verifyImages:
        - imageReferences:
            - "ghcr.io/eaglewings966/*"
            - "*.dkr.ecr.us-east-1.amazonaws.com/*"
          attestors:
            - entries:
                - keyless:
                    subject: "https://github.com/Eaglewings966/cosign-image-supply-chain/.github/workflows/supply-chain-pipeline.yml@refs/heads/main"
                    issuer: "https://token.actions.githubusercontent.com"
</code></pre>
<p>The <code>subject</code> field is the most important part of this policy. It specifies the <strong>exact GitHub Actions workflow path and branch</strong> that is permitted to sign images. An image signed by any other workflow, repository, or branch will fail verification and the pod will be blocked.</p>
<blockquote>
<p>🔐 <strong>This is what would have caught the SolarWinds attack.</strong> The attackers used SolarWinds' own signing credentials. But they could not have had the specific GitHub Actions OIDC identity for a specific workflow file at a specific git ref. The subject mismatch would have been caught before any image ran.</p>
</blockquote>
<hr />
<img src="https://cdn.hashnode.com/uploads/covers/69a6e08356428acc6fe423a5/625c4d81-78b1-43c9-90d7-96d3afb36ebe.png" alt="" style="display:block;margin:0 auto" />

<img src="https://cdn.hashnode.com/uploads/covers/69a6e08356428acc6fe423a5/6a4d4e64-4d0b-47b3-869c-812479b89a29.png" alt="" style="display:block;margin:0 auto" />

<hr />
<h3>Step 4 — Testing the Enforcement</h3>
<p>Attempting to deploy an unsigned image:</p>
<pre><code class="language-plaintext"># Try to deploy an unsigned nginx image
kubectl apply -f unsigned-test-deployment.yaml

# Expected response from Kyverno:
# Error from server: error when creating "unsigned-test-deployment.yaml":
# admission webhook "mutate.kyverno.svc-fail" denied the request:
# resource Deployment/demo-app/unsigned-test was blocked
# due to the following policies: verify-image-signatures:
# image nginx:latest failed signature verification
</code></pre>
<p>The admission webhook fires before the deployment reaches the Kubernetes scheduler. The unsigned image <strong>never touches a node</strong>. It is rejected at the API server level.</p>
<p>Then deploying the signed image:</p>
<pre><code class="language-bash">kubectl apply -f apps/demo-app/deployment.yaml

kubectl get pods -n demo-app
# NAME                        READY   STATUS    RESTARTS   AGE
# demo-app-7d9f8b6c4-xk2pq   1/1     Running   0          45s
</code></pre>
<p>The signed image deploys successfully. Kyverno verified the signature, confirmed the OIDC subject matched the expected workflow, and allowed the pod to schedule.</p>
<p><img src="align=%22center%22" alt="" /></p>
<img src="https://cdn.hashnode.com/uploads/covers/69a6e08356428acc6fe423a5/859f5817-0a54-43b9-97c6-97755752f06b.png" alt="" style="display:block;margin:0 auto" />

<hr />
<h2>What Broke During This Build</h2>
<h3>Debugging Story 1 — The Subject Mismatch</h3>
<p>My first attempt at the Kyverno policy used a simplified subject pattern:</p>
<pre><code class="language-yaml">subject: "https://github.com/Eaglewings966/cosign-image-supply-chain"
</code></pre>
<p>Every signed image was blocked. Kyverno returned a verification failure with a cryptic error about the attestor chain not matching.</p>
<p>The actual subject recorded in the Rekor entry is the full workflow path including the specific workflow file and the git ref:</p>
<pre><code class="language-plaintext">https://github.com/Eaglewings966/cosign-image-supply-chain/.github/workflows/supply-chain-pipeline.yml@refs/heads/main
</code></pre>
<p>The Kyverno policy subject must match this exactly. The way to find the exact subject is to run <code>cosign verify</code> on a signed image and look at the <code>subject</code> field in the JSON output. That subject is what goes into the policy.</p>
<blockquote>
<p>⚠️ <strong>The error message from Kyverno does not tell you what subject it found versus what it expected.</strong> You have to query Rekor directly to see the actual subject. Forty minutes of verification failures before I found the exact subject string.</p>
</blockquote>
<hr />
<h3>Debugging Story 2 — Gatekeeper Webhook Registration Race</h3>
<p>After deploying Gatekeeper on Fargate, the constraint templates failed to install with:</p>
<pre><code class="language-plaintext">Error from server (InternalError): error when creating
"require-signed-images-template.yaml": Internal error occurred:
failed calling webhook "webhook.gatekeeper.sh": failed to call
webhook: Post https://gatekeeper-webhook-service.gatekeeper-system.svc:443/v1/admit:
dial tcp: connection refused
</code></pre>
<p>Gatekeeper's own webhook was not ready when I tried to install the constraint templates. On Fargate, the cold start time for Gatekeeper pods is longer than on EC2. The webhook server was still initializing when <code>kubectl apply</code> was attempting to send resources through it.</p>
<p>The fix was waiting for all Gatekeeper pods to show <code>Running</code> and their health endpoints to return <code>200</code> before applying any constraint templates.</p>
<blockquote>
<p>💡 In production, replace the <code>sleep 60</code> with a proper readiness check against the webhook endpoint.</p>
</blockquote>
<hr />
<h3>Debugging Story 3 — SBOM Attach on Immutable Tag</h3>
<p>When I tried to attach the SBOM to the ECR image, <code>cosign attach</code> returned an error about the image tag being immutable.</p>
<p>ECR's immutable tag feature prevents any push to an existing tag. Cosign's attach command attempts to push an OCI artifact referencing the same tag — ECR treats this as a tag modification attempt and blocks it.</p>
<p>The solution is to attach the SBOM using the <strong>image digest</strong> instead of the tag:</p>
<pre><code class="language-bash"># Resolve tag to digest first
DIGEST=\((crane digest \){IMAGE_TAG})

# Attach SBOM by digest
cosign attach sbom --sbom sbom.spdx.json \({IMAGE_REGISTRY}/\){REPO}@${DIGEST}
</code></pre>
<p>The digest is immutable by nature. Attaching by digest creates a new OCI artifact in the registry that references the image digest without modifying the existing tag.</p>
<hr />
<h2>The Real Lesson</h2>
<p>Here is the thing about supply chain security that most tutorials skip entirely.</p>
<p><strong>Signing an image proves that you signed it. It does not prove that the image is safe.</strong></p>
<p>Traditional signing tells you <em>who</em> signed something. Transparent signing tells you who signed something, <em>when</em>, from which <em>exact build context</em>, and records that information in a log that anyone can audit for anomalies.</p>
<p>The difference is <strong>audibility at scale</strong>.</p>
<p>If someone compromises your build pipeline and signs a malicious image with a private key stored in GitHub Secrets, the traditional signature says the image is legitimate. A verifier cannot tell the difference between a legitimate build and a compromised build if both used the same key.</p>
<p>With keyless signing tied to OIDC identity, the compromised build would have signed with a different OIDC subject, or from a different workflow file, or from a different branch. Any of those deviations is visible in Rekor. A monitoring system that watches Rekor for unexpected signing events from your repository would catch it.</p>
<blockquote>
<p>The SolarWinds attackers had the signing key. They could not have had the correct OIDC identity for a specific workflow file at a specific git ref. <strong>That specificity is what transparent signing gives you that traditional signing does not.</strong></p>
</blockquote>
<p>Supply chain security is not about trust. It is about <strong>verifiability</strong>. Build systems where every assertion can be independently verified by anyone without trusting you.</p>
<hr />
<h2>Production Gap Analysis</h2>
<p>This platform demonstrates the core supply chain security model. A production deployment at enterprise scale would additionally require the following.</p>
<p><strong>SLSA Level 3 provenance</strong> via the <code>slsa-github-generator</code> action provides cryptographic proof of the full build process including the source code commit, the build inputs, and the build environment. SLSA Level 3 is the minimum required for US federal software procurement under Executive Order 14028.</p>
<p><strong>Sigstore Policy Controller</strong> replaces the Kyverno image verification component with a dedicated controller designed specifically for Cosign verification. Policy Controller has deeper integration with the Sigstore ecosystem and supports more complex verification policies including multi-party signing requirements.</p>
<p><strong>Rekor monitoring</strong> via a dedicated service that watches the Rekor transparency log for unexpected signing events from your repositories. An unexpected signing event is an early indicator of build pipeline compromise.</p>
<p><strong>Dependency-Track integration</strong> with the SBOM provides continuous vulnerability monitoring of every package in every deployed image. When a new CVE is published that affects a package in a deployed image's SBOM, Dependency-Track generates an alert without requiring a new image build.</p>
<hr />
<h2>Security Posture at a Glance</h2>
<table>
<thead>
<tr>
<th>Attack Scenario</th>
<th>Traditional Signing</th>
<th>This Platform</th>
</tr>
</thead>
<tbody><tr>
<td>Compromised private key</td>
<td>Undetectable — attacker signs with stolen key</td>
<td>OIDC subject mismatch — blocked and visible in Rekor</td>
</tr>
<tr>
<td>Malicious image pushed to registry</td>
<td>Passes if signed</td>
<td>Blocked by Kyverno at admission</td>
</tr>
<tr>
<td>Image from unapproved registry</td>
<td>No enforcement</td>
<td>Blocked by OPA Gatekeeper Rego policy</td>
</tr>
<tr>
<td>Unknown packages in image</td>
<td>No visibility</td>
<td>Full SBOM attached and queryable</td>
</tr>
<tr>
<td>Signing event outside expected workflow</td>
<td>No detection</td>
<td>Anomalous Rekor entry — monitorable</td>
</tr>
<tr>
<td>Supply chain compromise undetected</td>
<td>Average 9 months (SolarWinds)</td>
<td>Detectable at first deployment attempt</td>
</tr>
</tbody></table>
<hr />
<h2>How This Helps Companies</h2>
<p>The SolarWinds attack was not unique. Supply chain attacks have increased by over 700% since 2020. The attack vector is effective because it bypasses traditional perimeter security entirely. If you trust the build pipeline, you trust the output.</p>
<p>This platform establishes cryptographic proof at every step of the delivery process and enforces that proof before any code runs in the cluster. It does not eliminate supply chain attacks. It makes them <strong>detectable, auditable, and ultimately ineffective</strong> against a properly configured enforcement layer.</p>
<hr />
<h2>Full Source Code</h2>
<p>The complete code is available at:</p>
<p>🔗 <a href="https://github.com/Eaglewings966/cosign-image-supply-chain"><strong>github.com/Eaglewings966/cosign-image-supply-chain</strong></a></p>
<p>When you are done testing, clean up your environment:</p>
<pre><code class="language-bash">helm uninstall policy-reporter -n kyverno-policy-reporter
helm uninstall gatekeeper -n gatekeeper-system
helm uninstall kyverno -n kyverno
cd terraform &amp;&amp; terraform destroy --auto-approve
eksctl delete cluster \
  --name supply-chain-cluster --region us-east-1
</code></pre>
<hr />
<h2>What's Next</h2>
<p>Project 10 in this series covers the <strong>Zero-Trust Kubernetes Platform</strong> — Istio mTLS, OPA Gatekeeper, Falco runtime security, and Vault secrets injection.</p>
<hr />
<p><em>Emmanuel Ubani is a Cloud and DevOps Engineer based in Lagos, Nigeria. He writes about real infrastructure builds, career transitions into tech, and lessons learned the hard way.</em></p>
<ul>
<li><p>🔗 <a href="https://linkedin.com/in/ubaniemmanuel">LinkedIn</a></p>
</li>
<li><p>💻 <a href="https://github.com/Eaglewings966">GitHub</a></p>
</li>
<li><p>✍️ <a href="https://emmanuelubani.hashnode.dev">Hashnode</a></p>
</li>
<li><p>📝 <a href="https://medium.com/@emmaubani966">Medium</a></p>
</li>
<li><p>🌐 <a href="https://ops-run.lovable.app">Portfolio</a></p>
</li>
</ul>
]]></content:encoded></item><item><title><![CDATA[I Built a 5-Node HashiCorp Vault HA Cluster on AWS EKS That Eliminated Every Static Secret From Our Kubernetes Workloads]]></title><description><![CDATA[Raft integrated storage, AWS KMS auto-unseal, dynamic AWS and PostgreSQL credentials, internal PKI, and Vault Agent sidecar injection — end to end]]></description><link>https://emmanuelubani.hashnode.dev/i-built-a-5-node-hashicorp-vault-ha-cluster-on-aws-eks-that-eliminated-every-static-secret-from-our-kubernetes-workloads</link><guid isPermaLink="true">https://emmanuelubani.hashnode.dev/i-built-a-5-node-hashicorp-vault-ha-cluster-on-aws-eks-that-eliminated-every-static-secret-from-our-kubernetes-workloads</guid><category><![CDATA[AWS]]></category><category><![CDATA[Security]]></category><category><![CDATA[Terraform]]></category><category><![CDATA[Kubernetes]]></category><category><![CDATA[Career]]></category><dc:creator><![CDATA[Ubani Obiajulum Emmanuel]]></dc:creator><pubDate>Sun, 26 Apr 2026 21:26:54 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/69a6e08356428acc6fe423a5/bea1ca8b-aed5-4d72-b91e-7ea2c8ca6516.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<hr />
<h2>The Breach That Started With a Hardcoded Secret</h2>
<p>In March 2020, an engineer at a US healthcare company pushed a configuration file to a private GitHub repository. The file contained an AWS access key. The key had been there for four months. GitHub's secret scanning had not been enabled. The repository was private but not air-gapped from the internet.</p>
<p>A credential harvesting bot found it within <strong>eleven minutes</strong>.</p>
<p>By the time the security team discovered the breach two weeks later, the attacker had exfiltrated 3.1 million patient records using the access key's S3 permissions. The key had never been rotated. It had a two-year expiry. Nobody knew it existed in that file until the breach investigation surfaced it.</p>
<p><strong>The HIPAA fine was $6.85 million.</strong></p>
<p>The access key was not special. It was the same kind of static credential that lives in thousands of Kubernetes secrets, CI/CD pipelines, environment variables, and configuration files across the industry right now. The only difference between that company and most others is that their breach was discovered.</p>
<p>Static credentials are not a configuration problem. They are an <strong>architectural problem</strong>. The solution is not better rotation policies. The solution is eliminating static credentials entirely and replacing them with dynamic ones that expire automatically.</p>
<p>That is what this project builds.</p>
<img src="https://cdn.hashnode.com/uploads/covers/69a6e08356428acc6fe423a5/02486259-dd77-4011-8e9f-0c8f42076764.png" alt="" style="display:block;margin:0 auto" />

<hr />
<h2>Who I Am and Why I Built This</h2>
<p>I am <strong>Emmanuel Ubani</strong>, a Cloud and DevOps Engineer based in Lagos, Nigeria. I came into tech in January 2024 after working as a zoo volunteer, an automotive technician apprentice, and a beauty store manager.</p>
<p>I built this project because secrets management is one of the most underbuilt parts of cloud infrastructure. Teams spend weeks on CI/CD pipelines and days on monitoring stacks but leave database passwords in Kubernetes secrets and AWS access keys in GitHub Actions variables.</p>
<p>This article walks through the full implementation of a production-grade Vault HA cluster. I will explain every architectural decision, every configuration detail, and every problem I encountered. Whether you are implementing this for the first time or evaluating whether your current Vault setup matches production standards, this article gives you both the <strong>what</strong> and the <strong>why</strong>.</p>
<hr />
<h2>What Static Credentials Actually Cost</h2>
<p>Before I show you what I built, I want to make the business case concrete — because this is a conversation you may need to have with a CTO or an engineering manager.</p>
<p>Static credentials have four failure modes that compound over time.</p>
<p><strong>Theft.</strong> A credential stored anywhere can be stolen from there. It can be found in git history, leaked through logs, extracted from a compromised pod, or obtained through a phishing attack on the engineer who created it.</p>
<p><strong>Rotation failure.</strong> Manual rotation is a human process. Humans forget. Humans rotate in the wrong environment. Humans rotate without updating all the systems that depend on the credential. Every manual rotation is a potential outage.</p>
<p><strong>Scope creep.</strong> A credential created for one purpose gets reused for another. The S3 read key becomes the key that also writes. The database read user becomes the one running migrations. Permissions accumulate because creating new credentials is inconvenient.</p>
<p><strong>Attribution loss.</strong> Six months after an access key is created, nobody remembers what it was for, who created it, or what it has access to. It sits in the account as a permanent attack surface that nobody wants to revoke because nobody knows what will break.</p>
<p>Dynamic credentials eliminate all four failure modes at the architectural level:</p>
<table>
<thead>
<tr>
<th>Failure Mode</th>
<th>How Dynamic Credentials Eliminate It</th>
</tr>
</thead>
<tbody><tr>
<td>Theft</td>
<td>A credential expiring in one hour cannot be meaningfully used after that hour</td>
</tr>
<tr>
<td>Rotation failure</td>
<td>Rotation is automatic — no human process to fail</td>
</tr>
<tr>
<td>Scope creep</td>
<td>Credentials are generated per-request with exactly defined permissions</td>
</tr>
<tr>
<td>Attribution loss</td>
<td>Every credential request is logged with identity, timestamp, and lease ID</td>
</tr>
</tbody></table>
<hr />
<h2>What I Built</h2>
<p>A five-node HashiCorp Vault HA cluster on AWS EKS Fargate with four secrets engines and full sidecar injection for all workloads.</p>
<h3>The Cluster</h3>
<p>Five Vault server pods using <strong>Raft integrated storage</strong> for consensus. No external storage dependency. Vault manages its own distributed state. AWS KMS auto-unseal — when any Vault pod restarts, it calls the KMS key to unseal itself automatically. No human intervention. No on-call engineer woken up to type unseal keys.</p>
<h3>The Four Secrets Engines</h3>
<table>
<thead>
<tr>
<th>Engine</th>
<th>Path</th>
<th>What It Does</th>
</tr>
</thead>
<tbody><tr>
<td><strong>KV v2</strong></td>
<td><code>secret/</code></td>
<td>Versioned static secrets — config, feature flags, third-party API keys</td>
</tr>
<tr>
<td><strong>AWS</strong></td>
<td><code>aws/</code></td>
<td>Dynamic IAM users with scoped policies, auto-deleted after 1 hour</td>
</tr>
<tr>
<td><strong>Database</strong></td>
<td><code>database/</code></td>
<td>Dynamic PostgreSQL roles, auto-dropped on TTL expiry</td>
</tr>
<tr>
<td><strong>PKI</strong></td>
<td><code>pki_int/</code></td>
<td>Internal TLS CA — issues 24-hour certificates for service-to-service TLS</td>
</tr>
</tbody></table>
<img src="https://cdn.hashnode.com/uploads/covers/69a6e08356428acc6fe423a5/33986e1b-f9c5-4744-9a58-e745a5d695d4.png" alt="" style="display:block;margin:0 auto" />

<h3>The Injection Model</h3>
<p>Vault Agent injector handles sidecar injection for all workloads. Applications read secrets from files in <code>/vault/secrets/</code>. They never call the Vault API. They never handle token renewal. The agent sidecar does all of it.</p>
<img src="https://cdn.hashnode.com/uploads/covers/69a6e08356428acc6fe423a5/0a848458-c4f8-4f5f-b0bb-ec3ea82fc7af.png" alt="" style="display:block;margin:0 auto" />

<hr />
<h2>Why Five Replicas</h2>
<p>The most common question I get about this architecture is why five replicas instead of three.</p>
<p>Raft requires a quorum of <code>floor(n/2)+1</code> nodes to operate:</p>
<table>
<thead>
<tr>
<th>Replicas</th>
<th>Quorum Required</th>
<th>Fault Tolerance</th>
</tr>
</thead>
<tbody><tr>
<td>3 nodes</td>
<td>2 nodes</td>
<td>Can lose <strong>1</strong> node</td>
</tr>
<tr>
<td><strong>5 nodes</strong></td>
<td><strong>3 nodes</strong></td>
<td><strong>Can lose 2 nodes simultaneously</strong></td>
</tr>
</tbody></table>
<p>With three nodes, lose one and you are at exactly the minimum. One more failure and Vault stops accepting writes. You are one bad pod restart away from a split-brain scenario.</p>
<p>With five nodes, you can lose two nodes simultaneously and continue operating normally. During planned maintenance this means you can take down one node for an upgrade while being fully protected against an unplanned failure of a second node at the same time.</p>
<p>For a secrets platform — the infrastructure that <strong>everything else depends on</strong> — the extra two pods are not overhead. They are the difference between a system that can tolerate real production conditions and one that can only tolerate lab conditions.</p>
<hr />
<h2>Implementation</h2>
<h3>Step 1 — Provision Infrastructure with Terraform</h3>
<p>The Terraform configuration creates four categories of resources: a KMS key for auto-unseal, an IRSA role for the Vault service account, an RDS PostgreSQL instance for the dynamic credentials demonstration, and the Vault Helm release itself.</p>
<p>Here is the KMS key configuration:</p>
<pre><code class="language-javascript">resource "aws_kms_key" "vault_unseal" {
  description             = "Vault auto-unseal key"
  deletion_window_in_days = 7
  enable_key_rotation     = true

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Sid    = "EnableRootAccess"
        Effect = "Allow"
        Principal = {
          AWS = "arn:aws:iam::${data.aws_caller_identity.current.account_id}:root"
        }
        Action   = "kms:*"
        Resource = "*"
      },
      {
        Sid    = "AllowVaultUnseal"
        Effect = "Allow"
        Principal = {
          AWS = aws_iam_role.vault_server.arn
        }
        Action = [
          "kms:Encrypt",
          "kms:Decrypt",
          "kms:DescribeKey"
        ]
        Resource = "*"
      }
    ]
  })
}
</code></pre>
<p>The key policy scopes KMS access to exactly the Vault server role. No other IAM principal can call this key. If an attacker compromises a different role in the account, they cannot use it to unseal Vault.</p>
<img src="https://cdn.hashnode.com/uploads/covers/69a6e08356428acc6fe423a5/f5e8a233-d25b-42f8-b511-45d6f71bec65.png" alt="" style="display:block;margin:0 auto" />

<hr />
<h3>Step 2 — Configure Vault HA with Raft</h3>
<p>The Vault Helm values configure the five-node Raft cluster. The most important section is the Raft storage configuration with all five <code>retry_join</code> addresses:</p>
<pre><code class="language-yaml">storage "raft" {
  path    = "/vault/data"
  node_id = "{{ .NodeID }}"

  retry_join {
    leader_api_addr = "http://vault-0.vault-internal:8200"
  }
  retry_join {
    leader_api_addr = "http://vault-1.vault-internal:8200"
  }
  retry_join {
    leader_api_addr = "http://vault-2.vault-internal:8200"
  }
  retry_join {
    leader_api_addr = "http://vault-3.vault-internal:8200"
  }
  retry_join {
    leader_api_addr = "http://vault-4.vault-internal:8200"
  }
}

seal "awskms" {
  region     = "us-east-1"
  kms_key_id = "${VAULT_AWSKMS_SEAL_KEY_ID}"
}
</code></pre>
<p>Every node tries to join all other nodes on startup. Whichever node comes up first becomes the initial leader. The others join as followers. When the leader goes down, Raft holds an election and one of the followers becomes the new leader in five to ten seconds. This is transparent to the Vault clients.</p>
<hr />
<h3>Step 3 — Initialize Vault</h3>
<p>Initialization happens once and only once per cluster. With KMS auto-unseal, Vault uses recovery keys instead of unseal keys. These recovery keys are only needed if KMS itself becomes unavailable.</p>
<pre><code class="language-javascript">kubectl exec -it vault-0 -n vault -- \
  vault operator init \
    -key-shares=5 \
    -key-threshold=3 \
    -recovery-shares=5 \
    -recovery-threshold=3 \
    -format=json
</code></pre>
<blockquote>
<p>🔐 <strong>The recovery keys must be stored offline. In a physical safe. Not in AWS Secrets Manager. Not in another Kubernetes secret.</strong> If AWS KMS is the emergency — the only scenario where you need these keys — you cannot reach AWS to retrieve them. Paper. Safe. Off-site backup. This is not optional.</p>
</blockquote>
<img src="https://cdn.hashnode.com/uploads/covers/69a6e08356428acc6fe423a5/45df3767-1c93-4f7d-ad53-d16c09a3a957.png" alt="" style="display:block;margin:0 auto" />

<hr />
<h3>Step 4 — Configure Dynamic Database Credentials</h3>
<p>This is the most operationally significant configuration in the entire project. After this step, <strong>no human can log into the PostgreSQL database</strong>. Only Vault can.</p>
<pre><code class="language-javascript"># Configure the database connection
vault write database/config/demo-postgres \
  plugin_name="postgresql-database-plugin" \
  allowed_roles="demo-app-db-role" \
  connection_url="postgresql://{{username}}:{{password}}@${RDS_ENDPOINT}:5432/vaultdemo" \
  username="vaultadmin" \
  password="${DB_PASSWORD}"

# Rotate root credentials — Vault now owns the password
vault write -force database/rotate-root/demo-postgres

# Create the dynamic role
vault write database/roles/demo-app-db-role \
  db_name="demo-postgres" \
  creation_statements="CREATE ROLE \"{{name}}\" WITH LOGIN PASSWORD '{{password}}' VALID UNTIL '{{expiration}}'; GRANT SELECT, INSERT, UPDATE ON ALL TABLES IN SCHEMA public TO \"{{name}}\";" \
  default_ttl="1h" \
  max_ttl="24h"
</code></pre>
<p>After <code>rotate-root</code>, the original master password no longer works. Vault generated a new random password and stored it internally. Every application that needs database access now requests dynamic credentials from Vault. Each request creates a new PostgreSQL role with a unique name and password, valid for one hour. When the TTL expires, Vault drops the role automatically.</p>
<img src="https://cdn.hashnode.com/uploads/covers/69a6e08356428acc6fe423a5/4202f02d-3886-445e-a837-a6b97847ae8d.png" alt="" style="display:block;margin:0 auto" />

<hr />
<h3>Step 5 — Vault Agent Sidecar Injection</h3>
<p>The Vault Agent injector intercepts pod creation through a Kubernetes mutating admission webhook. When it sees specific annotations on a pod, it injects an init container that fetches secrets and a sidecar container that keeps them fresh.</p>
<pre><code class="language-yaml">annotations:
  vault.hashicorp.com/agent-inject: "true"
  vault.hashicorp.com/role: "demo-app"

  # KV secret injected as JSON file
  vault.hashicorp.com/agent-inject-secret-config.json: &gt;-
    secret/data/apps/demo-app/config

  # Dynamic database credentials injected as env file
  vault.hashicorp.com/agent-inject-secret-db-creds: &gt;-
    database/creds/demo-app-db-role
  vault.hashicorp.com/agent-inject-template-db-creds: |
    {{- with secret "database/creds/demo-app-db-role" -}}
    DB_USERNAME="{{ .Data.username }}"
    DB_PASSWORD="{{ .Data.password }}"
    {{- end }}
</code></pre>
<p>The application container reads <code>/vault/secrets/db-creds</code> as a file. It never calls Vault. It never handles token renewal. When the database credential TTL approaches expiry, the Vault Agent sidecar fetches a new credential and overwrites the file. The application reads the file on its next database connection. <strong>The entire rotation is transparent to the application code.</strong></p>
<img src="https://cdn.hashnode.com/uploads/covers/69a6e08356428acc6fe423a5/96ccbf05-68d7-4856-8014-6105397bedb3.png" alt="" style="display:block;margin:0 auto" />

<hr />
<h2>What Broke During This Build</h2>
<h3>Debugging Story 1 — The Raft Pods That Would Not Join</h3>
<p>After deploying the Helm release, four of the five Vault pods were stuck in <code>0/1 Not Ready</code>. Only <code>vault-0</code> showed Ready.</p>
<p>The issue was that the other pods were trying to join the Raft cluster but <code>vault-0</code> was not yet initialized. Raft requires the initial node to be initialized before followers can join. The <code>retry_join</code> configuration handles this by retrying indefinitely, but the pods showed as not ready because the readiness probe was failing while they waited.</p>
<p>The fix was waiting for <code>vault-0</code> to become ready before running the initialization script. Once <code>vault-0</code> was initialized and unsealed via KMS, the other four pods joined the Raft cluster within seconds.</p>
<blockquote>
<p>💡 <strong>Raft cluster formation is sequential on first deployment.</strong> All pods showing not-ready before initialization is expected behaviour, not a configuration error.</p>
</blockquote>
<hr />
<h3>Debugging Story 2 — Database Root Rotation Locked Me Out</h3>
<p>During my first attempt at configuring the database secrets engine, I ran <code>vault write -force database/rotate-root/demo-postgres</code> before testing that <code>vault read database/creds/demo-app-db-role</code> actually worked.</p>
<p>The root rotation succeeded. But the dynamic credential generation failed because I had a syntax error in the <code>creation_statements</code> SQL template. The role could not be created. And now I could not fix it by logging into the database directly because Vault had rotated the root password and I no longer had it.</p>
<p>The only recovery path was destroying the RDS instance and creating a new one with a known password.</p>
<blockquote>
<p>⚠️ <strong>Test dynamic credential generation completely before rotating root credentials.</strong> Once you rotate, there is no going back. The root password exists only inside Vault's encrypted storage.</p>
</blockquote>
<hr />
<h3>Debugging Story 3 — IRSA Trust Policy Mismatch</h3>
<p>The Vault pods were starting but immediately failing with permission errors on the KMS API calls. The IAM role existed. The KMS key policy allowed the role. But the calls were being denied.</p>
<p>The issue was in the IRSA trust policy condition. The service account name in the condition was <code>vault-server</code> but the actual service account created by Helm was <code>vault</code>. The OIDC subject did not match, so AWS rejected the <code>AssumeRoleWithWebIdentity</code> call silently.</p>
<p>The fix was updating the trust policy condition to match the exact service account name.</p>
<blockquote>
<p>💡 <strong>The error message from AWS does not tell you what the condition mismatch was — it just returns</strong> <code>AccessDenied</code><strong>.</strong> Debugging IRSA issues requires comparing the actual service account name and namespace against the exact strings in the trust policy condition.</p>
</blockquote>
<hr />
<h2>Production Gap Analysis</h2>
<p>This cluster demonstrates the full Vault capability set. A production deployment at enterprise scale would additionally require the following.</p>
<p><strong>Vault Enterprise namespaces</strong> provide multi-tenancy within a single Vault cluster. Each team or business unit gets their own namespace with isolated policies, auth methods, and secrets engines. Without namespaces, a single Vault cluster becomes a shared credential store where one team can potentially access another team's secrets through policy misconfiguration.</p>
<p><strong>Vault Sentinel policies</strong> provide fine-grained governance beyond what HCL policies support. Sentinel can enforce conditions like requiring MFA for destructive operations, blocking credential generation outside business hours, and enforcing minimum TTL values on all issued credentials.</p>
<p><strong>Cross-region Vault replication</strong> via Vault Enterprise Performance Replication provides read-scalability and disaster recovery across regions. The primary cluster handles all writes. Secondary clusters serve reads from local replicas. If the primary region becomes unavailable, a secondary can be promoted.</p>
<p><strong>Break-glass procedures</strong> need to be documented, tested, and stored in a physical secure location before the cluster goes to production. The recovery keys printed during initialization are the only way to recover if both AWS KMS and the Vault cluster state become unavailable simultaneously.</p>
<hr />
<h2>Security Posture at a Glance</h2>
<table>
<thead>
<tr>
<th>Risk</th>
<th>Without Vault</th>
<th>With This Architecture</th>
</tr>
</thead>
<tbody><tr>
<td>Hardcoded secrets in git</td>
<td>Permanent exposure</td>
<td>Nothing static to commit</td>
</tr>
<tr>
<td>Database password theft</td>
<td>Single credential compromise</td>
<td>Credentials expire in 1 hour</td>
</tr>
<tr>
<td>IAM key rotation failure</td>
<td>Manual process, often skipped</td>
<td>Automatic, per-request generation</td>
</tr>
<tr>
<td>Credential scope creep</td>
<td>Permissions accumulate over time</td>
<td>Per-request, policy-defined scope</td>
</tr>
<tr>
<td>Attribution on access events</td>
<td>Often none</td>
<td>Full audit log per lease</td>
</tr>
<tr>
<td>Pod restart unsealing</td>
<td>Manual operator intervention</td>
<td>KMS auto-unseal, zero downtime</td>
</tr>
</tbody></table>
<hr />
<h2>How This Helps Companies</h2>
<p>The healthcare company that paid a $6.85 million HIPAA fine was not technically incompetent. They were architecturally unprepared for the risk profile of static credentials. The access key that caused the breach was created by a developer solving a specific problem. It worked. Nobody thought about what happened if it was found.</p>
<p>Vault does not make developers more careful. <strong>It makes the dangerous pattern impossible.</strong> When credentials expire in an hour, there is nothing to find. When root database passwords are unknown even to the team that set them up, there is nothing to steal. When every credential request is logged with identity, timestamp, and lease ID, there is a full audit trail for every access event.</p>
<hr />
<h2>Full Source Code</h2>
<p>The complete code is available at:</p>
<p>🔗 <a href="https://github.com/Eaglewings966/hashicorp-vault-eks"><strong>github.com/Eaglewings966/hashicorp-vault-eks</strong></a></p>
<p>When you are done testing, clean up your environment:</p>
<pre><code class="language-plaintext">helm uninstall vault -n vault
kubectl delete namespace demo-app vault --ignore-not-found=true
cd terraform &amp;&amp; terraform destroy --auto-approve
eksctl delete cluster --name vault-eks-cluster --region us-east-1
</code></pre>
<hr />
<h2>What's Next</h2>
<p>Project 9 in this series covers <strong>Container Image Supply Chain Security</strong> — Cosign keyless signing, Syft SBOM generation, and Kyverno admission control enforcing signatures before any unsigned image reaches the cluster.</p>
<hr />
<p><em>Emmanuel Ubani is a Cloud and DevOps Engineer based in Lagos, Nigeria. He writes about real infrastructure builds, career transitions into tech, and lessons learned the hard way.</em></p>
<ul>
<li><p>🔗 <a href="https://linkedin.com/in/ubaniemmanuel">LinkedIn</a></p>
</li>
<li><p>💻 <a href="https://github.com/Eaglewings966">GitHub</a></p>
</li>
<li><p>✍️ <a href="https://emmanuelubani.hashnode.dev">Hashnode</a></p>
</li>
<li><p>📝 <a href="https://medium.com/@emmaubani966">Medium</a></p>
</li>
<li><p>🌐 <a href="https://ops-run.lovable.app">Portfolio</a></p>
</li>
</ul>
]]></content:encoded></item><item><title><![CDATA[I Built an Enterprise DevSecOps Pipeline That Would Have Prevented a $4.2 Million Regulatory Fine — Here Is Exactly How It Works ]]></title><description><![CDATA[Trivy, Checkov, Semgrep, Snyk, OWASP ZAP, AWS ECR, and GitHub Actions — security at every stage of the delivery lifecycle]]></description><link>https://emmanuelubani.hashnode.dev/i-built-an-enterprise-devsecops-pipeline-that-would-have-prevented-a-4-2-million-regulatory-fine-here-is-exactly-how-it-works</link><guid isPermaLink="true">https://emmanuelubani.hashnode.dev/i-built-an-enterprise-devsecops-pipeline-that-would-have-prevented-a-4-2-million-regulatory-fine-here-is-exactly-how-it-works</guid><category><![CDATA[AWS]]></category><category><![CDATA[Cloud Computing]]></category><category><![CDATA[DevSecOps]]></category><category><![CDATA[Security]]></category><category><![CDATA[Terraform]]></category><category><![CDATA[software development]]></category><dc:creator><![CDATA[Ubani Obiajulum Emmanuel]]></dc:creator><pubDate>Thu, 16 Apr 2026 11:13:19 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/69a6e08356428acc6fe423a5/d5daf54d-e58d-49f8-803e-1163a998e572.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<h2>The Breach That Cost $4.2 Million</h2>
<p>In September 2021, a fintech company in Singapore deployed a Docker image to production. The image contained a critical vulnerability in its base OS layer. The vulnerability had been publicly known for three weeks. There was a patch available. Nobody had scanned the image.</p>
<p>The company had a CI pipeline. It ran unit tests. It checked code coverage. It validated formatting. <strong>It did not check the security of the container it was building.</strong></p>
<p>Six weeks after the deployment, an attacker exploited the vulnerability. The breach exposed 2.3 million customer records. The regulatory fine was $4.2 million. The reputational damage was irreversible.</p>
<p>A container vulnerability scanner in the CI pipeline would have blocked that deployment on the day the image was built. The scan takes under two minutes. The difference between running it and not running it was <strong>$4.2 million and 2.3 million compromised customers</strong>.</p>
<p>This is the problem I built a solution for.</p>
<img src="https://cdn.hashnode.com/uploads/covers/69a6e08356428acc6fe423a5/459b61ac-8c41-4f9d-abce-81a0ee510960.png" alt="" style="display:block;margin:0 auto" />

<img src="https://cdn.hashnode.com/uploads/covers/69a6e08356428acc6fe423a5/70af4344-b0b8-43dd-a6d5-2db2017cc276.png" alt="" style="display:block;margin:0 auto" />

<img src="https://cdn.hashnode.com/uploads/covers/69a6e08356428acc6fe423a5/cb1a7c9a-9181-413c-897e-a3e9d348e754.png" alt="" style="display:block;margin:0 auto" />

<hr />
<h2>Who I Am and Why Security Matters to Me</h2>
<p>I am <strong>Emmanuel Ubani</strong>, a Cloud and DevOps Engineer based in Lagos, Nigeria. Before I transitioned into tech in January 2024, I was a zoo Tourist Guard volunteer, an automotive technician apprentice, and a beauty store manager.</p>
<p>I tell you this because I came into cloud engineering understanding what it means to operate with consequences. In my previous work, mistakes had real costs. A misdiagnosed engine problem meant a customer paying for repairs that did not fix anything.</p>
<p>The mindset I carry into DevSecOps is the same. A missed vulnerability in a pipeline is not a theoretical risk. <strong>It is a timer counting down to an incident.</strong></p>
<p>The pipeline I am describing in this article implements security scanning at every stage of the software delivery lifecycle. Source code, dependencies, infrastructure, container image, and running application. Five different attack surfaces. Five different tools. One integrated pipeline.</p>
<hr />
<h2>What Shift Left Actually Means</h2>
<p>Before I walk through the implementation, I want to explain the concept of shift left in terms that make the business case clear for both engineers and non-technical readers.</p>
<p>Security vulnerabilities exist in software at every stage of the delivery process. They can be found in source code, in the libraries the code depends on, in the infrastructure configuration, in the container image that packages the code, or in the running application itself.</p>
<p>The earlier in the pipeline you find a vulnerability, the cheaper it is to fix:</p>
<table>
<thead>
<tr>
<th>Where Found</th>
<th>Cost to Fix</th>
</tr>
</thead>
<tbody><tr>
<td>Source code</td>
<td>One commit</td>
</tr>
<tr>
<td>Built image</td>
<td>New build cycle</td>
</tr>
<tr>
<td>Production</td>
<td>Incident response + hotfix + post-mortem + potential regulatory disclosure</td>
</tr>
</tbody></table>
<p><strong>Shift left means moving security checks as early in the pipeline as possible.</strong> The fintech company in Singapore had security checks at zero stages. This pipeline has security checks at five stages before the image ever reaches production.</p>
<hr />
<h2>What I Built</h2>
<p>An eight-stage DevSecOps pipeline using GitHub Actions that scans every attack surface before allowing an image to reach the container registry. Everything provisioned with Terraform. OIDC authentication to AWS — no static credentials anywhere.</p>
<table>
<thead>
<tr>
<th>Stage</th>
<th>Tool</th>
<th>What It Catches</th>
</tr>
</thead>
<tbody><tr>
<td><strong>Stage 1</strong> — SAST</td>
<td>Semgrep</td>
<td>Source code flaws, hardcoded secrets, OWASP Top 10</td>
</tr>
<tr>
<td><strong>Stage 2</strong> — SCA</td>
<td>Snyk</td>
<td>Known CVEs in npm dependencies</td>
</tr>
<tr>
<td><strong>Stage 3</strong> — IaC Scan</td>
<td>Checkov</td>
<td>Terraform misconfigurations, overly permissive IAM</td>
</tr>
<tr>
<td><strong>Stage 4</strong> — Build</td>
<td>Docker</td>
<td>Multi-stage hardened image, non-root user, health check</td>
</tr>
<tr>
<td><strong>Stage 5</strong> — Image Scan</td>
<td>Trivy</td>
<td>OS and library CVEs — blocks on CRITICAL and HIGH</td>
</tr>
<tr>
<td><strong>Stage 6</strong> — Push</td>
<td>AWS ECR</td>
<td>Image pushed only after Trivy clears it</td>
</tr>
<tr>
<td><strong>Stage 7</strong> — DAST</td>
<td>OWASP ZAP</td>
<td>Missing security headers, exposed endpoints, runtime flaws</td>
</tr>
<tr>
<td><strong>Stage 8</strong> — Summary</td>
<td>GitHub PR Comment</td>
<td>Full security posture posted to every pull request</td>
</tr>
</tbody></table>
<hr />
<h2>Architecture Decisions</h2>
<h3>Why Trivy Blocks on CRITICAL and HIGH Only</h3>
<p>The blocking threshold is the most consequential policy decision in a DevSecOps pipeline. Get it wrong in one direction and the pipeline becomes a rubber stamp. Get it wrong in the other direction and engineers bypass it.</p>
<ul>
<li><p><strong>CRITICAL (9.0–10.0 CVSS):</strong> Public exploits exist, actively targeted, patching is non-negotiable</p>
</li>
<li><p><strong>HIGH (7.0–8.9 CVSS):</strong> Significant risk with exploits that are likely or proven</p>
</li>
</ul>
<p>Blocking on CRITICAL and HIGH is the threshold used at Stripe, Monzo, and Cloudflare. MEDIUM and LOW findings are tracked in a security backlog and addressed on a scheduled remediation cycle.</p>
<blockquote>
<p>⚠️ <strong>If you block on MEDIUM in a production environment, engineers will look for ways around the pipeline within a week.</strong> A policy that gets bypassed is worse than no policy because it creates false confidence.</p>
</blockquote>
<hr />
<h3>Why OIDC Instead of Static AWS Credentials</h3>
<p>Static AWS access keys stored in GitHub Secrets are a persistent attack surface. They must be rotated. They can be leaked through log output. They can be extracted through a compromised runner. They have a lifespan measured in months or years.</p>
<p>OIDC federation works differently:</p>
<ul>
<li><p>GitHub Actions requests a <strong>short-lived token</strong> from AWS's OIDC provider for each job run</p>
</li>
<li><p>The token is valid only for the duration of that specific job</p>
</li>
<li><p>No long-lived credentials to store, rotate, or protect</p>
</li>
<li><p>The IAM role is scoped to the specific repository — only jobs from <code>Eaglewings966/aws-devsecops-pipeline</code> can assume the role</p>
</li>
<li><p>A leaked token from one repository <strong>cannot</strong> be used to access another repository's AWS resources</p>
</li>
</ul>
<hr />
<h3>Why ECR Image Tag Immutability</h3>
<p>Mutable image tags are a silent security risk. If the <code>latest</code> tag can be overwritten, an attacker who gains push access to the registry can replace a known-good image with a malicious one without changing the tag. The deployment pulls <code>latest</code>, gets the malicious image, and there is no detectable deployment event to investigate.</p>
<p>Immutable tags make this attack impossible. Once an image is pushed with a specific tag, that tag cannot be reused. Any attempt to overwrite it fails at the API level.</p>
<hr />
<h3>Why DAST After Push Rather Than During Build</h3>
<p>OWASP ZAP requires a running application. Running the container from the ECR image after push validates that the <strong>same image that will be deployed to production</strong> is the one being tested. Not a locally built version. Not a development build with different environment variables. The exact production image.</p>
<img src="https://cdn.hashnode.com/uploads/covers/69a6e08356428acc6fe423a5/5d8cf594-4c01-4857-9b3f-27edace26a8e.png" alt="" style="display:block;margin:0 auto" />

<hr />
<h2>Implementation</h2>
<h3>Step 1 — Provision ECR and OIDC with Terraform</h3>
<p>The Terraform configuration creates three resources: an ECR repository with immutable tags and scan on push enabled, an IAM OIDC identity provider for GitHub Actions, and an IAM role that GitHub Actions assumes via OIDC with ECR push permissions.</p>
<p>Here is the OIDC provider and IAM role:</p>
<pre><code class="language-javascript">resource "aws_iam_openid_connect_provider" "github" {
  url             = "https://token.actions.githubusercontent.com"
  client_id_list  = ["sts.amazonaws.com"]
  thumbprint_list = [
    "6938fd4d98bab03faadb97b34396831e3780aea1",
    "1c58a3a8518e8759bf075b76b750d4f2df264fcd"
  ]
}

resource "aws_iam_role" "github_actions" {
  name = "devsecops-pipeline-github-actions-role"

  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect = "Allow"
      Principal = { Federated = aws_iam_openid_connect_provider.github.arn }
      Action    = "sts:AssumeRoleWithWebIdentity"
      Condition = {
        StringEquals = {
          "token.actions.githubusercontent.com:aud" = "sts.amazonaws.com"
        }
        StringLike = {
          "token.actions.githubusercontent.com:sub" =
            "repo:Eaglewings966/aws-devsecops-pipeline:*"
        }
      }
    }]
  })
}
</code></pre>
<p>The <code>StringLike</code> condition on the subject claim scopes the role to a specific repository. This is what prevents other GitHub repositories from assuming this role even if they obtain a valid OIDC token.</p>
<pre><code class="language-plaintext">cd terraform
terraform init
terraform apply --auto-approve
terraform output github_secrets_to_set
</code></pre>
<hr />
<img src="https://cdn.hashnode.com/uploads/covers/69a6e08356428acc6fe423a5/a399650b-9295-4a32-8e51-a775179f88d1.png" alt="" style="display:block;margin:0 auto" />

<img src="https://cdn.hashnode.com/uploads/covers/69a6e08356428acc6fe423a5/d86093a1-feb1-4d64-bd2b-b8d57da0f6af.png" alt="" style="display:block;margin:0 auto" />

<img src="https://cdn.hashnode.com/uploads/covers/69a6e08356428acc6fe423a5/1adc8b9b-4135-4742-93a6-1a7aa2a0463a.png" alt="" style="display:block;margin:0 auto" />

<hr />
<h3>Step 2 — The Pipeline Trigger and Permissions</h3>
<pre><code class="language-yaml">on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]

permissions:
  contents: read
  security-events: write
  id-token: write
  pull-requests: write
</code></pre>
<p>The <code>id-token: write</code> permission is what enables OIDC authentication. Without it, GitHub Actions cannot request the OIDC token needed to assume the AWS IAM role. The <code>security-events: write</code> permission allows SARIF results to be uploaded to the GitHub Security tab.</p>
<hr />
<h3>Step 3 — Semgrep SAST</h3>
<pre><code class="language-yaml">sast-semgrep:
  name: SAST — Semgrep Code Scanning
  runs-on: ubuntu-latest

  steps:
    - uses: actions/checkout@v4

    - name: Run Semgrep SAST scan
      uses: semgrep/semgrep-action@v1
      with:
        config: &gt;-
          p/nodejs
          p/security-audit
          p/owasp-top-ten
          p/secrets
        generateSarif: "1"
      env:
        SEMGREP_APP_TOKEN: ${{ secrets.SEMGREP_APP_TOKEN }}

    - name: Upload SARIF results
      uses: github/codeql-action/upload-sarif@v3
      if: always()
      with:
        sarif_file: semgrep.sarif
        category: semgrep-sast
</code></pre>
<blockquote>
<p>⚠️ The <code>if: always()</code> on the SARIF upload is critical. If Semgrep finds a vulnerability and fails the job, the upload step must still run to post the findings to the Security tab. Without <code>always()</code>, a failed scan produces <strong>no visible output</strong> for the engineering team.</p>
</blockquote>
<img src="https://cdn.hashnode.com/uploads/covers/69a6e08356428acc6fe423a5/6c6651ed-c499-4249-a45f-806593424790.png" alt="" style="display:block;margin:0 auto" />

<hr />
<h3>Step 4 — Trivy Image Scan With Blocking Policy</h3>
<pre><code class="language-yaml">image-scan-trivy:
  name: Image Scan — Trivy CVE Scanning
  needs: build

  steps:
    - name: Run Trivy — CRITICAL and HIGH block
      uses: aquasecurity/trivy-action@master
      with:
        image-ref: \({{ env.REGISTRY }}/\){{ env.ECR_REPOSITORY }}:${{ env.IMAGE_TAG }}
        format: sarif
        output: trivy-results.sarif
        severity: CRITICAL,HIGH
        exit-code: '1'
        ignore-unfixed: true
        vuln-type: os,library

    - name: Run Trivy — full report all severities
      uses: aquasecurity/trivy-action@master
      if: always()
      with:
        image-ref: \({{ env.REGISTRY }}/\){{ env.ECR_REPOSITORY }}:${{ env.IMAGE_TAG }}
        format: table
        severity: CRITICAL,HIGH,MEDIUM,LOW
        exit-code: '0'
</code></pre>
<p>Two Trivy runs are intentional:</p>
<table>
<thead>
<tr>
<th>Run</th>
<th>Severity</th>
<th>Exit Code</th>
<th>Purpose</th>
</tr>
</thead>
<tbody><tr>
<td>First</td>
<td>CRITICAL, HIGH</td>
<td><code>1</code></td>
<td><strong>Blocks the pipeline</strong> on serious findings</td>
</tr>
<tr>
<td>Second</td>
<td>ALL severities</td>
<td><code>0</code></td>
<td>Full visibility report — never blocks</td>
</tr>
</tbody></table>
<p>This gives the security team complete visibility into MEDIUM and LOW findings without stopping the deployment for issues that don't warrant it.</p>
<img src="https://cdn.hashnode.com/uploads/covers/69a6e08356428acc6fe423a5/57c550d6-9e9b-439d-be87-b7d8cc931105.png" alt="" style="display:block;margin:0 auto" />

<img src="https://cdn.hashnode.com/uploads/covers/69a6e08356428acc6fe423a5/060d5f08-1a80-465c-9719-6ac2b50cef36.png" alt="" style="display:block;margin:0 auto" />

<hr />
<h3>Step 5 — OWASP ZAP DAST</h3>
<pre><code class="language-yaml">dast-owasp-zap:
  needs: push-to-ecr

  steps:
    - name: Start application container
      run: |
        docker run -d \
          --name target-app \
          -p 3000:3000 \
          \({{ env.REGISTRY }}/\){{ env.ECR_REPOSITORY }}:${{ env.IMAGE_TAG }}

        for i in $(seq 1 30); do
          if curl -sf http://localhost:3000/health; then
            echo "Application is ready"; break
          fi
          sleep 2
        done

    - name: Run OWASP ZAP baseline scan
      uses: zaproxy/action-baseline@v0.10.0
      with:
        target: 'http://localhost:3000'
        fail_action: false
</code></pre>
<p>The readiness loop polls the health endpoint before starting the ZAP scan. A scan against an application that is still starting up produces false positives from connection refused errors. Waiting for the health check to return <code>200</code> before scanning ensures ZAP is testing the <strong>running application</strong>, not the startup sequence.</p>
<img src="https://cdn.hashnode.com/uploads/covers/69a6e08356428acc6fe423a5/f50590cb-a7fc-4659-ae73-ee2bef752a33.png" alt="" style="display:block;margin:0 auto" />

<hr />
<h2>What Broke During This Build</h2>
<h3>Debugging Story 1 — OIDC Token Permission Missing</h3>
<p>After setting up the OIDC provider and IAM role in Terraform and configuring the GitHub Actions workflow, the AWS authentication step failed with:</p>
<pre><code class="language-plaintext">Error: Credentials could not be loaded, please check your
action inputs: Could not load credentials from any providers
</code></pre>
<p>The workflow had <code>permissions: contents: read</code> at the top level but was missing <code>id-token: write</code>. Without <code>id-token: write</code>, GitHub Actions does not generate the OIDC token that the AWS action needs to assume the IAM role. Adding the permission to the workflow permissions block resolved it immediately.</p>
<hr />
<h3>Debugging Story 2 — Trivy Scanning a Non-Existent Image</h3>
<p>The Trivy scan job ran after the build job but scanned an image that was not available in the runner environment. The build job created the image but the Trivy job ran on a fresh runner with no local images.</p>
<p>The fix was uploading the Docker image as a GitHub Actions artifact after the build job and downloading it in the Trivy job before scanning. The artifact upload and download added about ninety seconds to the pipeline but made the image available across jobs without requiring an intermediate registry.</p>
<hr />
<h3>Debugging Story 3 — Checkov Failing on Valid Terraform</h3>
<p>Checkov flagged the ECR repository for missing Lambda environment variable encryption — a rule that does not apply to ECR resources. The check was a false positive from Checkov applying a Lambda-specific rule to a non-Lambda resource.</p>
<p>The fix was adding the specific check ID to the skip list in the Checkov action configuration with a documented reason.</p>
<blockquote>
<p>💡 <strong>Every skipped check must be documented with why it was accepted.</strong> A Checkov skip without documentation is a security debt that future engineers cannot evaluate.</p>
</blockquote>
<hr />
<h2>Security Posture Improvements at a Glance</h2>
<table>
<thead>
<tr>
<th>Capability</th>
<th>Without This Pipeline</th>
<th>With This Pipeline</th>
</tr>
</thead>
<tbody><tr>
<td>Source code vulnerabilities</td>
<td>Found in production</td>
<td>Caught before build</td>
</tr>
<tr>
<td>Dependency CVEs</td>
<td>Discovered on breach</td>
<td>Blocked at SCA stage</td>
</tr>
<tr>
<td>IaC misconfigurations</td>
<td>Deployed as infrastructure</td>
<td>Blocked before apply</td>
</tr>
<tr>
<td>Container image CVEs</td>
<td>Running in production</td>
<td>Blocked before push</td>
</tr>
<tr>
<td>Runtime vulnerabilities</td>
<td>Discovered by attackers</td>
<td>Caught by DAST</td>
</tr>
<tr>
<td>Security visibility</td>
<td>None</td>
<td>SARIF in GitHub Security tab</td>
</tr>
<tr>
<td>Credential management</td>
<td>Static keys in secrets</td>
<td>OIDC short-lived tokens</td>
</tr>
</tbody></table>
<hr />
<h2>Production Gap Analysis</h2>
<p>This pipeline covers the five most critical security scan layers. A production implementation at enterprise scale would additionally require the following.</p>
<p><strong>Software Bill of Materials (SBOM) generation</strong> using Syft or Grype creates a machine-readable inventory of every package in the container image. SBOM generation is becoming a regulatory requirement in several jurisdictions and is required for US federal software procurement under Executive Order 14028.</p>
<p><strong>Image signing with Cosign</strong> and verification at deployment time ensures that only images signed by the CI pipeline can be deployed to the Kubernetes cluster. A Gatekeeper admission webhook enforces this at the cluster level.</p>
<p><strong>Defect Dojo</strong> or a similar vulnerability management platform aggregates SARIF results from multiple pipelines, tracks vulnerability state over time, assigns remediation ownership, and measures mean time to remediation. Without aggregation, security findings from individual pipelines have no organizational memory.</p>
<p><strong>GitLeaks or TruffleHog</strong> as dedicated secret scanners provide deeper secret detection than Semgrep's secret rules. They detect secrets in git history in addition to current code — catching credentials that were committed and deleted but are still visible in the repository history.</p>
<hr />
<h2>How This Helps Companies</h2>
<p>The fintech company in Singapore did not lack engineering talent. They lacked an automated security gate in their delivery pipeline. The vulnerability they shipped was in a public CVE database. A scanner would have found it in ninety seconds.</p>
<p>The pipeline in this repository implements the security controls that prevent that scenario. Not as a one-time audit. <strong>As an automated gate that runs on every push and every pull request.</strong></p>
<p>These skills apply directly to any engineering team that ships containerized applications and wants to move from reactive security — finding vulnerabilities after they are exploited — to proactive security — catching them before they are deployed.</p>
<hr />
<h2>Full Source Code</h2>
<p>The complete code is available at:</p>
<p>🔗 <a href="https://github.com/Eaglewings966/aws-devsecops-pipeline"><strong>github.com/Eaglewings966/aws-devsecops-pipeline</strong></a></p>
<p>When you are done testing, clean up your environment:</p>
<pre><code class="language-javascript">aws ecr batch-delete-image \
  --repository-name devops-demo-app \
  --image-ids "$(aws ecr list-images \
    --repository-name devops-demo-app \
    --query 'imageIds[*]' --output json)" \
  --region us-east-1

cd terraform &amp;&amp; terraform destroy --auto-approve
</code></pre>
<hr />
<p><em>Emmanuel Ubani is a Cloud and DevOps Engineer based in Lagos, Nigeria. He writes about real infrastructure builds, career transitions into tech, and lessons learned the hard way.</em></p>
<ul>
<li><p>🔗 <a href="https://linkedin.com/in/ubaniemmanuel">LinkedIn</a></p>
</li>
<li><p>💻 <a href="https://github.com/Eaglewings966">GitHub</a></p>
</li>
<li><p>✍️ <a href="https://emmanuelubani.hashnode.dev">Hashnode</a></p>
</li>
<li><p>📝 <a href="https://medium.com/@emmaubani966">Medium</a></p>
</li>
<li><p>🌐 <a href="https://ops-run.lovable.app">Portfolio</a></p>
</li>
</ul>
]]></content:encoded></item><item><title><![CDATA[How I Built an AWS Multi-Account Landing Zone That Makes
Security Misconfigurations Architecturally Impossible]]></title><description><![CDATA[AWS Organizations, Service Control Policies, GuardDuty, CloudTrail, Config, and centralized logging — all provisioned with Terraform]]></description><link>https://emmanuelubani.hashnode.dev/how-i-built-an-aws-multi-account-landing-zone-that-makes-security-misconfigurations-architecturally-impossible</link><guid isPermaLink="true">https://emmanuelubani.hashnode.dev/how-i-built-an-aws-multi-account-landing-zone-that-makes-security-misconfigurations-architecturally-impossible</guid><category><![CDATA[Cloud Computing]]></category><category><![CDATA[AWS]]></category><category><![CDATA[Devops]]></category><category><![CDATA[Security]]></category><dc:creator><![CDATA[Ubani Obiajulum Emmanuel]]></dc:creator><pubDate>Sun, 12 Apr 2026 14:13:28 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/69a6e08356428acc6fe423a5/3bac0136-16e9-4124-b693-6360821fee31.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<h3>The Security Audit That Stopped a $12 Million Fundraise — And the Terraform Architecture That Prevents It</h3>
<p>In the spring of 2022, a healthtech startup in Amsterdam was three weeks away from closing a $12 million Series B round.</p>
<p>The lead investor required a security audit as a condition of closing. The startup engaged an external auditor for a two-week AWS security review. What the auditor found was not sophisticated. It was not the result of a targeted attack. It was the result of years of moving fast without governance.</p>
<p>The findings read like a checklist of everything that goes wrong when engineering teams manage AWS accounts without centralized security controls:</p>
<ul>
<li><p>Root account credentials had been used to create access keys twelve times in the previous eighteen months</p>
</li>
<li><p>CloudTrail had been disabled in the staging account to reduce logging costs and never re-enabled</p>
</li>
<li><p>Three IAM users had programmatic access keys that had not been rotated in over two years</p>
</li>
<li><p>GuardDuty was not enabled in any of the four accounts</p>
</li>
<li><p>Resources had been provisioned in six different AWS regions with no inventory of what was running where</p>
</li>
<li><p>No centralized audit log. No compliance monitoring. No threat detection.</p>
</li>
</ul>
<p>The investor pulled the term sheet two days before closing. The reason cited in the withdrawal letter was <strong>inadequate security posture for a company handling protected health information</strong>.</p>
<p>The engineering team spent the next four months implementing the controls that should have been in place from day one. The company raised at a lower valuation in a subsequent round nine months later.</p>
<p>Every finding in that audit is preventable with the architecture I am describing in this article. Not partially preventable. <strong>Completely preventable.</strong> With Terraform code that deploys in under fifteen minutes.</p>
<img src="https://cdn.hashnode.com/uploads/covers/69a6e08356428acc6fe423a5/881e0097-5566-461f-aa1d-d3231c662c62.png" alt="" style="display:block;margin:0 auto" />

<hr />
<h2>Who I Am and Why This Architecture Matters to Me</h2>
<p>I am <strong>Emmanuel Ubani</strong>, a Cloud and DevOps Engineer based in Lagos, Nigeria. I transitioned into tech in January 2024 after working as a zoo volunteer, an automotive technician apprentice, and a beauty store manager.</p>
<p>I tell you this because I came into cloud engineering without the luxury of learning security practices gradually through a senior engineer's mentorship. I had to understand from first principles why these controls exist, what they prevent, and what it costs when they are absent.</p>
<p>The architecture in this article is not theoretical. It is the practical implementation of the controls that enterprises require before they sign contracts, investors require before they fund, and auditors require before they certify. I built it, I understand every line of it, and I can explain the reasoning behind every decision.</p>
<hr />
<h2>What This Landing Zone Builds</h2>
<p>A complete multi-account governance foundation on AWS with two accounts, six security controls, and zero ability to disable any of them from within a member account.</p>
<table>
<thead>
<tr>
<th>Component</th>
<th>Details</th>
</tr>
</thead>
<tbody><tr>
<td><strong>AWS Organization</strong></td>
<td>Two OUs — <code>Workloads</code> and <code>Security</code></td>
</tr>
<tr>
<td><strong>Service Control Policies</strong></td>
<td>Six SCPs attached to the Workloads OU</td>
</tr>
<tr>
<td><strong>CloudTrail</strong></td>
<td>Organization-level trail with log file validation</td>
</tr>
<tr>
<td><strong>GuardDuty</strong></td>
<td>Full detection coverage with SNS alerting</td>
</tr>
<tr>
<td><strong>AWS Config</strong></td>
<td>Six compliance rules with continuous evaluation</td>
</tr>
<tr>
<td><strong>S3 Logging Bucket</strong></td>
<td>AES256 encryption, versioning, lifecycle policy</td>
</tr>
</tbody></table>
<h3>The Six Service Control Policies</h3>
<ol>
<li><p><strong>Deny root account usage</strong> in all member accounts</p>
</li>
<li><p><strong>Deny disabling CloudTrail</strong> — cannot be stopped from inside the account</p>
</li>
<li><p><strong>Deny disabling GuardDuty</strong> — detector cannot be deleted</p>
</li>
<li><p><strong>Deny leaving the Organization</strong> — accounts cannot escape governance</p>
</li>
<li><p><strong>Restrict activity to approved regions</strong> — no shadow infrastructure</p>
</li>
<li><p><strong>Deny sensitive IAM operations without MFA</strong> — credential abuse protection</p>
</li>
</ol>
<img src="https://cdn.hashnode.com/uploads/covers/69a6e08356428acc6fe423a5/87c69a63-fb3d-4bba-aa3a-f9f25e4723c3.png" alt="" style="display:block;margin:0 auto" />

<hr />
<h2>Understanding Service Control Policies</h2>
<p>Before I walk through the implementation, I want to make sure the concept of Service Control Policies is clear for every reader at every experience level.</p>
<p>An <strong>IAM policy</strong> controls what an identity can do. If you have an IAM admin in an account, they can modify their own IAM policies. They can grant themselves new permissions. They can remove restrictions that their organization put in place. IAM policies are enforced within an account but can be changed by anyone with IAM admin access.</p>
<p>An <strong>SCP</strong> is different in a fundamental way. An SCP is applied at the Organizational Unit level by the management account administrator. It sets the maximum permissions boundary for every account in that OU. An IAM admin in a member account cannot modify the SCP. They cannot remove it. They cannot create a permission that the SCP denies.</p>
<blockquote>
<p>💡 <strong>Think of it like this:</strong> An IAM policy is the rules posted on the wall inside a room. Anyone in the room with enough authority can change the rules. An SCP is the architecture of the building. The people inside cannot change the architecture of the building they are standing in.</p>
</blockquote>
<p>This distinction is why SCPs are the foundation of any serious multi-account security architecture. They establish controls that <strong>survive administrative compromise</strong>. Even if an attacker gains IAM admin access to a member account, they cannot disable CloudTrail, cannot disable GuardDuty, cannot move the account out of the Organization's governance model.</p>
<p><strong>The SCP holds.</strong></p>
<img src="https://cdn.hashnode.com/uploads/covers/69a6e08356428acc6fe423a5/9516d285-2a5c-401f-83f5-f8c30cb6d969.png" alt="" style="display:block;margin:0 auto" />

<hr />
<h2>Architecture Decisions</h2>
<h3>Why Organization-Level CloudTrail</h3>
<p>A per-account CloudTrail can be disabled by an admin in that account. The SCP that prevents <code>cloudtrail:StopLogging</code> helps, but the defense-in-depth approach is to also centralize the trail at the organization level.</p>
<p>An organization-level trail is created in the management account. It captures API activity from all current and future member accounts automatically. New accounts that join the Organization are immediately covered without any additional configuration. And because the trail resource lives in the management account, member account admins have no access to modify or delete it.</p>
<p>The trail also enables <strong>log file validation</strong>. Every log file gets a cryptographic hash. If any log file is tampered with after delivery, the validation process detects the corruption. For compliance and forensics purposes, this gives you provable log integrity.</p>
<h3>Why GuardDuty With All Detections Enabled</h3>
<p>The base GuardDuty configuration detects threats from VPC flow logs, DNS logs, and CloudTrail events. This covers network-based and credential-based attacks. But it misses two increasingly common attack vectors:</p>
<ul>
<li><p><strong>S3 data exfiltration</strong> through unusual API patterns — detected only when S3 protection is enabled</p>
</li>
<li><p><strong>Kubernetes cluster compromise</strong> through pod privilege escalation — detected only when Kubernetes audit log analysis is enabled</p>
</li>
<li><p><strong>Malware on EC2 instances</strong> that exfiltrates data through disk-level operations — detected only when malware protection is enabled</p>
</li>
</ul>
<p>Enabling all three increases the GuardDuty monthly cost slightly. In an account handling any form of sensitive data, the cost of a missed detection is orders of magnitude higher.</p>
<h3>Why Six Config Rules as the Baseline</h3>
<p>These six rules appear in the CIS AWS Foundations Benchmark, the AWS Well-Architected Framework security pillar, and every enterprise cloud security questionnaire I have reviewed.</p>
<p>They are not comprehensive coverage. They are the <strong>minimum</strong> that any organization should have before they describe their AWS environment as having compliance monitoring.</p>
<table>
<thead>
<tr>
<th>Rule</th>
<th>Addresses</th>
</tr>
</thead>
<tbody><tr>
<td>MFA for console users</td>
<td>Credential compromise</td>
</tr>
<tr>
<td>Root MFA</td>
<td>Root account takeover</td>
</tr>
<tr>
<td>CloudTrail enabled</td>
<td>Audit capability verification</td>
</tr>
<tr>
<td>GuardDuty enabled</td>
<td>Detection capability verification</td>
</tr>
<tr>
<td>S3 public access</td>
<td>Data exposure</td>
</tr>
<tr>
<td>EBS encryption</td>
<td>Data at rest exposure</td>
</tr>
</tbody></table>
<hr />
<h2>Implementation</h2>
<h3>Step 1 — Create the Organization Structure</h3>
<pre><code class="language-javascript">resource "aws_organizations_organization" "main" {
  aws_service_access_principals = [
    "cloudtrail.amazonaws.com",
    "config.amazonaws.com",
    "guardduty.amazonaws.com",
    "sso.amazonaws.com"
  ]

  feature_set = "ALL"

  enabled_policy_types = [
    "SERVICE_CONTROL_POLICY",
    "TAG_POLICY"
  ]
}

resource "aws_organizations_organizational_unit" "workloads" {
  name      = "Workloads"
  parent_id = aws_organizations_organization.main.roots[0].id
}

resource "aws_organizations_account" "workload" {
  name      = var.member_account_name
  email     = var.member_account_email
  parent_id = aws_organizations_organizational_unit.workloads.id
}
</code></pre>
<p>The <code>aws_service_access_principals</code> block enables trusted access for each service that will operate at the organization level. Without this, CloudTrail and Config cannot write to centralized resources in the management account from member accounts.</p>
<p><code>feature_set = "ALL"</code> enables both consolidated billing and all governance features including SCPs. Without this, SCP policy types cannot be enabled.</p>
<hr />
<h3>Step 2 — Deploy the Service Control Policies</h3>
<p>Here is the most critical SCP — the one that prevents any root account activity in member accounts:</p>
<pre><code class="language-javascript">resource "aws_organizations_policy" "deny_root_usage" {
  name = "landing-zone-deny-root-usage"
  type = "SERVICE_CONTROL_POLICY"

  content = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Sid    = "DenyRootAccountUsage"
      Effect = "Deny"
      Action = ["*"]
      Resource = ["*"]
      Condition = {
        StringLike = {
          "aws:PrincipalArn" = ["arn:aws:iam::*:root"]
        }
      }
    }]
  })
}

resource "aws_organizations_policy_attachment" "deny_root_usage" {
  policy_id = aws_organizations_policy.deny_root_usage.id
  target_id = aws_organizations_organizational_unit.workloads.id
}
</code></pre>
<p>The SCP uses a wildcard <code>Action</code> with a <code>Condition</code> that matches only the root principal ARN pattern. Every API call made from the root account in any member account under the Workloads OU will be denied. Every call. No exceptions.</p>
<p>Here is the region restriction SCP with the critical global service exemptions:</p>
<pre><code class="language-javascript">resource "aws_organizations_policy" "deny_non_approved_regions" {
  name = "landing-zone-deny-non-approved-regions"
  type = "SERVICE_CONTROL_POLICY"

  content = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Sid    = "DenyNonApprovedRegions"
      Effect = "Deny"
      NotAction = [
        "iam:*", "organizations:*", "route53:*",
        "budgets:*", "waf:*", "cloudfront:*",
        "sts:*", "support:*", "trustedadvisor:*"
      ]
      Resource = ["*"]
      Condition = {
        StringNotEquals = {
          "aws:RequestedRegion" = ["us-east-1", "us-west-2"]
        }
      }
    }]
  })
}
</code></pre>
<blockquote>
<p>⚠️ <strong>The</strong> <code>NotAction</code> <strong>pattern is essential here.</strong> Using <code>Action</code> with a <code>Deny</code> would also deny IAM, Route53, and CloudFront operations because those services are global and do not operate within a specific region. <code>NotAction</code> says: deny everything <em>except</em> this list of global services when outside the approved regions. Omitting these exemptions breaks IAM operations in the account.</p>
</blockquote>
<img src="https://cdn.hashnode.com/uploads/covers/69a6e08356428acc6fe423a5/99d8ec1d-32c3-4f7f-b6a9-69e1e210e764.png" alt="" style="display:block;margin:0 auto" />

<hr />
<h3>Step 3 — Deploy Organization-Level CloudTrail</h3>
<pre><code class="language-javascript">resource "aws_cloudtrail" "org_trail" {
  name                          = "org-cloudtrail"
  s3_bucket_name                = aws_s3_bucket.org_logs.bucket
  s3_key_prefix                 = "cloudtrail"
  include_global_service_events = true
  is_multi_region_trail         = true
  is_organization_trail         = true
  enable_log_file_validation    = true
  cloud_watch_logs_group_arn    = "${aws_cloudwatch_log_group.cloudtrail.arn}:*"
  cloud_watch_logs_role_arn     = aws_iam_role.cloudtrail_cloudwatch.arn

  event_selector {
    read_write_type           = "All"
    include_management_events = true

    data_resource {
      type   = "AWS::S3::Object"
      values = ["arn:aws:s3:::"]
    }
  }

  depends_on = [aws_s3_bucket_policy.org_logs]
}
</code></pre>
<p>Four flags make this trail production-grade:</p>
<table>
<thead>
<tr>
<th>Flag</th>
<th>What It Does</th>
</tr>
</thead>
<tbody><tr>
<td><code>is_organization_trail = true</code></td>
<td>Captures activity from all member accounts</td>
</tr>
<tr>
<td><code>is_multi_region_trail = true</code></td>
<td>Captures activity in all regions</td>
</tr>
<tr>
<td><code>enable_log_file_validation = true</code></td>
<td>Creates cryptographic hashes for all log files</td>
</tr>
<tr>
<td>S3 data resource event selector</td>
<td>Captures all S3 object-level operations for exfiltration detection</td>
</tr>
</tbody></table>
<img src="https://cdn.hashnode.com/uploads/covers/69a6e08356428acc6fe423a5/4098a590-fe35-4895-8608-834e99f8d12f.png" alt="" style="display:block;margin:0 auto" />

<hr />
<h3>Step 4 — Enable GuardDuty With Full Detection Coverage</h3>
<pre><code class="language-javascript">resource "aws_guardduty_detector" "main" {
  enable = true

  datasources {
    s3_logs {
      enable = true
    }
    kubernetes {
      audit_logs {
        enable = true
      }
    }
    malware_protection {
      scan_ec2_instance_with_findings {
        ebs_volumes {
          enable = true
        }
      }
    }
  }
}
</code></pre>
<pre><code class="language-javascript">resource "aws_cloudwatch_event_rule" "guardduty_findings" {
  name = "landing-zone-guardduty-findings"

  event_pattern = jsonencode({
    source      = ["aws.guardduty"]
    detail-type = ["GuardDuty Finding"]
    detail = {
      severity = [{ numeric = ["&gt;=", 7] }]
    }
  })
}
</code></pre>
<p>The severity threshold of <code>7.0</code> captures HIGH (7.0–8.9) and CRITICAL (9.0–10.0) findings. MEDIUM findings (4.0–6.9) require investigation but not immediate response. In a production environment with a security operations team, you would route MEDIUM findings to a ticketing system and HIGH/CRITICAL findings to PagerDuty.</p>
<img src="https://cdn.hashnode.com/uploads/covers/69a6e08356428acc6fe423a5/100d81d4-9c69-4c39-a308-cee7a6f749b1.png" alt="" style="display:block;margin:0 auto" />

<hr />
<h3>Step 5 — Deploy AWS Config With Six Compliance Rules</h3>
<pre><code class="language-javascript">resource "aws_config_config_rule" "mfa_enabled_for_iam_console" {
  name = "mfa-enabled-for-iam-console-access"

  source {
    owner             = "AWS"
    source_identifier = "MFA_ENABLED_FOR_IAM_CONSOLE_ACCESS"
  }

  depends_on = [aws_config_configuration_recorder_status.main]
}

resource "aws_config_config_rule" "s3_bucket_public_access_prohibited" {
  name = "s3-bucket-public-access-prohibited"

  source {
    owner             = "AWS"
    source_identifier = "S3_BUCKET_PUBLIC_ACCESS_PROHIBITED"
  }

  depends_on = [aws_config_configuration_recorder_status.main]
}
</code></pre>
<p>All six Config rules use managed rule identifiers. AWS maintains the rule logic. You declare the rule and AWS evaluates your resources against it continuously. Any non-compliant resource triggers a finding in the Config console and can be configured to trigger an SNS notification via Config notification channels.</p>
<img src="https://cdn.hashnode.com/uploads/covers/69a6e08356428acc6fe423a5/afd4fcc0-58f8-4d63-8875-b820123e1d59.png" alt="" style="display:block;margin:0 auto" />

<img src="https://cdn.hashnode.com/uploads/covers/69a6e08356428acc6fe423a5/0ebb36b7-63b2-4186-ae73-cc53598b4609.png" alt="" style="display:block;margin:0 auto" />

<hr />
<h3>Step 6 — Deploy and Verify</h3>
<pre><code class="language-javascript">cd terraform
terraform apply --auto-approve

# Verify everything is active
aws organizations describe-organization
aws cloudtrail get-trail-status \
  --name org-cloudtrail --region us-east-1
aws guardduty list-detectors --region us-east-1
aws configservice describe-configuration-recorder-status
aws organizations list-policies --filter SERVICE_CONTROL_POLICY
</code></pre>
<hr />
<h2>What Broke During This Build</h2>
<h3>Debugging Story 1 — The CloudTrail That Would Not Create</h3>
<p>My first <code>terraform apply</code> failed with this error:</p>
<pre><code class="language-javascript">Error: creating CloudTrail Trail: InsufficientS3BucketPolicyException:
Bucket does not exist or insufficient policy
</code></pre>
<p>CloudTrail requires a specific bucket policy before it can write logs. The policy must allow the <code>cloudtrail.amazonaws.com</code> service principal to call <code>s3:GetBucketAcl</code> and <code>s3:PutObject</code>. Terraform was creating the CloudTrail resource before the bucket policy resource was fully applied.</p>
<p><strong>The fix:</strong> Adding <code>depends_on = [aws_s3_bucket_policy.org_logs]</code> to the CloudTrail resource. This forces Terraform to complete the bucket policy creation before attempting to create the trail.</p>
<hr />
<h3>Debugging Story 2 — The Config Rules That Failed to Deploy</h3>
<p>After the CloudTrail fix, the apply succeeded until it reached the Config rules. Three rules failed with:</p>
<pre><code class="language-javascript">Error: creating ConfigService ConfigRule: InvalidParameterValueException:
The configuration recorder does not exist.
</code></pre>
<p>AWS Config rules require the configuration recorder to be active before rules can be evaluated. But the recorder requires a delivery channel before it can be enabled. And the delivery channel requires the S3 bucket to exist.</p>
<p><strong>The correct creation order is:</strong> S3 bucket → Config delivery channel → Config recorder → Config recorder status → Config rules</p>
<p>I had the recorder and recorder status in the wrong order relative to the delivery channel. Adding explicit <code>depends_on</code> references to enforce the correct sequence resolved the issue.</p>
<img src="https://cdn.hashnode.com/uploads/covers/69a6e08356428acc6fe423a5/be8e3948-90c9-462b-af38-462d0bef94c8.png" alt="" style="display:block;margin:0 auto" />

<img src="https://cdn.hashnode.com/uploads/covers/69a6e08356428acc6fe423a5/c5bd1dba-40c2-4ba5-95b9-37b41ff4b90a.png" alt="" style="display:block;margin:0 auto" />

<hr />
<h3>Debugging Story 3 — The SCP Region Restriction That Broke IAM</h3>
<p>After deploying the region restriction SCP, I tried to run <code>terraform plan</code> from my management account to check for drift. The plan failed immediately with:</p>
<pre><code class="language-javascript">Error: error calling sts:GetCallerIdentity: AccessDenied
</code></pre>
<p>The region restriction SCP was denying STS calls because <code>sts:*</code> was not in my <code>NotAction</code> exemption list.</p>
<blockquote>
<p>⚠️ <strong>Critical lesson:</strong> SCPs applied to an OU apply to all accounts in that OU — including the management account if you attach them to the root instead of to a child OU. <strong>Always attach region restriction SCPs to child OUs, never to the root</strong>, and always include all global services in the <code>NotAction</code> exemption list.</p>
</blockquote>
<hr />
<h2>Security Posture Improvements at a Glance</h2>
<table>
<thead>
<tr>
<th>Finding Type</th>
<th>Without This Platform</th>
<th>With This Platform</th>
</tr>
</thead>
<tbody><tr>
<td>Root account API calls</td>
<td>Undetected</td>
<td>Real-time alert within 5 minutes</td>
</tr>
<tr>
<td>CloudTrail disabled</td>
<td>Possible, may go unnoticed</td>
<td>SCP blocks it at API level</td>
</tr>
<tr>
<td>GuardDuty disabled</td>
<td>Possible</td>
<td>SCP blocks disabling</td>
</tr>
<tr>
<td>Resources in unapproved regions</td>
<td>No restriction</td>
<td>SCP blocks creation</td>
</tr>
<tr>
<td>Sensitive IAM without MFA</td>
<td>Allowed</td>
<td>SCP denies at API level</td>
</tr>
<tr>
<td>Audit log tampering</td>
<td>Undetectable</td>
<td>Log file validation detects it</td>
</tr>
<tr>
<td>High severity threats</td>
<td>No detection</td>
<td>GuardDuty alert within minutes</td>
</tr>
<tr>
<td>Compliance drift</td>
<td>Manual review monthly</td>
<td>Config continuous evaluation</td>
</tr>
<tr>
<td>Account escape from governance</td>
<td>Possible</td>
<td>SCP blocks <code>LeaveOrganization</code></td>
</tr>
</tbody></table>
<hr />
<h2>Production Gap Analysis</h2>
<p>This landing zone establishes the governance foundation. A production deployment at enterprise scale would additionally require the following.</p>
<p><strong>AWS Security Hub</strong> provides a unified view of security findings from GuardDuty, Config, IAM Access Analyzer, and third-party integrations in a single dashboard. Security Hub also runs its own automated checks against the CIS AWS Foundations Benchmark and the AWS Foundational Security Best Practices standard. This is the tool that security teams use for day-to-day compliance monitoring.</p>
<p><strong>GuardDuty delegated admin configuration</strong> designates a security account as the GuardDuty administrator for the entire Organization. This allows the security team to manage GuardDuty detectors, findings, and member accounts from a central point without accessing individual workload accounts.</p>
<p><strong>AWS Config aggregator</strong> collects configuration and compliance data from all member accounts into a single aggregated view. Without an aggregator, Config data is siloed per account and requires navigating to each account individually to see the full compliance picture.</p>
<p><strong>IAM Identity Center with SSO integration</strong> provides human access to member accounts through a centralized portal rather than requiring separate IAM users in each account. Engineers log in once with their corporate identity and assume roles in the accounts they need to access. Every access is logged, MFA is enforced centrally, and deprovisioning a user removes their access to all accounts simultaneously.</p>
<p><strong>AWS Firewall Manager</strong> centrally manages WAF rules, Shield Advanced protections, and security group policies across all accounts in the Organization.</p>
<hr />
<h2>How This Helps Companies</h2>
<p>The healthtech startup that lost their Series B term sheet was not unusual. The specific findings in their security audit appear in post-mortems, investor withdrawal letters, and regulatory enforcement actions across the industry.</p>
<p>The controls in this landing zone directly address every finding in that audit. Root account protection, CloudTrail integrity, GuardDuty coverage, region control, MFA enforcement, and centralized logging are not advanced security controls. They are <strong>the baseline</strong> that every organization managing sensitive data in AWS should have before they talk to enterprise customers or investors.</p>
<p>The engineering work to implement them is approximately one afternoon. The cost of not having them is measured in lost contracts, failed audits, and withdrawn term sheets.</p>
<hr />
<h2>Full Source Code</h2>
<p>The complete Terraform code is available at:</p>
<p>🔗 <a href="https://github.com/Eaglewings966/aws-multi-account-landing-zone"><strong>github.com/Eaglewings966/aws-multi-account-landing-zone</strong></a></p>
<p>When you are done testing, clean up your environment:</p>
<pre><code class="language-bash">cd terraform &amp;&amp; terraform destroy --auto-approve
</code></pre>
<blockquote>
<p><strong>Note:</strong> Member accounts created via Organizations cannot be deleted for 7 days after creation. This is an AWS constraint.</p>
</blockquote>
<hr />
<h2>What's Next</h2>
<p>Project 7 in this series covers a <strong>DevSecOps pipeline</strong> with container vulnerability scanning, infrastructure security scanning, and SAST integrated into GitHub Actions.</p>
<hr />
<p><em>Emmanuel Ubani is a Cloud and DevOps Engineer based in Lagos, Nigeria. He writes about real infrastructure builds, career transitions into tech, and lessons learned the hard way.</em></p>
<ul>
<li><p>🔗 <a href="https://linkedin.com/in/ubaniemmanuel">LinkedIn</a></p>
</li>
<li><p>💻 <a href="https://github.com/Eaglewings966">GitHub</a></p>
</li>
<li><p>✍️ <a href="https://emmanuelubani.hashnode.dev">Hashnode</a></p>
</li>
<li><p>📝 <a href="https://medium.com/@emmaubani966">Medium</a></p>
</li>
<li><p>🌐 <a href="https://ops-run.lovable.app">Portfolio</a></p>
</li>
</ul>
]]></content:encoded></item><item><title><![CDATA[I Built a Production-Grade CI/CD Pipeline With GitHub Actions That Lints, Tests, Scans, Builds, and Deploys to AWS ECS — Here Is Every Decision I Made and Why
]]></title><description><![CDATA[The tutorial version of GitHub Actions gets you to hello world. This gets you to production.]]></description><link>https://emmanuelubani.hashnode.dev/i-built-a-production-grade-ci-cd-pipeline-with-github-actions-that-lints-tests-scans-builds-and-deploys-to-aws-ecs-here-is-every-decision-i-made-and-why</link><guid isPermaLink="true">https://emmanuelubani.hashnode.dev/i-built-a-production-grade-ci-cd-pipeline-with-github-actions-that-lints-tests-scans-builds-and-deploys-to-aws-ecs-here-is-every-decision-i-made-and-why</guid><category><![CDATA[github-actions]]></category><category><![CDATA[Devops]]></category><category><![CDATA[ci-cd]]></category><category><![CDATA[Security]]></category><category><![CDATA[AWS]]></category><category><![CDATA[Docker]]></category><category><![CDATA[Career]]></category><category><![CDATA[aws-fargate]]></category><category><![CDATA[ECS]]></category><category><![CDATA[trivy]]></category><dc:creator><![CDATA[Ubani Obiajulum Emmanuel]]></dc:creator><pubDate>Thu, 09 Apr 2026 14:33:24 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/69a6e08356428acc6fe423a5/79f63d3d-80cc-41d4-8577-524ec1db1dc3.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<hr />
<h2>Before I Was a DevOps Engineer, I Was a Lot of Other Things</h2>
<p>I have cleaned animal enclosures at a zoo. I have replaced brake pads and diagnosed engine faults as an automotive apprentice. I have managed stock, handled suppliers, and run the daily operations of a beauty and cosmetics store.</p>
<p>None of those jobs involved cloud infrastructure. None of them involved CI/CD pipelines, Docker containers, or IAM policies. I started learning DevOps and Cloud Engineering in January 2024 — no connections in tech, no prior experience, no roadmap from anyone who had done it before me.</p>
<p>I tell you this not for sympathy but because it shapes how I explain things. I came into this field as someone who had to learn everything from first principles. When I write about CI/CD pipelines, I write for the version of me from eighteen months ago who would have stared at a GitHub Actions YAML file and had no idea where to start.</p>
<p>This is <strong>Project 2</strong> in my public DevOps portfolio series, built and deployed as part of my 30-day DevOps challenge. The full code is at <a href="https://github.com/Eaglewings966/github-actions-cicd-pipeline.git">github.com/Eaglewings966/github-actions-cicd-pipeline.</a></p>
<hr />
<h2>The Story That Made This Project Feel Necessary</h2>
<p>A Lagos-based fintech startup was processing several thousand transactions per day. Three developers. A part-time DevOps consultant. Deployments worked like this: the lead developer wrote code, reviewed it himself, SSHed into the production server, pulled the latest commit from GitHub, ran <code>npm install</code>, and restarted the Node.js process with PM2.</p>
<p>It worked. Until it did not.</p>
<p>The lead developer went on a three-day trip to Abuja. A junior developer merged a critical bug fix for a payment gateway timeout issue. Nobody else had the SSH credentials. The fix sat merged in GitHub for <strong>62 hours</strong> while users received failed transaction errors. The support queue built up. Two enterprise clients threatened to terminate contracts.</p>
<p>When the lead developer finally got back, the deployment took four minutes.</p>
<p>The business impact of those 62 hours was not four minutes.</p>
<p>When I audited their setup, I found the same five pain points I see consistently in early-stage startups:</p>
<ul>
<li><p><strong>Manual deployments</strong> locked to one person with credentials</p>
</li>
<li><p><strong>No CI/CD</strong> — merged code went directly from personal judgment to production</p>
</li>
<li><p><strong>Inconsistent environments</strong> that made bugs impossible to reproduce</p>
</li>
<li><p><strong>Credential mismanagement</strong> — SSH keys shared informally, secrets in plain text <code>.env</code> files</p>
</li>
<li><p><strong>Zero observability</strong> — the team learned about outages when users tweeted about them</p>
</li>
</ul>
<p>The solution I built addresses all five at the infrastructure level.</p>
<p>A fully automated CI/CD pipeline using GitHub Actions, Docker, Amazon ECR, and AWS ECS Fargate. Zero SSH access to production. Zero manual deployment steps. Every push to main passes automated quality gates and lands in a running container on AWS.</p>
<hr />
<h2>What Is a CI/CD Pipeline? (Plain English First)</h2>
<p>Before the architecture — because if you have never worked with CI/CD before, the acronym alone can create false confidence that you understand what is actually happening.</p>
<p><strong>CI (Continuous Integration)</strong> means every time a developer pushes code, an automated system checks it. Does it follow code style rules? Do the tests pass? Are there known security vulnerabilities in the dependencies? Problems are caught at the point of introduction, when they are cheapest to fix.</p>
<p><strong>CD (Continuous Deployment)</strong> means once the code passes every check, the system automatically packages it, publishes it to a container registry, and updates the running service in production. No human touch required after the <code>git push</code>.</p>
<p><strong>GitHub Actions</strong> is the automation engine. You write YAML files describing what to do — install Node.js, run ESLint, build a Docker image, push to ECR, update the ECS service — and GitHub runs them automatically every time something triggers the workflow.</p>
<hr />
<h2>The Pipeline Architecture</h2>
<p>Before writing a single line of YAML, I drew the dependency chain. The structure of the pipeline is as important as the code inside it.</p>
<pre><code class="language-plaintext">Developer pushes to main
         │
         ▼
   GitHub Actions triggered
         │
         ├─── Job 1: Lint (ESLint + Prettier)         ← runs immediately
         │
         ├─── Job 2: Security Scan (Trivy)             ← runs in parallel with Lint
         │
         ├─── Job 3: Test (Jest)                       ← needs Lint + Scan to pass
         │
         ├─── Job 4: Build and Push (Docker → ECR)     ← needs Test to pass
         │
         └─── Job 5: Deploy (ECS Fargate)              ← needs Build to succeed
</code></pre>
<h3>Why this structure matters in real money</h3>
<p>Lint and Security Scan run <strong>in parallel</strong>. They are independent quality gates — there is no reason to wait for one before starting the other. Running them simultaneously saves roughly two minutes on every pipeline run.</p>
<p>Test does not start until both pass. Build does not start until Test passes. Deploy does not start until the image is safely in ECR.</p>
<p>Without this chain, a failing lint check would still trigger a Docker build, an ECR push, and an ECS deployment update — burning five to eight minutes of compute and cloud storage on work you are about to throw away. With this chain, a failing lint check costs ten seconds of runner time.</p>
<blockquote>
<p>💡 <strong>Fail fast. Fail cheap.</strong> The job dependency chain is an engineering cost decision, not just syntax.</p>
</blockquote>
<hr />
<h2>Repository Structure</h2>
<pre><code class="language-plaintext">github-actions-cicd-pipeline/
│
├── .github/
│   └── workflows/
│       └── ci-cd.yml            ← the entire pipeline lives here
│
├── app/
│   ├── index.js                 ← Express app with /health endpoint
│   └── package.json
│
├── __tests__/
│   └── app.test.js              ← Jest test suite
│
├── docker/
│   ├── Dockerfile               ← multi-stage, non-root build
│   └── docker-compose.yml       ← full local stack
│
├── scripts/
│   ├── deploy.sh
│   └── nginx.conf
│
└── docs/
    └── pipeline-architecture.md
</code></pre>
<p>The <code>.github/workflows/</code> path is not configurable — GitHub Actions reads this directory automatically. Every change to the pipeline is version-controlled and reviewable in pull requests like any other code change.</p>
<p>The <code>docker/</code> directory separates containerisation concerns from application concerns. The <code>docker-compose.yml</code> brings up the full local stack — Node.js app, Nginx as a reverse proxy, and Redis for caching — so you can validate the complete environment locally before pushing anything.</p>
<hr />
<h2>Step 1 — The Application</h2>
<p>The application is intentionally minimal. The pipeline is the project. But it must be real enough to lint, test, build, scan, and deploy with production-appropriate patterns.</p>
<pre><code class="language-javascript">// app/index.js
const express = require('express');
const helmet = require('helmet');
const app = express();
const PORT = process.env.PORT || 3000;

app.use(helmet());

app.get('/', (req, res) =&gt; {
  res.json({
    message: 'Tech with Emma — CI/CD Pipeline Running',
    author: 'Emmanuel Ubani',
    github: 'github.com/Eaglewings966',
    status: 'healthy',
    version: process.env.APP_VERSION || 'local'
  });
});

app.get('/health', (req, res) =&gt; {
  res.status(200).json({
    status: 'ok',
    uptime: process.uptime(),
    timestamp: new Date().toISOString()
  });
});

module.exports = app;
</code></pre>
<p><strong>Why does</strong> <code>/health</code> <strong>exist?</strong> The Application Load Balancer sends health check requests to this endpoint every 30 seconds. If it does not return HTTP 200, the ALB stops routing traffic to that task and ECS replaces it automatically. The <code>uptime</code> and <code>timestamp</code> fields make the response useful for debugging as well as load balancer validation.</p>
<p><strong>Why</strong> <code>helmet()</code><strong>?</strong> It sets twelve HTTP security headers with one line — X-Content-Type-Options, X-Frame-Options, Strict-Transport-Security, and nine others. Baseline production security that costs nothing.</p>
<p>Validate the full stack locally before pushing:</p>
<pre><code class="language-bash"># bash
cd docker
docker-compose up --build
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/69a6e08356428acc6fe423a5/6cf3efc9-ef8a-4e2a-b2d6-0f539dc81452.png" alt="" style="display:block;margin:0 auto" />

<img src="https://cdn.hashnode.com/uploads/covers/69a6e08356428acc6fe423a5/ee392a60-d97d-4932-be2c-830e3ed47dbd.png" alt="" style="display:block;margin:0 auto" />

<hr />
<h2>Step 2 — The Dockerfile</h2>
<pre><code class="language-dockerfile"># docker/Dockerfile

# ── Stage 1: Install dependencies ──────────────────────────────────────────────
FROM node:18-alpine AS builder

WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production

# ── Stage 2: Production image ───────────────────────────────────────────────────
FROM node:18-alpine AS production

RUN addgroup -g 1001 -S nodejs &amp;&amp; \
    adduser -S nodejs -u 1001

WORKDIR /app

COPY --from=builder /app/node_modules ./node_modules
COPY app/ ./

EXPOSE 3000

USER nodejs

HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
  CMD node -e "require('http').get('http://localhost:3000/health', (r) =&gt; process.exit(r.statusCode === 200 ? 0 : 1))"

CMD ["node", "index.js"]
</code></pre>
<p>Three decisions here that carry real weight in production.</p>
<p><code>node:18-alpine</code> produces an image around 110MB compared to 900MB for the full Node image. In an ECS cluster running across multiple tasks, that difference in pull time compounds on every deployment. Smaller images also mean a smaller attack surface — fewer packages, fewer potential vulnerabilities.</p>
<p><strong>Multi-stage build</strong> means only production <code>node_modules</code> enter the final image. Jest, ESLint, and every other development dependency never reach production. This is both a security improvement and a size improvement.</p>
<p><code>USER nodejs</code> runs the process as a non-root user. A container running as root that gets compromised gives an attacker root-level access inside the container and potentially to the host. A dedicated non-root user limits the blast radius. It costs zero effort and is a baseline container security requirement.</p>
<hr />
<h2>Step 3 — Configure GitHub Secrets</h2>
<p>Do this before writing the workflow file. If secrets do not exist when the workflow runs, you get misleading error messages that point you everywhere except the actual problem.</p>
<p>Navigate to your repository: <strong>Settings → Secrets and variables → Actions</strong>. Create these six secrets:</p>
<table>
<thead>
<tr>
<th>Secret Name</th>
<th>Value</th>
</tr>
</thead>
<tbody><tr>
<td><code>AWS_ACCESS_KEY_ID</code></td>
<td>From your GitHub Actions IAM user</td>
</tr>
<tr>
<td><code>AWS_SECRET_ACCESS_KEY</code></td>
<td>From your GitHub Actions IAM user</td>
</tr>
<tr>
<td><code>AWS_REGION</code></td>
<td><code>us-east-1</code></td>
</tr>
<tr>
<td><code>ECR_REPOSITORY</code></td>
<td>Your ECR repository name</td>
</tr>
<tr>
<td><code>ECS_SERVICE</code></td>
<td>Your ECS service name</td>
</tr>
<tr>
<td><code>ECS_CLUSTER</code></td>
<td>Your ECS cluster name</td>
</tr>
</tbody></table>
<h3>Create the dedicated IAM user</h3>
<p>Create a user named <code>github-actions-deployer</code> — not your personal credentials, not an administrator account. A user with exactly two capabilities: pushing to ECR and updating ECS services.</p>
<pre><code class="language-json">// IAM Policy — github-actions-deployer
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "ECRAccess",
      "Effect": "Allow",
      "Action": [
        "ecr:GetAuthorizationToken",
        "ecr:BatchCheckLayerAvailability",
        "ecr:GetDownloadUrlForLayer",
        "ecr:BatchGetImage",
        "ecr:InitiateLayerUpload",
        "ecr:UploadLayerPart",
        "ecr:CompleteLayerUpload",
        "ecr:PutImage"
      ],
      "Resource": "*"
    },
    {
      "Sid": "ECSDeployAccess",
      "Effect": "Allow",
      "Action": [
        "ecs:RegisterTaskDefinition",
        "ecs:DescribeTaskDefinition",
        "ecs:DescribeServices",
        "ecs:UpdateService",
        "iam:PassRole"
      ],
      "Resource": "*"
    }
  ]
}
</code></pre>
<blockquote>
<p>🔒 If these credentials are ever compromised, the attacker can push container images and update ECS services. They cannot delete resources, access S3, modify IAM policies, or touch any other AWS service. <strong>Contained blast radius by design.</strong></p>
</blockquote>
<img src="https://cdn.hashnode.com/uploads/covers/69a6e08356428acc6fe423a5/31c4f5ba-7039-4fd1-910f-b3814afa57b1.png" alt="" style="display:block;margin:0 auto" />

<hr />
<h2>Step 4 — The Complete Workflow File</h2>
<pre><code class="language-yaml"># .github/workflows/ci-cd.yml
name: CI/CD Pipeline

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

env:
  AWS_REGION: us-east-1
  ECR_REPOSITORY: ${{ secrets.ECR_REPOSITORY }}
  ECS_SERVICE: ${{ secrets.ECS_SERVICE }}
  ECS_CLUSTER: ${{ secrets.ECS_CLUSTER }}
  IMAGE_NAME: devops-demo-app

jobs:

  # ── Job 1: Lint ──────────────────────────────────────────────────────────────
  lint:
    name: Code Quality
    runs-on: ubuntu-latest
    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Set up Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '18'
          cache: 'npm'
          cache-dependency-path: app/package-lock.json

      - name: Install dependencies
        run: npm ci
        working-directory: app

      - name: Run ESLint
        run: npx eslint . --max-warnings 0
        working-directory: app

      - name: Check Prettier formatting
        run: npx prettier --check .
        working-directory: app

  # ── Job 2: Security Scan (parallel with Lint) ────────────────────────────────
  security-scan:
    name: Security Scan
    runs-on: ubuntu-latest
    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Build image for scanning
        run: docker build -f docker/Dockerfile -t ${{ env.IMAGE_NAME }}:scan .

      - name: Run Trivy vulnerability scanner
        uses: aquasecurity/trivy-action@master
        with:
          image-ref: ${{ env.IMAGE_NAME }}:scan
          format: table
          exit-code: '1'
          ignore-unfixed: true
          vuln-type: 'os,library'
          severity: 'CRITICAL,HIGH'

      - name: Upload Trivy scan results
        uses: actions/upload-artifact@v4
        if: always()
        with:
          name: trivy-results
          path: trivy-results.sarif

  # ── Job 3: Test (waits for Lint + Security Scan) ─────────────────────────────
  test:
    name: Test Suite
    runs-on: ubuntu-latest
    needs: [lint, security-scan]
    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Set up Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '18'
          cache: 'npm'
          cache-dependency-path: app/package-lock.json

      - name: Install all dependencies
        run: npm ci
        working-directory: app

      - name: Run tests with coverage
        run: npm test -- --coverage --coverageThreshold='{"global":{"lines":80}}'
        working-directory: app

      - name: Upload coverage report
        uses: actions/upload-artifact@v4
        with:
          name: coverage-report
          path: app/coverage/

  # ── Job 4: Build and Push (waits for Test) ───────────────────────────────────
  build-and-push:
    name: Build and Push to ECR
    runs-on: ubuntu-latest
    needs: test
    outputs:
      image-uri: ${{ steps.build-image.outputs.image }}
    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - 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: ${{ env.AWS_REGION }}

      - name: Login to Amazon ECR
        id: login-ecr
        uses: aws-actions/amazon-ecr-login@v2

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3

      - name: Build, tag, and push image to ECR
        id: build-image
        env:
          ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }}
          IMAGE_TAG: ${{ github.sha }}
        run: |
          docker buildx build \
            --platform linux/amd64 \
            --file docker/Dockerfile \
            --tag \(ECR_REGISTRY/\)ECR_REPOSITORY:$IMAGE_TAG \
            --tag \(ECR_REGISTRY/\)ECR_REPOSITORY:latest \
            --cache-from type=gha \
            --cache-to type=gha,mode=max \
            --push \
            .
          echo "image=\(ECR_REGISTRY/\)ECR_REPOSITORY:\(IMAGE_TAG" &gt;&gt; \)GITHUB_OUTPUT

  # ── Job 5: Deploy (main branch only) ─────────────────────────────────────────
  deploy:
    name: Deploy to ECS Fargate
    runs-on: ubuntu-latest
    needs: build-and-push
    if: github.ref == 'refs/heads/main'
    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - 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: ${{ env.AWS_REGION }}

      - name: Download task definition
        run: |
          aws ecs describe-task-definition \
            --task-definition ${{ env.IMAGE_NAME }} \
            --query taskDefinition \
            &gt; task-definition.json

      - name: Update ECS task definition with new image
        id: task-def
        uses: aws-actions/amazon-ecs-render-task-definition@v1
        with:
          task-definition: task-definition.json
          container-name: ${{ env.IMAGE_NAME }}
          image: ${{ needs.build-and-push.outputs.image-uri }}

      - name: Deploy to ECS service
        uses: aws-actions/amazon-ecs-deploy-task-definition@v1
        with:
          task-definition: ${{ steps.task-def.outputs.task-definition }}
          service: ${{ env.ECS_SERVICE }}
          cluster: ${{ env.ECS_CLUSTER }}
          wait-for-service-stability: true
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/69a6e08356428acc6fe423a5/2eeffecb-43f7-4a52-8d10-10cfdadac470.png" alt="" style="display:block;margin:0 auto" />

<img src="https://cdn.hashnode.com/uploads/covers/69a6e08356428acc6fe423a5/b30bfc17-dd6c-4889-8085-1072d9feea2b.png" alt="" style="display:block;margin:0 auto" />

<hr />
<h2>Step 5 — Set Up AWS ECS Fargate</h2>
<h3>Create the ECR repository</h3>
<pre><code class="language-bash"># bash
aws ecr create-repository \
  --repository-name devops-demo-app \
  --region us-east-1 \
  --image-scanning-configuration scanOnPush=true \
  --encryption-configuration encryptionType=AES256
</code></pre>
<p><code>scanOnPush=true</code> enables ECR's built-in vulnerability scanning on every push — independent of the Trivy scan in the pipeline. Two scanning engines, two chances to catch what one might miss.</p>
<h3>Create the ECS cluster</h3>
<pre><code class="language-bash"># bash
aws ecs create-cluster \
  --cluster-name devops-demo-cluster \
  --capacity-providers FARGATE FARGATE_SPOT \
  --region us-east-1
</code></pre>
<p>Both FARGATE and FARGATE_SPOT capacity providers give you cost flexibility. FARGATE_SPOT instances can be 70% cheaper but can be interrupted with a two-minute warning. For production, use FARGATE for your baseline desired count and FARGATE_SPOT for burst capacity.</p>
<h3>Create the ECS service</h3>
<pre><code class="language-javascript"># bash
aws ecs create-service \
  --cluster devops-demo-cluster \
  --service-name devops-demo-service \
  --task-definition devops-demo-app \
  --desired-count 2 \
  --launch-type FARGATE \
  --network-configuration "awsvpcConfiguration={subnets=[subnet-xxx,subnet-yyy],securityGroups=[sg-xxx],assignPublicIp=ENABLED}" \
  --load-balancers "targetGroupArn=arn:aws:elasticloadbalancing:...,containerName=devops-demo-app,containerPort=3000" \
  --deployment-configuration "minimumHealthyPercent=100,maximumPercent=200"
</code></pre>
<blockquote>
<p>🚀 <strong>Zero-downtime is in that last flag.</strong> <code>minimumHealthyPercent=100</code> with <code>maximumPercent=200</code> means ECS starts new tasks first, waits for ALB health checks to confirm they are healthy, then drains and stops the old tasks. There is never a moment with fewer healthy tasks than your desired count serving traffic. This costs nothing extra — it just requires knowing the configuration exists.</p>
<img src="https://cdn.hashnode.com/uploads/covers/69a6e08356428acc6fe423a5/9f9f13dd-33c6-486e-80d1-8ed62bf496e4.png" alt="" style="display:block;margin:0 auto" /></blockquote>
<img src="https://cdn.hashnode.com/uploads/covers/69a6e08356428acc6fe423a5/898a9842-9158-477a-9555-826bf6813c37.png" alt="" style="display:block;margin:0 auto" />

<hr />
<h2>The Security Architecture</h2>
<p>Security in this pipeline is not a single feature. It is five independent controls, each addressing a different risk surface.</p>
<table>
<thead>
<tr>
<th>Control</th>
<th>Risk It Addresses</th>
</tr>
</thead>
<tbody><tr>
<td><strong>Trivy Container Scan</strong></td>
<td>Blocks known CVEs from ever reaching ECR</td>
</tr>
<tr>
<td><strong>ECR Scan on Push</strong></td>
<td>Second independent engine — catches what Trivy might miss</td>
</tr>
<tr>
<td><strong>Dedicated IAM User</strong></td>
<td>Limits blast radius if credentials are compromised</td>
</tr>
<tr>
<td><strong>Non-Root Container User</strong></td>
<td>Prevents root-level escalation if the container is breached</td>
</tr>
<tr>
<td><strong>Helmet.js Security Headers</strong></td>
<td>Clickjacking, MIME sniffing, missing HTTPS enforcement</td>
</tr>
</tbody></table>
<h3>Why SHA image tags matter</h3>
<p>When you tag images with <code>latest</code>, you lose traceability. If something breaks in production, you cannot look at the running container and trace it back to the exact code.</p>
<p>With SHA tagging, every running ECS task tells you its exact Git commit. You can look up that commit, read the code, and understand precisely what is deployed. A system you can reason about is a system you can fix quickly.</p>
<hr />
<h2>The Real Lesson</h2>
<p>I want to tell you about the three hours I lost to a problem that took two seconds to fix.</p>
<p>During development, I hit this error:</p>
<pre><code class="language-plaintext">Error: Credentials could not be loaded
</code></pre>
<p>I checked the secret names — they matched exactly. I checked the IAM user — the access key was active. I read the GitHub Actions documentation three times. Everything looked right.</p>
<p>The problem was one character. I had set <code>AWS_REGION</code> to <code>us-east-2</code> instead of <code>us-east-1</code>. The credentials action was trying to validate against the wrong region endpoint and failing with a credentials error, not a region error. The actual mistake was invisible because I had no visibility into what values the pipeline was actually using.</p>
<p>The fix was changing one character. The diagnosis was three hours.</p>
<p>Here is what I add before every credentials step in any pipeline I build now:</p>
<pre><code class="language-yaml"># Add this debug step before any AWS credentials usage
- name: Debug environment
  run: |
    echo "Region:     ${{ env.AWS_REGION }}"
    echo "Cluster:    ${{ env.ECS_CLUSTER }}"
    echo "Service:    ${{ env.ECS_SERVICE }}"
    echo "Repository: ${{ env.ECR_REPOSITORY }}"
</code></pre>
<p>When the context is visible, the actual problem becomes obvious in seconds. One wrong character in one variable stands out immediately when you can see all the values together.</p>
<img src="https://cdn.hashnode.com/uploads/covers/69a6e08356428acc6fe423a5/1244b5a8-993c-45b7-9ebb-3d1693267f96.png" alt="" style="display:block;margin:0 auto" />

<blockquote>
<p>🧠 <strong>The most expensive debugging sessions are caused by the smallest mistakes. The defence is visibility, not cleverness.</strong></p>
</blockquote>
<hr />
<h2>Destroy Reminder</h2>
<p>If you deployed to a live AWS account while following this walkthrough, clean up your resources when you are done. ECS Fargate tasks, Application Load Balancers, and NAT Gateways all accumulate charges while running.</p>
<pre><code class="language-javascript"># bash — Run these in order

# 1. Scale the service to zero, then delete it
aws ecs update-service \
  --cluster devops-demo-cluster \
  --service devops-demo-service \
  --desired-count 0

aws ecs delete-service \
  --cluster devops-demo-cluster \
  --service devops-demo-service

# 2. Delete the ECS cluster
aws ecs delete-cluster \
  --cluster devops-demo-cluster

# 3. Delete the ECR repository and all images inside it
aws ecr delete-repository \
  --repository-name devops-demo-app \
  --force \
  --region us-east-1

# 4. Delete the ALB and target group
# Navigate to: EC2 → Load Balancers in the AWS console

# 5. Remove VPC resources if you created them for this project
</code></pre>
<blockquote>
<p>⚠️ <strong>Verify in your AWS Billing console after cleanup.</strong> Active resources you miss will continue billing silently.</p>
</blockquote>
<hr />
<p>Full code for this project: <a href="https://github.com/Eaglewings966/github-actions-cicd-pipeline">github.com/Eaglewings966/github-actions-cicd-pipeline</a></p>
<hr />
<h2>About the Author</h2>
<p><strong>Emmanuel Ubani</strong> is a Cloud and DevOps Engineer based in Lagos, Nigeria, currently studying Computer Science at Imo State University. Before entering tech in January 2024, he volunteered at a zoo, trained as an automotive technician, and ran a beauty and cosmetics store. He writes about real infrastructure builds, non-traditional career transitions into tech, and lessons learned the hard way — under the brand <strong>Tech with Emma</strong>.</p>
<ul>
<li><p>🔗 LinkedIn — <a href="https://linkedin.com/in/ubaniemmanuel">linkedin.com/in/ubaniemmanuel</a></p>
</li>
<li><p>💻 GitHub — <a href="https://github.com/Eaglewings966">github.com/Eaglewings966</a></p>
</li>
<li><p>✍️ Hashnode — <a href="https://emmanuelubani.hashnode.dev">emmanuelubani.hashnode.dev</a></p>
</li>
<li><p>📝 Medium — <a href="https://medium.com/@emmaubani966">medium.com/@emmaubani966</a></p>
</li>
<li><p>🌐 Portfolio — <a href="https://ops-run.lovable.app">ops-run.lovable.app</a></p>
</li>
</ul>
<p><em>If this walkthrough saved you time or helped something click, share it with one engineer who is learning CI/CD right now.</em></p>
]]></content:encoded></item><item><title><![CDATA[How I Built an Automated AWS Cost Optimization Engine That Stops
Idle Servers, Enforces Tagging, and Alerts Before the Bill Arrives]]></title><description><![CDATA[Lambda, AWS Budgets, S3 lifecycle policies, IAM tagging enforcement, and a CloudWatch dashboard — all provisioned with Terraform]]></description><link>https://emmanuelubani.hashnode.dev/how-i-built-an-automated-aws-cost-optimization-engine-that-stops-idle-servers-enforces-tagging-and-alerts-before-the-bill-arrives</link><guid isPermaLink="true">https://emmanuelubani.hashnode.dev/how-i-built-an-automated-aws-cost-optimization-engine-that-stops-idle-servers-enforces-tagging-and-alerts-before-the-bill-arrives</guid><dc:creator><![CDATA[Ubani Obiajulum Emmanuel]]></dc:creator><pubDate>Sun, 05 Apr 2026 16:45:48 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/69a6e08356428acc6fe423a5/283294e7-3ddd-4f56-90e2-858e78565bb1.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>The Bill That Ended a Startup's Quarter In early 2023, a Series A startup based in Berlin received their AWS bill for the month of January. The number was \(34,000. Their expected monthly spend was \)8,000. The engineering team spent three days investigating. What they found was a collection of infrastructure failures that individually seemed minor but compounded into a $26,000 overrun. Fourteen EC2 instances had been left running after a load testing exercise that concluded six weeks earlier. Nobody had stopped them because nobody owned them. They had no tags identifying a team, a project, or an owner. The instances were t3.large and t3.xlarge types, running continuously in us-east-1 for forty-two days.</p>
<p>Three S3 buckets contained 2.1 terabytes of build artifacts, container image layers, and test data. Every object was stored in S3 Standard, the most expensive tier, despite none of the objects having been accessed in over thirty days. Two RDS instances that had been provisioned for a feature that was deprioritised were still running at full capacity, consuming compute and storage costs for a database that served zero queries.</p>
<p>There was no budget alert configured. There was no tagging policy. There was no automated idle detection. There was no monitoring of cost trends. The first signal the company received was the invoice at the end of the month. The CEO cancelled two planned hires to cover the overrun. Every single one of those cost drivers was detectable and preventable with automated controls. The tools exist in AWS. The engineering work to implement them is not complex. What was missing was the decision to build the system.</p>
<p>This article describes exactly how I built that system.</p>
<img src="https://cdn.hashnode.com/uploads/covers/69a6e08356428acc6fe423a5/65e0b229-0966-4178-abdd-5d9b86840c54.png" alt="" style="display:block;margin:0 auto" />

<h2>Who I Am and Why This Problem Matters to Me</h2>
<p>I am Emmanuel Ubani, a Cloud and DevOps Engineer based in Lagos, Nigeria. Before I transitioned into tech in January 2024, I was a zoo volunteer, an automotive technician apprentice, and a beauty store manager.</p>
<p>I tell you this because I understand what it means to operate with limited resources. Every naira I spent on cloud learning had to be justified. I could not afford to leave resources running and discover the cost at the end of the month. That constraint taught me to build systems that watch spending automatically, because manual review is a discipline that fails under pressure.</p>
<p>The cost optimization engine I am describing in this article is the same architecture I would implement on day one at any engineering team that was not already running it. It is not a complex system. It is a responsible one.</p>
<p>I will explain every component, every architecture decision, and every implementation detail. Whether you are a senior engineer who wants to validate the approach or a junior engineer who wants to understand how automated cost controls work, this article gives you both.</p>
<hr />
<h2>What the System Does</h2>
<p>Five automated controls working together as a coherent platform.</p>
<p>A Lambda function written in Python runs every 24 hours via an EventBridge schedule. It queries CloudWatch for EC2 CPU utilization metrics across all running instances. Any instance with average CPU below 5% for the past 24 hours is stopped automatically. A detailed report is published to an SNS topic and delivered to the engineering team by email.</p>
<p>AWS Budgets monitors the monthly spend against a $20 threshold. Alerts fire at 50%, 80%, and 100% of actual spend and at 80% of forecasted spend. The engineering team knows they are approaching the limit before they breach it, not after.</p>
<p>An S3 lifecycle configuration applies three rules to the cost optimizer bucket. Objects transition to Standard-IA after 30 days, to Glacier after 90 days, and are deleted after 365 days. Incomplete multipart uploads are aborted after 7 days. Old object versions are expired after 90 days. Storage costs reduce automatically without any ongoing manual action.</p>
<p>An IAM Deny policy blocks the creation of EC2, S3, and RDS resources that do not include all four required tags: Environment, Owner, Project, and CostCenter. Untagged resources cannot be created. Cost attribution is enforced at the point of creation.</p>
<p>A CloudWatch dashboard provides unified visibility into EC2 CPU utilization trends, Lambda invocation counts, Lambda error rates, and active cost alarms. The engineering team sees all cost signals in one place rather than navigating three separate AWS consoles.</p>
<img src="https://cdn.hashnode.com/uploads/covers/69a6e08356428acc6fe423a5/174dbfeb-8250-4335-81bd-7f798bf3e4af.png" alt="" style="display:block;margin:0 auto" />

<h2>Architecture Decisions Why Lambda Over AWS Instance Scheduler</h2>
<p>AWS Instance Scheduler is a managed AWS solution for starting and stopping instances on a schedule. It requires CloudFormation and has a fixed reporting structure. I chose Lambda because it gives full control over the idle detection logic.</p>
<p>The Lambda approach checks actual CPU utilization metrics rather than stopping instances based on a time schedule. A scheduled stop might shut down an instance that is actively processing a long-running job at midnight. A CPU-based idle check only stops instances that are genuinely not being used. The distinction matters in a production environment where workloads do not always follow a predictable schedule.</p>
<p>The exemption tag pattern adds another dimension. Any instance tagged with CostOptimizer=exempt is skipped by the Lambda. This gives teams a way to protect specific instances without disabling the automation globally.</p>
<h2>Why SNS as the Central Notification Hub</h2>
<p>The Lambda publishes its report to an SNS topic rather than sending email directly. This design decision has a significant operational benefit. Additional notification channels can be added to the SNS topic without modifying the Lambda code.</p>
<p>A Slack integration can subscribe to the topic via an HTTP endpoint backed by another Lambda. A PagerDuty integration can subscribe for critical budget breach alerts. A ticketing system can subscribe to create an issue for every idle instance report. The Lambda code never changes. The notification architecture evolves by adding subscribers to the topic.</p>
<h2>Why IAM Deny Policy Over AWS Config Rules</h2>
<p>AWS Config can detect untagged resources after they are created and trigger remediation. But remediation runs after the fact. The resource exists in an untagged state for some period before Config evaluates it and remediates it.</p>
<p>An IAM Deny policy prevents the creation entirely. The API call fails before the resource exists. There is no remediation step because there is no untagged resource to remediate. For tagging enforcement, prevention is categorically more reliable than detection and remediation.</p>
<h2>Why Terraform default_tags</h2>
<p>The Terraform AWS provider supports a default_tags block at the provider level. Every resource created by the Terraform configuration automatically receives the tags defined there. This means if a new resource is added to main.tf and the engineer forgets to add the tag block, the resource still receives all four required tags from the provider default.</p>
<p>This is more reliable than repeating the tag block in every resource definition. It reduces configuration size and eliminates the most common source of tagging drift in large Terraform files.</p>
<h2>Implementation — Building the Engine Step 1 — Set Up the Project Structure</h2>
<pre><code class="language-javascript">
mkdir aws-cost-optimization &amp;&amp; cd aws-cost-optimization 
mkdir -p terraform lambda policies 
git init
</code></pre>
<h2>Step 2 — The Lambda Function</h2>
<p>The Lambda function is the heart of the system. It does four things in sequence. It retrieves all running EC2 instances, excluding any tagged as exempt. It queries CloudWatch for CPU utilization metrics for each instance over the past 24 hours. It stops any instance whose average CPU is below the threshold. And it publishes a structured report to SNS. Here is the core idle detection and stop logic</p>
<pre><code class="language-javascript">
def get_average_cpu(instance_id, hours=24):
    end_time = datetime.now(timezone.utc)
    start_time = end_time - timedelta(hours=hours)

    response = cloudwatch_client.get_metric_statistics(
        Namespace="AWS/EC2",
        MetricName="CPUUtilization",
        Dimensions=[{"Name": "InstanceId", "Value": instance_id}],
        StartTime=start_time,
        EndTime=end_time,
        Period=int(hours * 3600),
        Statistics=["Average"]
    )

    datapoints = response.get("Datapoints", [])
    if not datapoints:
        return None

    return round(datapoints[0]["Average"], 2)


def lambda_handler(event, context):
    instances = get_running_instances()

    for instance in instances:
        avg_cpu = get_average_cpu(instance["instance_id"])

        if avg_cpu is not None and avg_cpu &lt; CPU_IDLE_THRESHOLD:
            stop_instance(instance["instance_id"])
            stopped.append(instance)
        else:
            skipped.append(instance)
publish_report(stopped, skipped, no_data, errors)
</code></pre>
<p>The <code>get_metric_statistics</code> call uses a single period equal to the full 24-hour window. This returns one datapoint representing the average CPU across the entire evaluation period rather than a series of hourly averages. One datapoint means one comparison against the threshold. The logic stays simple.</p>
<h2>Step 3 — AWS Budgets With Three Alert Thresholds</h2>
<pre><code class="language-javascript">

resource "aws_budgets_budget" "monthly_cost" {
  name         = "cost-optimizer-monthly-budget"
  budget_type  = "COST"
  limit_amount = "20"
  limit_unit   = "USD"
  time_unit    = "MONTHLY"

  notification {
    comparison_operator       = "GREATER_THAN"
    threshold                 = 50
    threshold_type            = "PERCENTAGE"
    notification_type         = "ACTUAL"
    subscriber_sns_topic_arns = [aws_sns_topic.cost_alerts.arn]
  }

  notification {
    comparison_operator       = "GREATER_THAN"
    threshold                 = 80
    threshold_type            = "PERCENTAGE"
    notification_type         = "ACTUAL"
    subscriber_sns_topic_arns = [aws_sns_topic.cost_alerts.arn]
  }

  notification {
    comparison_operator       = "GREATER_THAN"
    threshold                 = 100
    threshold_type            = "PERCENTAGE"
    notification_type         = "ACTUAL"
    subscriber_sns_topic_arns = [aws_sns_topic.cost_alerts.arn]
  }
}
</code></pre>
<p>Three actual spend notifications plus one forecasted spend notification at 80%. The forecasted alert is the most operationally valuable. It fires before the threshold is breached based on AWS's spend projection model. This gives the team time to investigate and act before the actual breach occurs.</p>
<img src="https://cdn.hashnode.com/uploads/covers/69a6e08356428acc6fe423a5/15b04f78-8aeb-4adc-833a-bf4e7f1683b7.png" alt="" style="display:block;margin:0 auto" />

<h3>Step 4 — The S3 Lifecycle Policy</h3>
<p>Three rules combined into one lifecycle configuration:</p>
<pre><code class="language-javascript">
resource "aws_s3_bucket_lifecycle_configuration" "assets_lifecycle" {
  bucket = aws_s3_bucket.cost_optimizer_assets.id

  rule {
    id     = "transition-to-cheaper-storage"
    status = "Enabled"
    filter { prefix = "" }

    transition {
      days          = 30
      storage_class = "STANDARD_IA"
    }
    transition {
      days          = 90
      storage_class = "GLACIER"
    }
    expiration {
      days = 365
    }
  }

  rule {
    id     = "abort-incomplete-multipart"
    status = "Enabled"
    filter { prefix = "" }

    abort_incomplete_multipart_upload {
      days_after_initiation = 7
    }
  }

  rule {
    id     = "expire-old-versions"
    status = "Enabled"
    filter { prefix = "" }

    noncurrent_version_expiration {
      noncurrent_days = 90
    }
  }
}
</code></pre>
<p>The incomplete multipart upload rule deserves special attention. Multipart uploads that fail or are abandoned leave partial object data in S3 that is charged at Standard tier rates but is never accessible and never completes. This is a silent cost driver in any bucket where large objects are uploaded. Aborting incomplete uploads after 7 days eliminates this.</p>
<img src="https://cdn.hashnode.com/uploads/covers/69a6e08356428acc6fe423a5/53aa2a44-f792-4753-9c30-59405331d8a3.png" alt="" style="display:block;margin:0 auto" />

<h3>Step 5 — IAM Tagging Enforcement</h3>
<pre><code class="language-javascript">
resource "aws_iam_policy" "require_tags" {
  name = "cost-optimizer-require-tags-policy"

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Sid    = "DenyEC2WithoutRequiredTags"
        Effect = "Deny"
        Action = ["ec2:RunInstances"]
        Resource = ["arn:aws:ec2:*:*:instance/*"]
        Condition = {
          "Null" = {
            "aws:RequestTag/Environment" = "true"
            "aws:RequestTag/Owner"       = "true"
            "aws:RequestTag/Project"     = "true"
            "aws:RequestTag/CostCenter"  = "true"
          }
        }
      }
    ]
  })
}

</code></pre>
<p>The Null condition with value "true" evaluates to true when the tag key is absent from the request. The Deny statement fires when any of the four required tags are missing. This means all four tags are required for the resource creation to succeed. One missing tag blocks the entire request.</p>
<h3>Step 6 — Deploy Everything</h3>
<pre><code class="language-javascript">

cd terraform 
terraform init 
terraform apply --auto-approve 

# Wait for apply to complete then invoke Lambda manually 
aws lambda invoke \ 
--function-name cost-optimizer-stop-idle-instances \ 
--region us-east-1 \ 
--log-type Tail \ 
response.json 

cat response.json
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/69a6e08356428acc6fe423a5/7e8ad593-6526-4583-80d9-dc5ee732ab8f.png" alt="" style="display:block;margin:0 auto" />

<h2>What Broke During the Build</h2>
<h3>Debugging Story 1 — The Lambda That Would Not Package</h3>
<p>When I first ran terraform apply, it failed with this error:</p>
<pre><code class="language-javascript">Error: error creating Lambda Function: InvalidParameterValueException:
Could not unzip uploaded file. Please check your file, then try again.
</code></pre>
<p>The <code>archive_file data</code> source had created the ZIP file, but Terraform was trying to upload it before the file was fully written to disk in some runs. The fix was adding a <code>depends_on</code> to the Lambda resource pointing to the archive data source, forcing Terraform to wait for the ZIP to be created and its hash computed before attempting the upload.</p>
<p>The lesson is that <code>archive_file data</code> sources can have timing issues with Lambda resources in the same apply. <code>Explicit depends_on</code> resolves this reliably.</p>
<h3>Debugging Story 2 — The Budget Alert That Never Arrived</h3>
<p>After terraform apply completed, I invoked the Lambda, checked the CloudWatch dashboard, and waited for the budget notification. Nothing arrived.</p>
<p>Fifteen minutes later I realized I had not confirmed the SNS email subscription. AWS had sent a confirmation email immediately after the SNS subscription resource was created. I had missed it in my inbox. Budget alerts and Lambda reports are silently discarded for unconfirmed email subscriptions. There is no error. No retry. No warning. The notification just disappears.</p>
<p>The fix was finding the confirmation email, clicking the link, and waiting sixty seconds for the confirmed status to propagate. Every subsequent notification arrived correctly.\</p>
<img src="https://cdn.hashnode.com/uploads/covers/69a6e08356428acc6fe423a5/6e358158-dafa-44e6-9e41-a0d5f0c1c704.png" alt="" style="display:block;margin:0 auto" />

<p>The lesson is that SNS email subscription confirmation is not optional and is not visible anywhere in the AWS console if you miss it. Check your inbox immediately after terraform apply.</p>
<h3>Debugging Story 3 — The IAM Policy Condition That Did Nothing</h3>
<p>My first attempt at the IAM tagging Deny policy used StringLike instead of Null for the condition operator.</p>
<pre><code class="language-json">"Condition": {
  "StringLike": {
    "aws:RequestTag/Environment": ""
  }
}
</code></pre>
<p>This condition checks whether the tag value matches an empty string pattern. It does not trigger when the tag key is absent entirely. An EC2 instance could be created without any tags at all and this condition would not fire because StringLike requires the key to be present to evaluate the value.</p>
<p>The correct operator is Null with value "true", which evaluates to true specifically when the tag key is absent from the request. Once I made that change, the policy correctly blocked resource creation when any required tag was missing.</p>
<hr />
<h2>Metrics and Improvements</h2>
<p>Running this system for one month on a typical engineering account produces measurable results across all five control areas.</p>
<p>Idle instance detection catches instances that engineers forget to stop after testing, experimentation, or abandoned features. In an account with twenty running instances, an idle detection run typically finds two to four instances that have had near-zero CPU activity for more than 24 hours.</p>
<p>S3 lifecycle policies reduce storage costs progressively over time. In the first month the savings are small because objects need time to age into cheaper tiers. By month three, objects that have not been accessed begin transitioning to Glacier at a storage cost of approximately \(0.004 per GB per month compared to \)0.023 per GB in Standard tier.</p>
<p>Budget alerts at 50% give the engineering team two weeks of reaction time in a typical monthly spend cycle. By the time the alert fires, there is enough of the month remaining to investigate anomalies, stop unnecessary resources, and avoid breaching the full budget.</p>
<p>IAM tagging enforcement eliminates the tagging compliance problem at the source. After the policy is attached to the relevant IAM groups or roles, untagged resource creation simply fails. No remediation workflow. No audit cycle. No Config rule evaluation delay.</p>
<hr />
<h2>Production Considerations</h2>
<p>This platform covers the essential automated cost controls. A production deployment at engineering team scale would additionally require the following.</p>
<p>Multi-account cost aggregation via AWS Organizations and Cost Explorer at the management account level gives visibility across all accounts in the organization in a single view. Single-account Budget alerts are a starting point, not a complete solution for organizations with more than one account.</p>
<p>AWS Compute Optimizer analyses EC2 instance utilization patterns and recommends rightsizing from overprovisioned instance types to appropriately sized ones. This addresses a different category of waste than idle detection — instances that are always running but at 20% of their capacity because they were provisioned for a peak that never materialised.</p>
<p>AWS Cost Anomaly Detection uses machine learning to identify spend patterns that deviate from the historical baseline. This catches cost spikes that do not breach a fixed budget threshold but are anomalous relative to normal spending patterns. It is more sensitive than threshold-based alerting for detecting unexpected cost drivers.</p>
<p>Slack notifications via SNS to Lambda to webhook give the engineering team cost alerts in the channel they are already monitoring rather than requiring them to check a separate email inbox. Real-time alerts in Slack have a significantly faster response rate than email notifications for operational issues.</p>
<p>Terraform remote state with S3 backend and DynamoDB locking replaces local state management. This allows multiple engineers to run Terraform operations without state conflicts and stores state history for auditing and recovery.</p>
<hr />
<h2>Key Lessons Learned</h2>
<p><strong>The</strong> <code>archive_file</code> <strong>data source requires explicit dependency management</strong></p>
<p>Terraform does not always resolve the timing between data source evaluation and resource creation correctly for Lambda packaging. Use <code>depends_on</code> to make the dependency explicit and prevent upload failures caused by incomplete ZIP file creation.</p>
<p><strong>SNS email confirmation is invisible if missed</strong></p>
<p>There is no AWS console indicator that a subscription is unconfirmed and silently discarding messages. Check the inbox immediately after apply and confirm before testing any notification path.</p>
<p><strong>IAM Null condition versus StringLike is a meaningful difference</strong></p>
<p>Null evaluates key presence. StringLike evaluates key value. For tagging enforcement where the goal is to prevent keyless resource creation, Null is the correct operator. StringLike will not catch requests where the tag key is entirely absent.</p>
<p><strong>Terraform default_tags is the most reliable tagging mechanism</strong></p>
<p><code>Per-resource tag</code> blocks are skipped. <code>default_tags</code> at the provider level is never skipped. For any Terraform configuration where consistent tagging is a compliance requirement, <code>default_tags</code> is the correct implementation.</p>
<p><strong>EventBridge cron expressions are more operationally useful than rate expressions</strong></p>
<p>A rate(24 hours) expression runs relative to the function creation time. A cron expression runs at a predictable time each day. For cost optimization, receiving the idle instance report at 6am UTC before the team starts work is more useful than receiving it at a random time determined by when the Lambda was first deployed.</p>
<hr />
<h2>How This Helps Companies</h2>
<p>The startup that received a $34,000 bill in January 2023 was not unusual. The pattern of uncontrolled cloud spend driven by forgotten instances, untiered storage, and missing tags repeats across engineering organizations at every scale.</p>
<p>The five controls in this platform directly address the specific failure modes that caused that bill. Idle detection would have stopped the fourteen forgotten load test instances within 24 hours. S3 lifecycle policies would have tiered the 2.1 terabytes of build artifacts automatically. IAM tagging enforcement would have prevented the creation of the unattributed instances entirely. Budget alerts at 50% would have surfaced the anomaly weeks before the invoice arrived.</p>
<p>The skills demonstrated here apply directly to any engineering team that is running AWS infrastructure and wants to move from reactive cost management to proactive cost control.</p>
<p>I am actively looking for remote DevOps and Cloud Engineering roles globally. If your team is building the kind of automated infrastructure controls described in this article, I want to contribute to that work.</p>
<p>Full code is at: <a href="https://github.com/Eaglewings966/aws-cost-optimization">https://github.com/Eaglewings966/aws-cost-optimization</a></p>
<p>Destroy your environment when done:</p>
<pre><code class="language-plaintext">cd terraform &amp;&amp; terraform destroy --auto-approve
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/69a6e08356428acc6fe423a5/5520d65a-1d48-4f35-a292-f31b2332f419.png" alt="" style="display:block;margin:0 auto" />

<p><em>Emmanuel Ubani is a Cloud and DevOps Engineer based in Lagos, Nigeria. He writes about real infrastructure builds, career transitions into tech, and lessons learned the hard way.</em></p>
<p><em>LinkedIn:</em> <a href="https://linkedin.com/in/ubaniemmanuel"><em>linkedin.com/in/ubaniemmanuel</em></a></p>
<p><em>GitHub:</em> <a href="https://github.com/Eaglewings966"><em>github.com/Eaglewings966</em></a></p>
<p><em>Hashnode Blog:</em> <a href="https://emmanuelubani.hashnode.dev"><em>emmanuelubani.hashnode.dev</em></a></p>
<p><a href="https://emmanuelubani.hashnode.dev"><em>Medium Article:</em></a> <a href="https://medium.com/@emmaubani966"><em>medium.com/@emmaubani966</em></a></p>
<p><a href="https://ops-run.lovable.app"><em>Portfolio:</em> <em>ops-run.lovable.app</em></a></p>
]]></content:encoded></item><item><title><![CDATA[How I Built a Production GitOps Platform on AWS EKS Fargate That
Solves the Deployment Crisis Every Fast-Growing Engineering Team Hits]]></title><description><![CDATA[App of Apps, canary progressive delivery, Fargate networking constraints, real debugging stories, and an honest gap analysis between portfolio and…]]></description><link>https://emmanuelubani.hashnode.dev/how-i-built-a-production-gitops-platform-on-aws-eks-fargate-that-solves-the-deployment-crisis-every-fast-growing-engineering-team-hits</link><guid isPermaLink="true">https://emmanuelubani.hashnode.dev/how-i-built-a-production-gitops-platform-on-aws-eks-fargate-that-solves-the-deployment-crisis-every-fast-growing-engineering-team-hits</guid><dc:creator><![CDATA[Ubani Obiajulum Emmanuel]]></dc:creator><pubDate>Tue, 31 Mar 2026 10:12:09 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/69a6e08356428acc6fe423a5/0a9bcf33-a463-4fdc-9f7e-cb08894efb65.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<h2>The Incident That Convinced Me GitOps Was Not Optional</h2>
<p>In 2021, a well-funded fintech startup in London had a deployment process that looked perfectly reasonable on paper. Developers pushed code to GitHub. A CI pipeline ran tests. A senior engineer reviewed the build. Someone ran <code>kubectl apply</code> against the production cluster.</p>
<p>It worked most of the time.</p>
<p>On a Tuesday afternoon in November, it did not.</p>
<p>A configuration change for a new payment processor integration was deployed to production. The change passed all automated tests. It passed human review. It was applied to the cluster at 2:47pm.</p>
<p>By 3:04pm, the payment processing service had crashed across all replicas. The database connection pool had been exhausted by a misconfigured retry loop in the new integration. Every transaction request was timing out. Seventeen minutes after the deployment, the engineering team discovered the problem — not from their monitoring system, which had no alerting for connection pool exhaustion but from customer support receiving 340 failure reports in their inbox.</p>
<p>The rollback took 38 minutes. Not because the fix was complex. Because nobody could agree on which state the cluster should return to. The only deployment history was the engineer's memory of what <code>kubectl apply</code> command had been run. There was no Git reference point for the previous cluster state because the deployment had never been tracked in Git in the first place.</p>
<p>Estimated revenue loss for that 55-minute incident: $280,000.</p>
<p>The engineering post-mortem identified four root causes.</p>
<p>No Git-based deployment tracking meant no clean rollback target. No progressive delivery meant 100% of users hit the bad release simultaneously. No self-healing meant configuration drift from previous manual changes had gone undetected for weeks and contributed to the failure mode. No audit trail meant the post-mortem could not definitively identify what state the cluster was in before the deployment.</p>
<p>All four of those root causes have one solution.</p>
<p><em>"GitOps"</em>.</p>
<p>That is what this project builds.</p>
<img src="https://cdn.hashnode.com/uploads/covers/69a6e08356428acc6fe423a5/0c266de4-6159-42e8-8f5b-2f149db14ef6.png" alt="" style="display:block;margin:0 auto" />

<hr />
<h2>What GitOps Actually Solves</h2>
<p>Before I describe what I built, I want to be precise about what GitOps is solving and what it is not.</p>
<p>GitOps is not a deployment automation tool. It is an operational model that moves cluster authority from the people operating the cluster to the Git repository. In a traditional model, the cluster reflects whatever engineers have done to it recently. kubectl apply, manual edits, configuration patches during incidents. The cluster is the source of truth.</p>
<p>In a GitOps model, Git is the source of truth. The cluster is an execution environment that continuously tries to match what Git describes. Any divergence is automatically corrected. Every change to the cluster must come through Git, which means every change is reviewed, version-controlled, timestamped, and attributed.</p>
<p>This eliminates configuration drift because drift cannot persist beyond the next reconciliation cycle. It provides a clean rollback target because the previous cluster state is the previous Git commit. It makes progressive delivery the natural default because the delivery controller reads from Git on every sync.</p>
<p>The fintech incident I described could not have unfolded the same way in a GitOps environment. The deployment would have been a Git push. The rollback would have been a Git revert. The root cause investigation would have started with a git diff. And if progressive delivery had been in place, the bad configuration would have hit 20% of users before promotion, not 100%.</p>
<hr />
<h2>Who I Am and Why I Built This</h2>
<p>I am Emmanuel Ubani, a Cloud and DevOps Engineer based in Lagos, Nigeria. Before I wrote a single line of Terraform, I was a zoo volunteer tourist guard, an automotive technician apprentice, and a beauty store manager. I transitioned into tech in January 2024.</p>
<p>I tell you this not as decoration but as context for how I approach learning. I did not come from a background where infrastructure was familiar. Every concept I understand, I understand because I broke it first and then fixed it. This article is written from that place. I will show you what I built, how I built it, what broke during the build, and what I would do differently for a production deployment.</p>
<p>I am going to give both the engineer who already runs Kubernetes in production and the engineer who is building their first real cluster something worth reading.</p>
<hr />
<h2>What I Built</h2>
<p>I built a production-grade GitOps platform on AWS EKS Fargate with the following components working together as a coherent system.</p>
<p>An EKS Fargate cluster provisioned with eksctl. Zero EC2 instances. Zero node groups. AWS provisions the exact compute each pod needs, invisibly, per pod.</p>
<p>Terraform with the Helm provider managing Argo CD and Argo Rollouts installation as version-controlled infrastructure. The platform tooling itself is infrastructure as code.</p>
<p>Argo CD implementing the App of Apps pattern. One root application watches a folder in my GitHub repository. Every manifest file in that folder is automatically registered as a child application.</p>
<p>Argo Rollouts handling canary progressive delivery. New versions receive 20% of traffic first, then 50%, then 100%. Automatic rollback if health checks fail at any step.</p>
<p>GitHub Actions closing the GitOps delivery loop. Every push to main triggers an immediate Argo CD sync. The entire cycle from code change to cluster update is automated.</p>
<img src="https://cdn.hashnode.com/uploads/covers/69a6e08356428acc6fe423a5/f720017a-48d9-4bcb-b073-d1edb22a27d2.png" alt="" style="display:block;margin:0 auto" />

<hr />
<h2>Architecture Decisions — The Why Behind Each Choice</h2>
<h3>Why App of Apps</h3>
<p>If you register applications individually in Argo CD, you create a tight coupling between your platform team and the Argo CD server. Every time a new service needs to be onboarded, someone has to touch Argo CD directly. At three services this is manageable. At thirty services it is a bottleneck.</p>
<p>The App of Apps pattern removes this coupling completely.</p>
<p>One root application watches a specific path in the Git repository. Every YAML file at that path is automatically treated as a child application and registered in Argo CD without any manual action. To add a new service to the platform, an engineer creates one YAML file, pushes it to Git, and waits three minutes. The platform does the rest.</p>
<p>The trade-off is one additional sync layer and a dependency on sync-wave annotations when services have startup ordering requirements. For most platforms, that trade-off is worth it.</p>
<h3>Why Fargate Over EC2 Node Groups</h3>
<p>Fargate removes an entire category of operational work from the platform team. No AMI version management. No cluster autoscaler configuration. No node drain sequences during Kubernetes upgrades. No capacity planning for the worker node pool. No node-level security patching.</p>
<p>The trade-offs are real. Fargate pods start slower than EC2 pods because AWS provisions compute per pod rather than using pre-warmed node capacity. Per-pod cost is higher than equivalent EC2 capacity at sustained utilisation. All Fargate pods run in private subnets, which constrains external access patterns and requires additional networking components for production exposure.</p>
<p>For a platform running Argo CD and supporting workloads, those trade-offs are acceptable. The operational simplification is significant enough to justify them.</p>
<p>There was also a practical dimension to this decision. My AWS account had hard limits that blocked standard EKS node group provisioning. My vCPU quota was 1 and my EC2 Fleet Request quota was effectively 0. I tried four different approaches before discovering that Fargate bypasses both constraints entirely. What started as a workaround turned out to be a genuinely better architecture for this use case.</p>
<h3>Why Argo Rollouts Over Kubernetes Rolling Updates</h3>
<p>A standard Kubernetes Deployment rolling update gives you zero traffic control during the transition. Old and new pod versions coexist temporarily but the distribution of traffic between them is not configurable. There is no mechanism to validate behavior at 20% of users before continuing.</p>
<p>Argo Rollouts introduces two separate Kubernetes services, one for stable traffic and one for canary traffic, and a controller that manages the weighted distribution between them at each step. At 20%, one pod revision receives the canary service traffic and the other receives the stable service traffic. The controller advances the weight only when the configured pause duration passes and health checks confirm the canary pods are healthy.</p>
<p>If health checks fail at any step, the entire rollout stops. Traffic returns to 100% stable. No manual intervention required. This is what prevents the fintech incident I described from unfolding the way it did.</p>
<h3>Why Terraform Helm Provider Over Manual helm install</h3>
<p>A manual helm install command is not reproducible across environments. It produces no infrastructure state that tooling can inspect or modify. It cannot be included in a terraform plan output. It cannot be rolled back to a previous version via a single command.</p>
<p>The Terraform Helm provider makes Argo CD a first-class infrastructure resource. It appears in plan output. It can be updated via apply. It is destroyed cleanly via destroy. And critically, it allows per-component resource requests to be set declaratively, which is a hard requirement on Fargate where every pod must have explicit CPU and memory requests meeting the minimum Fargate thresholds.</p>
<img src="https://cdn.hashnode.com/uploads/covers/69a6e08356428acc6fe423a5/4248a9b7-e6ef-4eb0-821d-75d621c66d49.png" alt="" style="display:block;margin:0 auto" />

<hr />
<h2>Implementation — The Full Build</h2>
<h3>Step 1 — Check Your AWS Account Constraints First</h3>
<p>Before provisioning anything, identify your account-level restrictions. Two quotas matter for EKS on a new AWS account.</p>
<pre><code class="language-javascript"># Check running On-Demand vCPU quota
aws service-quotas get-service-quota \
  --service-code ec2 \
  --quota-code L-1216C47A \
  --region us-east-1

# Check EC2 Fleet Request quota
aws service-quotas get-service-quota \
  --service-code ec2 \
  --quota-code L-34B43A08 \
  --region us-east-1
</code></pre>
<p>If your vCPU quota is below 4 or your Fleet Request quota is 0, standard EKS managed node groups will fail. Use Fargate. It bypasses both constraints entirely and is a legitimate production architecture, not a workaround.</p>
<h3>Step 2 — Create the EKS Fargate Cluster</h3>
<pre><code class="language-javascript">eksctl create cluster \
  --name emmanuel-gitops \
  --region us-east-1 \
  --fargate

aws eks update-kubeconfig \
  --region us-east-1 \
  --name emmanuel-gitops

kubectl get nodes
</code></pre>
<p>eksctl provisions the VPC, public and private subnets, NAT Gateway, EKS control plane, and a default Fargate profile in a single operation. The entire process takes 15 to 20 minutes.</p>
<p>When <code>kubectl get nodes</code> returns nothing, this is correct behaviour on a Fargate cluster. Fargate has no persistent node pool to list. Compute is provisioned per pod on demand and is not visible as a node object in Kubernetes.</p>
<img src="https://cdn.hashnode.com/uploads/covers/69a6e08356428acc6fe423a5/cf9fe6e8-ab3c-4039-9b1d-541dccda2d14.png" alt="" style="display:block;margin:0 auto" />

<h3>Step 3 — Create Fargate Profiles for Every Namespace</h3>
<p>This is the most operationally important step in the entire Fargate setup and the one most tutorials understate.</p>
<p>On EKS Fargate, the scheduler cannot place a pod into a namespace unless a matching Fargate profile exists for that namespace. The default profile created by eksctl covers the default and kube-system namespaces only. Every additional namespace requires an explicit profile.</p>
<p>If you skip this and deploy a pod into an uncovered namespace, the pod will remain in Pending state indefinitely. There is no error message. No scheduler event that clearly identifies the cause. The pod just waits. This cost me significant debugging time before I understood the mechanism.</p>
<pre><code class="language-javascript">for ns in argocd argo-rollouts devops-demo; do
  eksctl create fargateprofile \
    --cluster emmanuel-gitops \
    --region us-east-1 \
    --name fp-${ns} \
    --namespace ${ns}
done

# Verify all profiles are Active before continuing
eksctl get fargateprofile \
  --cluster emmanuel-gitops \
  --region us-east-1
</code></pre>
<p>Wait until all three profiles show Active status before proceeding to Terraform. Profiles in a Creating state will not accept pod scheduling.</p>
<img src="https://cdn.hashnode.com/uploads/covers/69a6e08356428acc6fe423a5/c2c4afa6-ecb8-4a95-881a-f5f24dc3ba85.png" alt="" style="display:block;margin:0 auto" />

<h3>Step 4 — Install Argo CD and Argo Rollouts via Terraform</h3>
<p>The Terraform configuration reads the existing EKS cluster via data sources, configures the Kubernetes and Helm providers against it, creates the three namespaces, and installs Argo CD and Argo Rollouts via <code>helm_release</code> resources.</p>
<p>The most important detail in this Terraform configuration is the resource request values. Argo CD installs six separate components as independent deployments. Every single one must have explicit CPU and memory requests that meet Fargate minimums of 250m CPU and 512Mi memory. Missing a single component means that component stays in Pending state and Argo CD operates in a degraded mode that fails in non-obvious ways.</p>
<p>Here is the Argo CD helm_release resource with all required resource requests:</p>
<pre><code class="language-javascript">resource "helm_release" "argocd" {
  name       = "argocd"
  repository = "https://argoproj.github.io/argo-helm"
  chart      = "argo-cd"
  version    = var.argocd_chart_version
  namespace  = kubernetes_namespace.argocd.metadata[0].name
  timeout    = 600

  set {
    name  = "configs.params.server\\.insecure"
    value = "true"
  }

  set { name = "server.resources.requests.cpu"           value = "250m"  }
  set { name = "server.resources.requests.memory"        value = "512Mi" }
  set { name = "controller.resources.requests.cpu"       value = "250m"  }
  set { name = "controller.resources.requests.memory"    value = "512Mi" }
  set { name = "repoServer.resources.requests.cpu"       value = "250m"  }
  set { name = "repoServer.resources.requests.memory"    value = "512Mi" }
  set { name = "redis.resources.requests.cpu"            value = "250m"  }
  set { name = "redis.resources.requests.memory"         value = "512Mi" }
  set { name = "applicationSet.resources.requests.cpu"   value = "250m"  }
  set { name = "applicationSet.resources.requests.memory" value = "512Mi" }
  set { name = "notifications.resources.requests.cpu"    value = "250m"  }
  set { name = "notifications.resources.requests.memory" value = "512Mi" }

  depends_on = [kubernetes_namespace.argocd]
}
</code></pre>
<pre><code class="language-javascript">cd terraform
terraform init
terraform apply --auto-approve

# Monitor pod startup — allow 5 to 8 minutes on Fargate
kubectl get pods -n argocd --watch
kubectl get pods -n argo-rollouts --watch
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/69a6e08356428acc6fe423a5/7e32d251-ea6b-4f3f-a3a5-f93db4fbeb18.png" alt="" style="display:block;margin:0 auto" />

<img src="https://cdn.hashnode.com/uploads/covers/69a6e08356428acc6fe423a5/7bb50797-8100-41bf-8246-348449ff2223.png" alt="" style="display:block;margin:0 auto" />

<h3>Step 5 — Access Argo CD and Deploy the Root Application</h3>
<pre><code class="language-javascript"># Retrieve the initial admin password
kubectl -n argocd get secret argocd-initial-admin-secret \
  -o jsonpath='{.data.password}' | base64 -d &amp;&amp; echo

# Open port-forward — note this will drop intermittently on Fargate
kubectl port-forward svc/argocd-server -n argocd 8080:80

# Open http://localhost:8080 in browser
# Username: admin
# Password: retrieved above
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/69a6e08356428acc6fe423a5/6740a5e0-9ec0-402e-9787-397fb4e1f691.png" alt="" style="display:block;margin:0 auto" />

<p>Before applying the root application, push all manifests to GitHub. Argo CD reads from the remote Git repository, not the local filesystem. Applying the root app before pushing produces a ComparisonError state where Argo CD cannot find the child application manifests it was told to watch.</p>
<pre><code class="language-javascript">git add .
git commit -m "feat: production GitOps platform with canary delivery"
git push origin main

# Deploy the root application
kubectl apply -f argocd/apps/root-app.yaml

# Verify child apps are registered and syncing
argocd app list --insecure --server localhost:8080
</code></pre>
<p>Here is the root application manifest that implements App of Apps:</p>
<pre><code class="language-javascript">apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: root-app
  namespace: argocd
  finalizers:
    - resources-finalizer.argocd.argoproj.io
spec:
  project: default
  source:
    repoURL: https://github.com/Eaglewings966/argocd-gitops-platform
    targetRevision: HEAD
    path: argocd/apps
  destination:
    server: https://kubernetes.default.svc
    namespace: argocd
  syncPolicy:
    automated:
      prune: true
      selfHeal: true
    syncOptions:
      - CreateNamespace=true
      - PrunePropagationPolicy=foreground
      - PruneLast=true
</code></pre>
<p>The <code>prune: true</code> flag removes cluster resources that no longer have a corresponding Git manifest. The <code>selfHeal: true</code> flag reverts manual cluster changes that diverge from Git state. Together they enforce Git as the authoritative source of truth.</p>
<img src="https://cdn.hashnode.com/uploads/covers/69a6e08356428acc6fe423a5/bc4fdaec-efcc-4db5-8cb2-205ec3aa912e.png" alt="" style="display:block;margin:0 auto" />

<h3>Step 6 — The Canary Rollout</h3>
<p>Here is the rollout configuration that implements progressive delivery with 20/50/100 traffic weight steps:</p>
<pre><code class="language-javascript">apiVersion: argoproj.io/v1alpha1
kind: Rollout
metadata:
  name: devops-demo-rollout
  namespace: devops-demo
spec:
  replicas: 2
  selector:
    matchLabels:
      app: devops-demo
  template:
    metadata:
      labels:
        app: devops-demo
    spec:
      containers:
        - name: devops-demo
          image: eaglewings6/devops-demo-app:latest
          ports:
            - containerPort: 3000
          resources:
            requests:
              memory: "512Mi"
              cpu: "250m"
            limits:
              memory: "512Mi"
              cpu: "500m"
          livenessProbe:
            httpGet:
              path: /
              port: 3000
            initialDelaySeconds: 15
            periodSeconds: 20
          readinessProbe:
            httpGet:
              path: /
              port: 3000
            initialDelaySeconds: 10
            periodSeconds: 10
  strategy:
    canary:
      stableService: devops-demo-stable
      canaryService: devops-demo-canary
      steps:
        - setWeight: 20
        - pause:
            duration: 60s
        - setWeight: 50
        - pause:
            duration: 60s
        - setWeight: 100
      maxSurge: 1
      maxUnavailable: 0
</code></pre>
<p>Watch the rollout progress in real time:</p>
<pre><code class="language-javascript">kubectl argo rollouts get rollout devops-demo-rollout \
  -n devops-demo --watch
</code></pre>
<p>You will see the traffic weight change in your terminal as each step completes. 20% to 50% to 100%. Every step is gated by a health check. If the readiness probe fails at 20%, the rollout stops. Traffic returns to 100% stable. Nobody gets paged.</p>
<img src="https://cdn.hashnode.com/uploads/covers/69a6e08356428acc6fe423a5/627c0dae-f41f-4963-9382-4043779656aa.png" alt="" style="display:block;margin:0 auto" />

<img src="https://cdn.hashnode.com/uploads/covers/69a6e08356428acc6fe423a5/0f140b21-d3d2-46ed-8f5a-2fc99ad838ef.png" alt="" style="display:block;margin:0 auto" />

<hr />
<h2>What Broke During This Build — The Real Debugging Stories</h2>
<h3>Debugging Story 1 — The Silent Pending State</h3>
<p>After creating the EKS cluster and running terraform apply, I deployed the Argo CD manifests and waited for the pods to start. Five minutes passed. Eight minutes passed. The pods were still in Pending state.</p>
<p>I ran <code>kubectl describe pod</code> on one of the Argo CD pods.</p>
<pre><code class="language-javascript">Events:
  Warning  FailedScheduling  default-scheduler
  0/0 nodes are available: 0 Fargate nodes available.
</code></pre>
<p>Zero Fargate nodes available. I had created the cluster with Fargate enabled but had not created a Fargate profile for the argocd namespace. The default profile only covered kube-system and default. The scheduler could not place the pods anywhere. The fix was creating the Fargate profile for the argocd namespace before reapplying. The pods started within four minutes of the profile reaching Active status. The lesson is that Fargate profiles are not optional configuration. They are the scheduling permission system. No profile means no compute assignment for that namespace. Create them before deploying.</p>
<h3>Debugging Story 2 — The Argo CD CLI Refusing Connections</h3>
<p>After Argo CD was running and I had opened the port-forward session, I tried to login via the CLI.</p>
<pre><code class="language-javascript">argocd login localhost:8080 
--username admin 
--password [PASSWORD] 
--insecure
</code></pre>
<p>The response was <code>FATA[0000] dial tcp [::1]:8080: connect: connection refused</code></p>
<img src="https://cdn.hashnode.com/uploads/covers/69a6e08356428acc6fe423a5/55a1e286-8e8b-4d5a-807d-6cc84078539f.png" alt="" style="display:block;margin:0 auto" />

<p>The UI was loading fine in the browser. The CLI was getting a connection refused error. They were hitting the same port on the same localhost address.</p>
<p>The cause was that the port-forward session had dropped between when I opened the browser tab and when I ran the CLI command. The browser had retried the connection transparently. The CLI had not. By the time the CLI command ran, the port-forward process had terminated. The fix was to restart the port-forward session in a separate terminal window and keep it running as a background process.</p>
<pre><code class="language-javascript">
kubectl port-forward svc/argocd-server -n argocd 8080:80
</code></pre>
<p>The deeper lesson is that port-forward on Fargate is inherently unstable for anything requiring persistent connectivity. A browser handles reconnection silently. A CLI tool and a CI/CD pipeline do not. For automated operations, you need a stable network endpoint.</p>
<h3>Debugging Story 3 — The Terraform Plan That Would Not Run</h3>
<p>After creating the cluster with eksctl, I ran <code>terraform init</code> and <code>terraform plan</code> inside the terraform directory. The plan failed immediately.</p>
<pre><code class="language-javascript">
Error: Invalid provider configuration
Provider "registry.terraform.io/hashicorp/kubernetes"
requires explicit configuration.
</code></pre>
<p>The Kubernetes and Helm providers were configured using data sources that read the cluster endpoint and certificate from the EKS API. But the plan was failing before reaching those data sources.</p>
<p>The cause was that I had not yet run <code>aws eks update-kubeconfig</code> after creating the cluster. The Terraform Kubernetes provider was falling back to the local kubeconfig, which had no entry for the new cluster yet.</p>
<p>The fix was running <code>aws eks update-kubeconfig</code> before <code>terraform plan</code>. Once the local kubeconfig had the cluster entry, the data sources resolved correctly and the plan succeeded.</p>
<img src="https://cdn.hashnode.com/uploads/covers/69a6e08356428acc6fe423a5/e1086870-833f-4f4c-b719-4c96f4b6fe01.png" alt="" style="display:block;margin:0 auto" />

<h3>Debugging Story 4 — AWS Blocking ELB Provisioning</h3>
<p>When I tried to expose the Argo CD server via a LoadBalancer service type, the service remained in Pending external IP state indefinitely.</p>
<pre><code class="language-javascript">
kubectl get svc argocd-server -n argocd NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) argocd-server LoadBalancer 172.20.84.123 80:30080/TCP
</code></pre>
<p>The underlying cause was an account-level AWS restriction on ELB provisioning. The restriction was independent of VPC configuration or subnet settings. AWS was denying the ELB creation at the account level.</p>
<p>The workaround for this project was to switch to port-forward for UI access and document that the full production solution requires the AWS Load Balancer Controller, which provisions ALBs through Ingress resources rather than LoadBalancer services and is not subject to the same account restriction.</p>
<hr />
<h2>Production Gap Analysis</h2>
<p>This platform is a functional demonstration of the GitOps delivery model. A production deployment at engineering team scale would require these additional components:</p>
<p><strong>ALB Controller for stable Argo CD exposure</strong></p>
<p>The AWS Load Balancer Controller provisions ALBs through Kubernetes Ingress resources rather than Service type LoadBalancer. This provides a stable HTTPS endpoint with ACM certificate termination and Route 53 DNS. It enables browser access, CLI access, and GitHub Actions pipeline connectivity without port-forward sessions.</p>
<p><strong>IRSA for pod-level AWS identity</strong></p>
<p>Pods that interact with AWS services should operate under IAM Roles for Service Accounts. IRSA binds an IAM role to a Kubernetes service account via an OIDC trust relationship, giving each workload a scoped identity with minimum required permissions rather than relying on the broader Fargate execution role.</p>
<p><strong>External Secrets Operator for secret management</strong></p>
<p>Kubernetes Secrets store values as base64-encoded strings without encryption at rest by default. External Secrets Operator with AWS Secrets Manager provides automatic secret rotation, encryption at rest, and eliminates the pattern of managing secrets manually in the cluster.</p>
<p><strong>Argo CD SSO for team access</strong></p>
<p>The default admin credential is a single shared password with no user-level audit trail. Argo CD's Dex component supports OIDC integration with GitHub, Okta, and Google Workspace, providing named user authentication and per-user activity logs for every sync and rollback operation.</p>
<p><strong>Container image security scanning</strong></p>
<p>The GitHub Actions workflow should include a container vulnerability scanning step using Trivy or Grype before the Argo CD sync is triggered. This prevents images with known critical CVEs from reaching the cluster.</p>
<p><strong>Full GitHub Actions GitOps loop</strong></p>
<p>The gitops-sync.yml workflow is committed and correct. It requires a network-reachable Argo CD endpoint to execute fully. That endpoint exists once the ALB Controller is deployed. The workflow is ready. The infrastructure is not yet complete for full automation in this environment.</p>
<hr />
<h2>Metrics and Impact</h2>
<table>
<thead>
<tr>
<th>Capability</th>
<th>Without This Platform</th>
<th>With This Platform</th>
</tr>
</thead>
<tbody><tr>
<td>Deployment audit trail</td>
<td>None</td>
<td>Full Git history</td>
</tr>
<tr>
<td>Configuration drift</td>
<td>Undetected and persistent</td>
<td>Auto-corrected within 3 minutes</td>
</tr>
<tr>
<td>Bad release blast radius</td>
<td>100% of users</td>
<td>Maximum 20% at canary step 1</td>
</tr>
<tr>
<td>Rollback mechanism</td>
<td>Manual, 15 to 45 minutes</td>
<td>Automatic, under 60 seconds</td>
</tr>
<tr>
<td>New service onboarding</td>
<td>Manual Argo CD registration</td>
<td>One YAML file in Git</td>
</tr>
<tr>
<td>Deployment traceability</td>
<td>Engineer memory</td>
<td>Git commit hash, author, timestamp</td>
</tr>
<tr>
<td>Infrastructure reproducibility</td>
<td>Cannot recreate from scratch</td>
<td>Full Terraform and eksctl commands</td>
</tr>
</tbody></table>
<hr />
<h2>Key Technical Lessons</h2>
<p><strong>Fargate networking is a different compute model</strong> Fargate is not EC2 with node management removed. It is a fundamentally different compute model with different networking semantics. Port-forward stability, load balancer provisioning, and pod scheduling all behave differently on Fargate. Understand the networking model before choosing Fargate for a production platform.</p>
<p><strong>GitOps self-healing is a governance mechanism</strong> Teams that adopt GitOps as a deployment tool get deployment automation. Teams that adopt it as a governance model get configuration drift prevention, audit trails, and the guarantee that the cluster always reflects an approved state. The difference is in how you frame it internally and what policies you enforce around it.</p>
<p><strong>Partial automation is documented architecture</strong> The GitHub Actions GitOps loop in this project requires an ALB endpoint to execute fully. That endpoint is not available in this environment. The workflow is committed, documented, and ready. Documenting what exists, what is blocked, and what the path to close the gap is demonstrates the same engineering depth as a fully operational system.</p>
<p><strong>Resource request completeness on Fargate is binary</strong> On EC2, missing resource requests result in best-effort scheduling. On Fargate, missing requests at minimum thresholds result in pods never scheduling. This is not a degraded mode. It is a hard failure. Treat resource requests as correctness requirements, not optimisation suggestions.</p>
<hr />
<h2>How This Helps Companies</h2>
<p>The four failure modes that caused the fintech incident are not unusual. They are the default state of Kubernetes operations without intentional architecture to address them.</p>
<p>Configuration drift accumulates in every cluster where engineers have direct kubectl access and no automated reconciliation. Deployment blast radius is 100% in every environment without progressive delivery. Rollback is manual and slow in every system without a clear Git-based deployment history. And audit trails are absent in every cluster where deployments are not tracked in version control.</p>
<p>This platform addresses all four. The skills demonstrated here apply directly to any engineering team running Kubernetes in production and looking to improve their delivery reliability, reduce deployment risk, and introduce progressive delivery without replacing their existing toolchain.</p>
<p>I am actively looking for DevOps and Cloud Engineering roles globally, including remote positions. If your team is working on problems like this, I would like to be part of solving them.</p>
<p>Full code is at: [github.com/Eaglewings966/argocd-gitops-platform](http:// <a href="https://github.com/Eaglewings966/argocd-gitops-platform">https://github.com/Eaglewings966/argocd-gitops-platform</a>)</p>
<p>Destroy your environment when you are done:</p>
<pre><code class="language-javascript">kubectl delete application --all -n argocd kubectl delete namespace devops-demo argo-rollouts argocd 
--ignore-not-found=true cd terraform &amp;&amp; terraform destroy --auto-approve eksctl delete cluster --name emmanuel-gitops --region us-east-1
</code></pre>
<p>Total spend for this project was approximately $5. Project 5 in this series is AWS Cost Optimization — building the automated system that finds and eliminates wasted cloud spend across a multi-service AWS environment.</p>
<blockquote>
<p>Emmanuel Ubani is a Cloud and DevOps Engineer based in Lagos, Nigeria. He writes about real infrastructure builds, career transitions into tech, and lessons learned the hard way.</p>
<p>LinkedIn: <a href="https://linkedin.com/in/ubaniemmanuel">linkedin.com/in/ubaniemmanuel</a></p>
<p>GitHub: <a href="https://github.com/Eaglewings966">github.com/Eaglewings966</a></p>
<p>Hashnode: <a href="http://emmanuelubani.hashnode.dev">emmanuelubani.hashnode.dev</a></p>
<p>Medium: <a href="https://medium.com/@emmaubani966">medium.com/@emmaubani966</a></p>
<p>Portfolio: <a href="http://ops-run.lovable.app">ops-run.lovable.app</a></p>
</blockquote>
]]></content:encoded></item><item><title><![CDATA[I Deployed Kubernetes on AWS EKS — And Hit Every Quota Wall AWS Could Throw At Me]]></title><description><![CDATA[The real story of Fleet Request errors, vCPU limits, Spot failures, and how Fargate finally saved the day.]]></description><link>https://emmanuelubani.hashnode.dev/i-deployed-kubernetes-on-aws-eks-and-hit-every-quota-wall-aws-could-throw-at-me</link><guid isPermaLink="true">https://emmanuelubani.hashnode.dev/i-deployed-kubernetes-on-aws-eks-and-hit-every-quota-wall-aws-could-throw-at-me</guid><category><![CDATA[Kubernetes]]></category><category><![CDATA[aws-fargate]]></category><category><![CDATA[Terraform]]></category><category><![CDATA[Devops]]></category><category><![CDATA[Cloud Computing]]></category><dc:creator><![CDATA[Ubani Obiajulum Emmanuel]]></dc:creator><pubDate>Mon, 23 Mar 2026 09:44:02 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/69a6e08356428acc6fe423a5/70a88521-d7ec-458f-83c5-be0099849e21.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>I did not come from a traditional tech background. Before Cloud and DevOps, I was volunteering at a zoo, working as an automotive technician apprentice, and running a beauty store. Tech was not my first world — but infrastructure became the thing that made sense to me.</p>
<p>And nothing tests your infrastructure thinking like EKS.</p>
<p>I expected this project to take a few hours. It took days. Not because my code was wrong, but because AWS account restrictions blocked every path I tried. This is the story of what actually happened, what I learned, and how I finally got a Node.js application running on Kubernetes on AWS EKS using Fargate.</p>
<hr />
<h2>What I Set Out to Build</h2>
<ul>
<li><p>AWS EKS cluster provisioned with Terraform</p>
</li>
<li><p>Custom VPC with two public subnets across two availability zones</p>
</li>
<li><p>IAM roles for the cluster and node group with least-privilege policies</p>
</li>
<li><p>Kubernetes Deployment with liveness and readiness probes</p>
</li>
<li><p>Kubernetes LoadBalancer Service for external access</p>
</li>
<li><p>Full infrastructure teardown with a single command</p>
</li>
</ul>
<img src="https://cdn.hashnode.com/uploads/covers/69a6e08356428acc6fe423a5/6610566a-7c40-41f9-a88b-14d22ae522f2.png" alt="" style="display:block;margin:0 auto" />

<hr />
<h2>The First Wall — EC2 vCPU Quota of 1</h2>
<p>Before I even ran <code>terraform apply</code>, I should have checked this:</p>
<pre><code class="language-bash">aws service-quotas get-service-quota \
  --service-code ec2 \
  --quota-code L-1216C47A \
  --region us-east-1
</code></pre>
<p>My account had a vCPU limit of 1. Not 5 like AWS documentation suggests for new accounts. Just 1.</p>
<p>A single t3.medium instance needs 2 vCPUs. So my node group was never going to launch — not even one node.</p>
<p>I submitted a quota increase request to AWS Support. The first request for 10 vCPUs was rejected. The appeal that worked asked for 4 vCPUs and explicitly pointed out that 4 vCPUs is still below AWS's own standard default of 5 for new accounts.</p>
<img src="https://cdn.hashnode.com/uploads/covers/69a6e08356428acc6fe423a5/7fc73de3-191a-4ac2-a515-58f80a9c2cd1.png" alt="" style="display:block;margin:0 auto" />

<img src="https://cdn.hashnode.com/uploads/covers/69a6e08356428acc6fe423a5/4d391ee8-c6f8-4b47-8f37-113bbe3defd1.png" alt="" style="display:block;margin:0 auto" />

<hr />
<h2>The Second Wall — EC2 Fleet Request Quota</h2>
<p>Even after addressing the vCPU issue, every node group attempt failed with this error:</p>
<pre><code class="language-plaintext">You've reached your quota for maximum Fleet Requests for this account.
Launching EC2 instance failed.
</code></pre>
<p>This quota is completely separate from the vCPU quota. EKS managed node groups use EC2 Fleet internally to provision instances — and my account had a Fleet Request limit so low it blocked every launch attempt.</p>
<p>The worst part — this quota is not visible in the Service Quotas console and cannot be queried via AWS CLI. It is an invisible wall.</p>
<p>I tried three approaches to bypass it:</p>
<p><strong>Attempt 1 — EKS managed node group with On-Demand capacity</strong> Failed. EC2 Fleet error every time.</p>
<p><strong>Attempt 2 — EKS managed node group with Spot capacity</strong> Failed. Spot instances also route through EC2 Fleet internally.</p>
<p><strong>Attempt 3 — Launch template with managed node group</strong> Failed. The launch template changes how the instance is configured but not how it is requested from AWS capacity.</p>
<hr />
<h2>The Third Attempt — Self-Managed Node Group</h2>
<p>Amazon Q suggested using self-managed node groups, which use AWS Auto Scaling Groups directly and bypass EC2 Fleet entirely.</p>
<p>I updated my Terraform to use <code>aws_autoscaling_group</code> instead of <code>aws_eks_node_group</code>:</p>
<pre><code class="language-hcl">resource "aws_autoscaling_group" "eks_nodes" {
  name                = "${var.project_name}-nodes-asg"
  desired_capacity    = var.desired_nodes
  min_size            = var.min_nodes
  max_size            = var.max_nodes
  vpc_zone_identifier = [aws_subnet.public_a.id, aws_subnet.public_b.id]

  launch_template {
    id      = aws_launch_template.eks_nodes.id
    version = "$Latest"
  }
}
</code></pre>
<p>This bypassed the Fleet Request quota. But then hit the vCPU wall again:</p>
<pre><code class="language-plaintext">You have requested more vCPU capacity than your current vCPU limit of 1
</code></pre>
<p>The self-managed approach proved the Fleet Request issue was solved — but the vCPU limit of 1 was still the final blocker.</p>
<hr />
<h2>The Solution — AWS Fargate</h2>
<p>Fargate is serverless compute for containers. No EC2 instances. No vCPU quota. No Fleet Requests. Nothing.</p>
<p>One command creates the entire cluster:</p>
<pre><code class="language-bash">eksctl create cluster --name emmanuel-eks --region us-east-1 --fargate
</code></pre>
<p>Then add a Fargate profile for your application namespace:</p>
<pre><code class="language-bash">eksctl create fargateprofile \
  --cluster emmanuel-eks \
  --region us-east-1 \
  --name fp-devops-demo \
  --namespace devops-demo
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/69a6e08356428acc6fe423a5/21343f11-6092-44e3-9e21-bd725e881c75.png" alt="" style="display:block;margin:0 auto" />

<hr />
<h2>Deploying the Application</h2>
<p>With the cluster ready, deploying the app took less than 2 minutes:</p>
<pre><code class="language-bash">kubectl apply -f k8s/namespace.yaml
kubectl apply -f k8s/deployment.yaml
kubectl apply -f k8s/service.yaml
</code></pre>
<p>Watch the pods come up:</p>
<pre><code class="language-bash">kubectl get pods -n devops-demo -w
</code></pre>
<p>Both pods went from Pending to ContainerCreating to Running in under 60 seconds.</p>
<img src="https://cdn.hashnode.com/uploads/covers/69a6e08356428acc6fe423a5/c131e9a2-3bcc-434d-9af3-4ebc203dc119.png" alt="" style="display:block;margin:0 auto" />

<img src="https://cdn.hashnode.com/uploads/covers/69a6e08356428acc6fe423a5/989c2302-0873-43f4-aab3-b22c11737f9d.png" alt="" style="display:block;margin:0 auto" />

<hr />
<h2>Verifying the App is Live</h2>
<pre><code class="language-bash">kubectl port-forward -n devops-demo deployment/devops-demo-app 8080:3000
</code></pre>
<p>Then open your browser at <code>http://localhost:8080</code></p>
<img src="https://cdn.hashnode.com/uploads/covers/69a6e08356428acc6fe423a5/f5539d7d-8858-4af2-9d9c-67d10dd18bf4.png" alt="" style="display:block;margin:0 auto" />

<p>The app responded with:</p>
<pre><code class="language-json">{
  "status": "healthy",
  "message": "CI/CD pipeline is working",
  "author": "Emmanuel Ubani"
}
</code></pre>
<hr />
<h2>One Important Fargate Gotcha</h2>
<p>Fargate pods run in <strong>private subnets</strong>. Classic Load Balancers cannot register pods in private subnets as targets — so <code>kubectl get svc</code> will show a LoadBalancer hostname but requests to it will return empty responses.</p>
<p>For local access, port-forward works perfectly. For production external access, you need the AWS Load Balancer Controller which creates an Application Load Balancer instead of a Classic LB.</p>
<p>For portfolio purposes, port-forward proves the application is running correctly.</p>
<img src="https://cdn.hashnode.com/uploads/covers/69a6e08356428acc6fe423a5/4b1a9787-c7d9-40e9-8b29-d70db2ce4d1c.png" alt="" style="display:block;margin:0 auto" />

<img src="https://cdn.hashnode.com/uploads/covers/69a6e08356428acc6fe423a5/18fc2fbe-93d0-4d30-aa50-60bce8cc7d1c.png" alt="" style="display:block;margin:0 auto" />

<hr />
<h2>Cleanup — Always Destroy When Done</h2>
<pre><code class="language-bash">eksctl delete cluster --name emmanuel-eks --region us-east-1
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/69a6e08356428acc6fe423a5/66b5696d-9f31-46f1-a262-0212d7098ab5.png" alt="" style="display:block;margin:0 auto" />

<p>This removes everything — cluster, Fargate profiles, VPC, subnets, IAM roles. Zero orphaned resources. Zero ongoing charges.</p>
<hr />
<h2>Key Lessons From This Build</h2>
<p><strong>Always check your vCPU quota first.</strong> Run the service-quotas command before writing a single line of Terraform. It takes 10 seconds and saves days.</p>
<p><strong>EC2 Fleet Request quota is invisible.</strong> It does not appear in the Service Quotas console and cannot be queried via CLI. If you hit Fleet Request errors on a new account, Fargate is your fastest path forward.</p>
<p><strong>Fargate bypasses every EC2 restriction.</strong> No vCPU quota. No Fleet Requests. No instance type selection. AWS manages all the compute underneath.</p>
<p><strong>Fargate needs private subnets.</strong> Always use eksctl to create the cluster — it handles the VPC networking correctly with both public and private subnets.</p>
<p><strong>Never store projects in C:/Windows/System32.</strong> Git cannot write to protected Windows directories. Use C:/Projects or your user home folder.</p>
<p><strong>Never commit terraform.tfstate to GitHub.</strong> Check your .gitignore before every first push.</p>
<p><strong>Debugging is the job.</strong> I tried four different approaches before finding the one that worked. That is not failure — that is engineering.</p>
<hr />
<h2>Full Code</h2>
<p>Complete Terraform and Kubernetes <em>manifests are on my GitHub:</em></p>
<p><em>👉</em> <a href="https://github.com/Eaglewings966/eks-kubernetes-deployment">eks-kubernetes-deployment</a></p>
<hr />
<h2>What Is Next</h2>
<p>This is Project 3 in my Cloud and DevOps portfolio series. Next up is a production-grade GitOps platform on AWS EKS using Argo CD — implementing the App of Apps pattern used by companies like Spotify, Airbnb, and Goldman Sachs, with progressive delivery using Argo Rollouts and automated canary deployments.</p>
<p><strong>Previous projects in this series:</strong></p>
<ul>
<li><p>Project 1 — <a href="https://github.com/Eaglewings966/aws-iam-multi-account-setup">AWS IAM Multi-Account Setup</a></p>
</li>
<li><p>Project 2 — <a href="https://github.com/Eaglewings966/github-actions-cicd-pipeline">GitHub Actions CI/CD Pipeline</a></p>
</li>
</ul>
<hr />
<p><em>Emmanuel Ubani is a Cloud and DevOps Engineer based in Lagos, Nigeria. He writes about real infrastructure builds, career transitions into tech, and lessons learned the hard way.</em></p>
<p><a href="https://linkedin.com/in/ubaniemmanuel">LinkedIn</a> · <a href="https://github.com/Eaglewings966">GitHub</a> · <a href="https://emmanuelubani.hashnode.dev">Hashnode</a> · <a href="https://medium.com/@emmaubani966">Medium</a></p>
]]></content:encoded></item><item><title><![CDATA[How I Built a Production-Grade CI Pipeline With GitHub Actions and Docker]]></title><description><![CDATA[Most CI pipelines I see in the wild are broken in the same 3 ways. They test nothing. They push unscanned images. They have no gates between stages. Here…]]></description><link>https://emmanuelubani.hashnode.dev/how-i-built-a-production-grade-ci-pipeline-with-github-actions-and-docker</link><guid isPermaLink="true">https://emmanuelubani.hashnode.dev/how-i-built-a-production-grade-ci-pipeline-with-github-actions-and-docker</guid><dc:creator><![CDATA[Ubani Obiajulum Emmanuel]]></dc:creator><pubDate>Sun, 08 Mar 2026 21:06:25 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/69a6e08356428acc6fe423a5/8ba74132-6949-4252-8f39-8a8422ef4ea8.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Most CI pipelines I see in the wild are broken in the same 3 ways.</p>
<p>They test nothing. They push unscanned images. They have no gates between stages.</p>
<p>Here is what a proper one looks like — and exactly how I built it.</p>
<hr />
<h2>What I Built</h2>
<p>A full CI pipeline using GitHub Actions that does the following on every push to main:</p>
<p>→ Runs unit tests automatically</p>
<p>→ Lints the Dockerfile with Hadolint</p>
<p>→ Only if both pass — builds a Docker image and pushes it to Docker Hub</p>
<p>→ Tags the image with the commit SHA for full traceability</p>
<p>If tests fail, the image never gets built. If the Dockerfile has issues, the image never gets built. The pipeline has opinions and it enforces them.</p>
<hr />
<h2>Project Structure</h2>
<pre><code class="language-javascript">github-actions-ci-pipeline/
├── app/
│   ├── index.js
│   ├── index.test.js
│   └── package.json
├── Dockerfile
├── .dockerignore
├── .github/
│   └── workflows/
│       └── ci.yml
└── README.md
</code></pre>
<hr />
<h2>The Pipeline — ci.yml Explained</h2>
<p>The pipeline has three jobs:</p>
<h3>Job 1 — test</h3>
<p>Runs unit tests using Node.js before anything else happens. If this fails, the entire pipeline stops.</p>
<h3>Job 2 — lint-dockerfile</h3>
<p>Uses Hadolint to check the Dockerfile for bad practices. Things like running as root, using latest tags, or missing HEALTHCHECK instructions get flagged here.</p>
<h3>Job 3 — build-and-push</h3>
<p>This job only runs if both test and lint-dockerfile pass. It uses Docker Buildx for multi-platform builds and pushes the image to Docker Hub with three tags:</p>
<ul>
<li><p>latest (for main branch only)</p>
</li>
<li><p>branch name</p>
</li>
<li><p>commit SHA for full traceability</p>
</li>
</ul>
<img src="https://cdn.hashnode.com/uploads/covers/69a6e08356428acc6fe423a5/f6bede73-0f26-4c8c-bda5-ba7081662b4f.jpg" alt="" style="display:block;margin:0 auto" />

<hr />
<h2>Two Things I Paid Extra Attention To</h2>
<p>Multi-stage Docker build — separates the build environment from the production image. Smaller, cleaner, more secure.</p>
<p>GitHub Actions cache — caches Docker layers between runs so builds are faster after the first one.</p>
<p>These are not advanced techniques. They are just good engineering habits that a lot of teams skip.</p>
<hr />
<h2>The Lesson</h2>
<p>Most deployment issues are not deployment issues. They are integration issues that a good CI pipeline would have caught much earlier.</p>
<p>Gate your pipeline. Test before you build. Build before you deploy.</p>
<hr />
<h2>Full Code</h2>
<p>Complete pipeline code is on my GitHub: <a href="https://github.com/Eaglewings966/github-actions-ci-pipeline">https://github.com/Eaglewings966/github-actions-ci-pipeline</a></p>
]]></content:encoded></item><item><title><![CDATA[How I Built a Production-Style AWS VPC Using Terraform (No Console Clicking)]]></title><description><![CDATA[The console is great for learning. But it doesn't scale. You can't review a console click in a pull request. You can't reproduce it in staging with one…]]></description><link>https://emmanuelubani.hashnode.dev/how-i-built-a-production-style-aws-vpc-using-terraform-no-console-clicking</link><guid isPermaLink="true">https://emmanuelubani.hashnode.dev/how-i-built-a-production-style-aws-vpc-using-terraform-no-console-clicking</guid><dc:creator><![CDATA[Ubani Obiajulum Emmanuel]]></dc:creator><pubDate>Sun, 08 Mar 2026 09:00:01 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/69a6e08356428acc6fe423a5/e93b2891-3062-423f-8cd0-7497891aadd2.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<h3>If you're still building AWS infrastructure by clicking around in the console, this post is for you.</h3>
<p>The console is great for learning. But it doesn't scale.</p>
<p>You can't review a console click in a pull request. You can't reproduce it in staging with one command. You can't audit it when something breaks at 2am.</p>
<p>Infrastructure as Code fixes all three. Here's exactly how I built a production-style AWS VPC using Terraform — step by step.</p>
<hr />
<h2>What I Built</h2>
<ul>
<li><p>Custom VPC with a 10.0.0.0/16 CIDR block</p>
</li>
<li><p>Public subnet with an Internet Gateway and route table</p>
</li>
<li><p>Private subnet for internal workloads</p>
</li>
<li><p>Security group with least-privilege rules</p>
</li>
<li><p>Every resource tagged with environment and ownership metadata</p>
</li>
</ul>
<hr />
<h2>Project Structure</h2>
<pre><code class="language-javascript">terraform-aws-vpc/
├── main.tf
├── variables.tf
├── outputs.tf
├── terraform.tfvars
├── .gitignore
└── README.md
</code></pre>
<hr />
<h2>Step 1 — The VPC</h2>
<pre><code class="language-javascript">resource "aws_vpc" "main" {
  cidr_block           = var.vpc_cidr
  enable_dns_support   = true
  enable_dns_hostnames = true

  tags = {
    Name        = "${var.project_name}-vpc"
    Environment = "dev"
    ManagedBy   = "terraform"
  }
}
</code></pre>
<h3>Two things worth noting here:</h3>
<ul>
<li><p><code>enable_dns_support</code> and <code>enable_dns_hostnames</code> are both set to true. This is required if you ever want to resolve AWS service endpoints inside the VPC.</p>
</li>
<li><p>Every resource is tagged with <code>ManagedBy = "terraform"</code> so anyone looking at the AWS console immediately knows this is IaC-managed and should not be edited manually.</p>
</li>
</ul>
<hr />
<h2>Step 2 — Public vs Private Subnet</h2>
<p>The public subnet has <code>map_public_ip_on_launch = true</code> — any EC2 instance launched here gets a public IP automatically.</p>
<p>The private subnet has no such setting. Databases and internal services live here and are never directly reachable from the internet. That is intentional.</p>
<hr />
<h2>Step 3 — The Route Table</h2>
<p>The public route table has one route: <code>0.0.0.0/0</code> pointing to the Internet Gateway. This is what makes the public subnet actually public.</p>
<p>Without this route, even if your subnet is labeled public, traffic has nowhere to go.</p>
<hr />
<h2>The Lesson That Stuck With Me</h2>
<p>If you can't tear it down cleanly, you don't fully understand what you built.</p>
<p>terraform destroy is the most honest test of your IaC. Run it. If it errors, you have a dependency problem worth fixing now, not in production.</p>
<hr />
<h2>Full Code</h2>
<p>The complete Terraform code is available on my GitHub: <a href="https://github.com/Eaglewings966/terraform-aws-vpc">https://github.com/Eaglewings966/terraform-aws-vpc</a></p>
]]></content:encoded></item></channel></rss>