DEV Community

Cover image for From Source Code to Production: Building a Secure Docker Platform on AWS
Kene Ojiteli
Kene Ojiteli

Posted on

From Source Code to Production: Building a Secure Docker Platform on AWS

Introduction

There is a common idea in cloud-native engineering that Docker Compose is only a temporary step:

Use Docker Compose for development. Use Kubernetes for production.

That advice is reasonable in many situations, but it can hide an important question:

What actually makes a deployment production-oriented?

Is it the orchestrator?

Or is it the collection of engineering practices around the application: repeatable builds, automated delivery, restricted network exposure, vulnerability scanning, immutable artefacts, health validation, encrypted traffic, and predictable recovery?

I began thinking seriously about this after completing the previous stage of this project: a self-managed Kubernetes platform built with kubeadm on AWS.

Kubernetes taught me about control planes, worker nodes, service discovery, scheduling, networking, reconciliation, and cluster operations. It also made something else clear.

Many of the practices that make Kubernetes deployments reliable are not exclusive to Kubernetes. A deployment should already care about:

  • automated builds
  • immutable application versions
  • container security
  • reverse proxying
  • health checks
  • infrastructure configuration
  • observability
  • controlled network exposure.

Those principles should exist before an application ever reaches a Kubernetes cluster.

That realisation shaped the next stage of my project.

Instead of creating another demonstration in which containers merely started successfully, I wanted to discover how far I could take a Docker Compose platform before Kubernetes became necessary.

The central question became:

How do I move from application source code to a securely deployed production platform on AWS?

To answer it, I built a deployment path that connected:

Application source code
        ↓
Local validation
        ↓
Production Docker images
        ↓
Docker Compose
        ↓
GitHub Actions
        ↓
Dependency + image security checks
        ↓
GitHub Container Registry
        ↓
Amazon EC2
        ↓
Containerised Nginx
        ↓
Let's Encrypt + Certbot
        ↓
HTTPS
        ↓
Publicly accessible application
Enter fullscreen mode Exit fullscreen mode

What makes this article important to me, however, was not the final diagram. The architecture did not emerge perfectly formed.

I initially installed Nginx and Certbot directly on the EC2 host before later refactoring the edge into Docker Compose. Health checks failed even while applications were responding correctly. A backend container appeared healthy while its MongoDB configuration was missing. Nginx returned 502 Bad Gateway after a backend recreation. And at one point, the HTTPS configuration on disk was correct while the running Nginx process had not necessarily loaded it.

Those failures taught me considerably more than the successful commands did. This article documents that complete story.

Problem Statement

Many Docker tutorials stop as soon as the application runs:

docker build .
docker compose up -d
Enter fullscreen mode Exit fullscreen mode

That proves that containers can start. It does not yet answer the questions involved in operating an application:

  • How are application images versioned?
  • Where are the images stored?
  • How does a server receive a new release?
  • Should source code be copied to the production server?
  • Which services should be publicly reachable?
  • How are known vulnerabilities detected?
  • How does the frontend communicate with the backend?
  • How are application secrets supplied?
  • How is HTTP traffic encrypted?
  • How can the deployment be recreated on another server?
  • How do we verify that a release is actually serving traffic?

These are no longer individual Docker questions. They are platform-engineering questions.

The purpose of this phase was therefore not simply to containerise a quiz application. It was to understand how multiple technologies could work together to create a secure, repeatable, and traceable deployment process.

Project repository: View the source code and deployment configuration on GitHub

Project Objectives

I designed the project around engineering outcomes rather than around a checklist of tools.

Objective Engineering Purpose
Validate the application before containerisation Separate application defects from infrastructure and containerisation defects.
Build production Docker images Create consistent and portable runtime artefacts.
Use multi-stage builds Reduce image size and exclude unnecessary build-time components.
Orchestrate services with Docker Compose Define service networking, startup behaviour, environment configuration, and restart policies declaratively.
Automate builds with GitHub Actions Remove inconsistent manual build steps.
Scan images before publication Detect known high- and critical-severity vulnerabilities.
Store images in GitHub Container Registry (GHCR) Maintain centralised, versioned, and traceable deployment artefacts.
Deploy prebuilt artefacts to Amazon EC2 Run the platform in a realistic cloud environment.
Expose only the reverse proxy Reduce the public attack surface.
Enable HTTPS Encrypt client-to-platform traffic.
Prepare for observability Create a platform that could be measured and load-tested in the next phase.

Notice that “learn Docker” was not one of the objectives. Docker was one component of the solution. The actual goal was to build a dependable path from a source-code change to a running cloud deployment.

Platform Architecture

The application was a three-tier quiz system:

React frontend
      ↓
Express backend
      ↓
MongoDB Atlas
Enter fullscreen mode Exit fullscreen mode

Why MongoDB Atlas remained external

I deliberately kept MongoDB outside the Compose stack. Application containers are relatively easy to replace. Databases have different requirements around durable storage, backup, replication, access control, and recovery.

Using MongoDB Atlas allowed this phase to remain focused on application packaging, CI/CD, networking, deployment, reverse proxying, and TLS rather than database operations.

It also reinforced an important production principle:

Not every component must be self-managed. Selecting a managed service can be an engineering decision based on operational responsibility rather than convenience alone.

The final runtime architecture became:

                        Internet
                           │
                         HTTPS
                           │
                           ▼
                  kene-quiz.duckdns.org
                           │
                           ▼
                   EC2 Security Group
                      ports 80/443
                           │
                           ▼
                    ┌───────────┐
                    │   Nginx   │
                    └─────┬─────┘
                          │
                 Docker bridge network
                    ┌─────┴─────┐
                    ▼           ▼
               frontend:80   backend:3000
                                  │
                                  ▼
                             MongoDB Atlas
Enter fullscreen mode Exit fullscreen mode

Only Nginx published application ports to the EC2 host.

The frontend and backend remained inside Docker's private network, where Docker DNS allowed them to be reached using stable service names instead of ephemeral container IP addresses.

Only the reverse-proxy boundary is exposed publicly; the backend remains inside the Compose network.

Implementation

Validate the Application Before Containerisation

Before adding Docker, I established a known-good application baseline.

Data layer
I created the MongoDB Atlas cluster, configured access, populated the quiz data, and confirmed that the expected documents existed.

populate-db

mongoDB-cluster

Application layer
I started the Express backend and tested the expected endpoint with Postman. A successful response confirmed that:

  • the application could reach Atlas,
  • the route existed,
  • the backend could query the expected collection,
  • and the response structure matched the frontend's expectations.

API-test-on-postman

Presentation layer
Finally, I started the React application and confirmed that the quiz questions appeared in the browser.

quiz-question

This gave me a baseline that became extremely useful later. When Docker requests began failing, I knew I had introduced the problem somewhere after application validation.

Validate the simplest working form first, then add infrastructure one layer at a time.

Build Production Docker Images

The application worked locally, but its runtime still depended on my machine. A manual server installation would require me to reproduce the correct Node.js version, npm dependencies, frontend build tooling, runtime configuration, application files, and startup commands.

That approach would introduce configuration drift and make deployments dependent on the server's state. I needed a portable application artifact.

I used Docker images as the deployment units. The objective was not simply to place the application inside containers. I wanted the images to be reproducible, minimal, versioned, suitable for production, and independent of my development machine.

Frontend: separate build and runtime

The frontend required Node.js during compilation but not to serve the compiled static assets. That made it ideal for a multi-stage build:

FROM node:24-alpine AS build

WORKDIR /app
COPY package*.json ./
RUN npm ci

COPY . .
RUN npm run build

FROM nginx:1.29-alpine AS runtime
COPY --from=build /app/dist /usr/share/nginx/html
Enter fullscreen mode Exit fullscreen mode

The first stage compiled the React source. The second stage contained only:

  • the static build output,
  • the Nginx runtime,
  • and the configuration required to serve the application.

This prevented build tools and development dependencies from remaining inside the final runtime image.

frontend-build

Backend: minimise the runtime

The backend required Node.js at runtime, but it did not require all development dependencies. The two most important decisions were installing only production dependencies and running the process as a non-root user:

RUN npm ci --omit=dev
...
USER node
Enter fullscreen mode Exit fullscreen mode

backend-build

I also used .dockerignore so that unnecessary files were not sent into the build context. Environment files were excluded deliberately. Secrets should be supplied at runtime, not embedded into image layers.

What went wrong: runtime version mismatch

An early build problem exposed inconsistent Node.js across:
local development → Docker → GitHub Actions

A dependency expected a newer runtime than one part of the build environment provided. The fix was not to suppress the warning.

I aligned the supported runtime versions across the environments.

Runtime versions are part of application architecture. They should be explicitly controlled rather than inherited accidentally.

Define the Platform with Docker Compose

Docker images solved packaging, but they did not define how the services should operate together.

Starting the services through independent docker run commands would mean managing networking, environment files, startup order, health checks, restart policies, volumes, and image versions independently.

Docker Compose became the declarative description of the single-server platform. Rather than run every container independently, Compose defined service relationships, private networking, environment files, restart behaviour, health checks, volumes, image versions, and public exposure

A simplified view of the final topology was:

services:
  backend:
    expose:
      - "3000"

  frontend:
    expose:
      - "80"

  nginx:
    ports:
      - "80:80"
      - "443:443"
Enter fullscreen mode Exit fullscreen mode

The distinction between expose and published ports became important. The backend did not need to be reachable from the Internet.

It only needed to be reachable by Nginx. Inside the Compose network, Nginx could therefore connect to backend:3000 and frontend:80. Docker's internal DNS resolved those names.

Understanding localhost properly

This deployment also forced me to internalise a networking concept I had previously understood only partially:

EC2 localhost
       ≠
backend container localhost
       ≠
frontend container localhost
       ≠
my laptop localhost
Enter fullscreen mode Exit fullscreen mode

Inside the backend container, 127.0.0.1:3000 means that backend container.

Inside the Nginx container, backend:3000 means resolve the backend service through Docker DNS.

From the public Internet, neither of those addresses is directly relevant. A client reaches EC2 through ports 80 or 443. Nginx then crosses the boundary into the private container network. That distinction became fundamental to understanding the rest of the platform.

Health checks

I added explicit frontend and backend health endpoints. This allowed Docker Compose and the deployment workflow to test a known endpoint rather than assuming that a running process was ready.

What went wrong: the wrong route looked like a network failure

During container-to-container testing, I received an HTTP error and initially suspected Docker DNS or bridge networking.

The actual problem was an incorrect application endpoint. The HTTP status itself was evidence that networking had already succeeded.

Verify hostname, port and HTTP path independently before blaming the network.

What went wrong: the frontend was healthy, but Docker said it was unhealthy

A later failure was more subtle. Compose reported the frontend was unhealthy, yet inside the container wget http://127.0.0.1/ returned HTTP/1.1 200 OK.

The frontend was serving correctly, but healthcheck was malformed

test: ["CMD", "wget", "-qO-", "http://localhost/ || exit 1"]
Enter fullscreen mode Exit fullscreen mode

Exec-form CMD form does not invoke a shell. Therefore:

|| exit 1
Enter fullscreen mode Exit fullscreen mode

was not interpreted as shell logic. It became part of the URL. Nginx logs revealed the absurd request:

GET / || exit 1 HTTP/1.1
Enter fullscreen mode Exit fullscreen mode

which returned 400 Bad Request. The corrected health check was simply to allow wget to return its own non-zero exit status:

healthcheck:
  test: ["CMD", "wget", "-qO-", "http://127.0.0.1/"]
Enter fullscreen mode Exit fullscreen mode

This became one of the strongest lessons of the phase:

A failed health check does not automatically mean a failed application. The health check itself is software and can be wrong.

frontend-health-failure

The application was returning HTTP 200 manually; the health-check command itself was malformed

HTTP/1.1-400 Bad-Request

Turn GitHub Actions into the Build Environment

Once the images worked locally, the next problem was reproducibility. I wanted to remove my workstation from the release path. The CI system should create the deployment artefact.

GitHub Actions therefore became the controlled build environment. The pipeline performed:

checkout
   ↓
dependency installation
   ↓
tests/quality checks
   ↓
npm audit
   ↓
Docker build
   ↓
Trivy image scan
   ↓
immutable tagging
   ↓
GHCR publication
Enter fullscreen mode Exit fullscreen mode

A matrix strategy allowed frontend and backend to follow the same CI structure without duplicating the entire job definition.

Frontend and backend pass through the same repeatable CI and security gates.

Dependency auditing and image scanning are different controls

I used both npm audit and Trivy because they answer different questions. npm audit evaluated the Node.js dependency graph.

Trivy evaluated the resulting container image, including application packages and operating-system packages. That distinction became important when findings appeared in packages such as brace-expansion, js-yaml, postcss, and mongoose.

Instead of blindly updating whichever package name appeared in the report, I used npm why and npm ls to investigate dependency provenance.

Some findings came from development tooling such as ESLint or Nodemon. Others represented actual production dependencies.

The lesson became:

Do not stop at "there is a vulnerability." Determine where it came from and whether it exists in the production runtime.

trivy-scan-fe

trivy-scan-be

What went wrong: GHCR permissions

Images built successfully but initially could not be published. The missing piece was:

permissions:
  contents: read
  packages: write
Enter fullscreen mode Exit fullscreen mode

The build was fine. The delivery identity was not.

What went wrong: vulnerable dependency

Trivy also exposed a high-severity dependency issue. The important part of the debugging process was understanding that the vulnerable package was transitive. It did not necessarily appear as a dependency I had knowingly selected.

I inspected the dependency tree, identified the parent package, updated the relevant dependency, rebuilt the image, and reran the scan.

Make Image Releases Traceable

During early experimentation, I repeatedly reused tags. That worked technically, but it created ambiguity.

If quiz-backend:v1.0.0 can refer to different image contents over time, the tag no longer tells me exactly which source revision is deployed.

The pipeline therefore moved to commit-derived tags sha-<git-commit>

The relationship became:

Source commit
      ↕
Docker image
      ↕
GHCR artefact
      ↕
EC2 deployment
Enter fullscreen mode Exit fullscreen mode

Every deployment could now be traced back to the Git commit that produced it.
Immutable commit-derived tags created a direct relationship between source code, registry artefacts and the running deployment

Immutable commit-derived tags created a direct relationship between source code, registry artefacts and the running deployment

Make EC2 a Runtime, Not a Build Server

I wanted the production server to have a deliberately narrow responsibility. It should:

  • run Docker and Docker Compose
  • authenticate to GHCR
  • receive runtime configuration
  • pull approved versioned images
  • run containers

It should not compile application source code. That gave me a useful release model:

CI produces artifacts. Production consumes artifacts.

The runtime directory eventually looked roughly like this:

/opt/quiz-app/
├── docker-compose.prod.yml
├── .env
├── backend.env
├── nginx/
│   ├── bootstrap.conf
│   ├── quiz-app.conf
│   └── app.conf
└── certbot/
    ├── conf/
    └── www/
Enter fullscreen mode Exit fullscreen mode

The deployment workflow generated .env and backend.env. .env contained Compose-level configuration such as the immutable image tag, while backend.env contained application runtime configuration such as the MongoDB connection. Neither belonged in Git.

What went wrong: deployment-path mismatch

One deployment failed because the SCP step copied deployment files into one path structure while the remote script expected another. There was also an inconsistency between Compose filenames such as .yml and .yaml.

The failure looked like a Compose problem. It was really an automation contract problem. I debugged the boundary directly using commands such as:

pwd, find, and ls to determine what actually existed on the server.

I then standardised repository path → SCP destination → EC2 runtime path → Compose filename

Automation does not eliminate assumptions. It executes them faster.

SCP/path failure

SCP/path success

Refactor the Edge: From Host Nginx to Containerised Nginx

This was one of the most important architecture decisions in the project.

Version 1: host-managed edge

My first EC2 deployment used:

EC2 host
├── Nginx installed with apt
├── Certbot installed on host
│
└── Docker Compose
    ├── frontend
    └── backend
Enter fullscreen mode Exit fullscreen mode

Because Nginx lived outside Docker, the application containers published loopback-only ports such as:

backend:
  ports:
    - "127.0.0.1:3000:3000"

frontend:
  ports:
    - "127.0.0.1:8080:80"
Enter fullscreen mode Exit fullscreen mode

Host Nginx then proxied to those addresses. The architecture worked. It was not wrong.

But it meant recreating the platform on another EC2 instance required host-level configuration:

install Nginx
configure site
enable site
install Certbot
configure certificates
manage systemd service
Enter fullscreen mode Exit fullscreen mode

That conflicted with my goal of making the deployment reproducible.

Version 2: containerised edge

I refactored the platform to:

EC2
└── Docker Compose
    ├── frontend
    ├── backend
    ├── nginx
    └── certbot
Enter fullscreen mode Exit fullscreen mode

Nginx now communicated with application services directly through Docker DNS:

frontend:80
backend:3000
Enter fullscreen mode Exit fullscreen mode

Only Nginx published ports 80 and 443. The important lesson was not:

“Containerised Nginx is always better.”

It was:

Architecture should match the operational model.

For this single-application, reproducibility-focused platform, moving Nginx into Compose reduced host-specific configuration and brought more of the runtime under version control.

Bootstrap HTTP Before Enabling HTTPS

This became one of the most technically interesting parts of the project. At first glance, adding HTTPS sounds simple:

request a certificate and configure Nginx.

Containerising Nginx exposed the dependency hidden underneath that statement. The final Nginx configuration referenced certificate files such as fullchain.pem and privkey.pem

But on a brand-new server, those files do not exist. Nginx needs them to start its HTTPS configuration. Certbot needs HTTP validation to obtain them.

That creates a circular dependency:

Nginx needs certificate for HTTPS
              ↑
              │
Certificate does not exist
              │
              ↓
Certbot needs HTTP validation
              ↓
Nginx must already be serving HTTP
Enter fullscreen mode Exit fullscreen mode

The solution was to model two legitimate infrastructure states.

State 1: HTTP bootstrap

The repository contained bootstrap.conf
which listened only on port 80 and exposed /.well-known/acme-challenge/.

Nginx and Certbot shared an ACME webroot. During validation:

./certbot/www
Enter fullscreen mode Exit fullscreen mode

through bind mounts.

Certbot
   │ writes token
   ▼
certbot/www/.well-known/acme-challenge/<token>
   ▲
   │ Nginx serves
Let's Encrypt
Enter fullscreen mode Exit fullscreen mode

Let's Encrypt requests the challenge through the public domain. If the file is reachable, domain ownership has been proven.

The ACME path is therefore not an application API. It exists purely for certificate validation.

State 2: HTTPS production

After successful validation, Certbot persisted the certificate under the host-mounted certificate directory.

The final Nginx configuration then:

  • loaded the certificate and private key
  • listened on port 443
  • redirected regular HTTP requests to HTTPS
  • retained the ACME challenge route for certificate renewal

The deployment pipeline therefore had two source configurations bootstrap.conf and quiz-app.conf but only one runtime-selected configuration app.conf

Conceptually:

bootstrap.conf ─┐
                ├── pipeline selects → app.conf → Nginx container
quiz-app.conf ──┘
Enter fullscreen mode Exit fullscreen mode

app.conf was runtime state, not source configuration.

What went wrong: the file changed, but the runtime had not necessarily changed

At one point, the host's app.conf clearly contained the HTTPS configuration. Yet curl https://kene-quiz.duckdns.org could not connect to port 443.

I changed the deployment process to recreate the Nginx container after switching from bootstrap configuration, validated and tested TLS locally using the real hostname while forcing the connection to loopback at several boundaries:

Nginx config valid
       ↓
container listens on 443
       ↓
Docker publishes host 443
       ↓
EC2 listener exists
       ↓
local TLS request succeeds
       ↓
public HTTPS request succeeds
Enter fullscreen mode Exit fullscreen mode

The public validation eventually moved outside the SSH session and onto the GitHub-hosted runner. That meant the deployment test followed the real external path:

GitHub runner
      ↓
Internet
      ↓
DNS
      ↓
EC2 Security Group
      ↓
Docker :443
      ↓
Nginx
Enter fullscreen mode Exit fullscreen mode

That was a much stronger deployment test than simply checking a container.

https-validation

Successful TLS was validated at both the container boundary and the real public request path.

Treat Runtime Configuration as Deployment State

Another failure occurred much later and looked initially like a database/network problem. The browser could reach:

https://kene-quiz.duckdns.org/api/questions
Enter fullscreen mode Exit fullscreen mode

Nginx forwarded the request to Express. But Express returned:

{
  "message": "Operation `questions.find()` buffering timed out..."
}
Enter fullscreen mode Exit fullscreen mode

Backend logs exposed the real problem:

The `uri` parameter to `openUri()` must be a string, got "undefined"
Enter fullscreen mode Exit fullscreen mode

My application expected MONGO_URI, while the deployment had initially created MONGODB_URI; After correcting the variable name, I discovered something else.

backend.env on EC2 contained a valid URI MONGO_URI=mongodb://... but docker inspect ... showed MONGO_URI= inside the running backend container.

That taught me another crucial container concept:

Changing an env file on the host does not mutate the environment of an already-created container.

Environment values are injected during container creation. I therefore recreated only the backend:

docker compose up -d \
  --force-recreate \
  --no-deps \
  backend
Enter fullscreen mode Exit fullscreen mode

The next logs finally showed Connected to MongoDB...

The host configuration was correct, but the existing container still held the environment it received when it was created

What --force-recreate and --no-deps meant

--force-recreate instructed Compose to replace the existing backend container even if Compose believed it could reuse it.

--no-deps meant recreate this service only; don't unnecessarily restart the surrounding dependency graph. This was useful during targeted runtime debugging.

What went wrong next: 502 after recreating the backend

Fixing MongoDB exposed another boundary. The backend now worked, but Nginx returned 502 Bad Gateway. Rather than treating that as another generic failure, the status code narrowed the problem:

client → Nginx ✅
Nginx → upstream ❌
Enter fullscreen mode Exit fullscreen mode

Recreating the backend could change its container IP. Docker's service name still resolves correctly through its internal DNS, but a long-running Nginx upstream can temporarily retain an earlier resolution depending on how the configuration was loaded.

Reloading/recreating the proxy after the backend replacement restored the upstream path. This reinforced why I wanted service names like backend instead of hard-coded container IP addresses in the first place.

The Frontend Does Not Need to Know the Backend's Infrastructure Address

One concern I investigated was whether the React frontend needed an EC2 environment variable containing the backend address. It did not.

The frontend code already used:

const apiUrl =
  import.meta.env.VITE_REACT_APP_API_URL ||
  '/api/questions';
Enter fullscreen mode Exit fullscreen mode

For this architecture, the fallback is actually desirable. The browser requests:

https://kene-quiz.duckdns.org/api/questions
Enter fullscreen mode Exit fullscreen mode

Nginx then proxies internally:

/api/questions
      ↓
backend:3000/api/questions
Enter fullscreen mode Exit fullscreen mode

The frontend therefore does not need to know:

  • the backend container IP
  • the EC2 IP
  • Docker's backend hostname
  • a public backend port

This also makes browser API traffic same-origin and removes an unnecessary CORS boundary. That is one of the cleanest consequences of introducing the reverse proxy.

The Final Deployment Pipeline

By the end of the refactor, the deployment workflow effectively performed:

Establish temporary administrative access

The GitHub-hosted runner discovered its current public IP. The workflow temporarily allowed that single /32 address through the EC2 security group for SSH.

Once deployment finished, the rule was revoked. This prevented the automation requirement from becoming an excuse to permanently expose SSH broadly.

Install deployment definitions

GitHub Actions copied only deployment files to EC2:

  • Docker Compose definition
  • Nginx bootstrap configuration
  • Nginx HTTPS configuration

Application source code was not copied. This preserved the separation between build and runtime environments.

Create runtime configuration

The workflow generated runtime files such as .env and backend.env. This is where deployment metadata and secrets entered the system.

There were now three distinct sources of deployment information:

Git repository
→ version-controlled deployment configuration

GitHub Secrets
→ sensitive runtime values

GHCR
→ immutable application artefacts
Enter fullscreen mode Exit fullscreen mode

That separation is important.

Pull and reconcile the application

EC2 authenticated to GHCR and pulled the SHA-tagged frontend and backend images. Compose then reconciled the services against the deployment definition.

At this point, the production server did not need to know how the application had been built. It only needed to know which approved artifact to run.

Reconcile TLS state

The deployment checked whether a certificate already existed. If no certificate existed, it selected HTTP bootstrap mode and performed the ACME flow.

If a certificate existed, the final HTTPS configuration could be selected immediately. That made the deployment workflow capable of handling both first-ever deployment and routine subsequent deployment without manually maintaining two separate procedures.

Validate the platform progressively

Deployment did not end when docker compose up -d returned successfully.

The pipeline tested the system from the inside outward:

containers started
      ↓
container health
      ↓
internal Docker networking
      ↓
Nginx upstream connectivity
      ↓
host port 443
      ↓
local TLS validation
      ↓
public HTTPS validation
Enter fullscreen mode Exit fullscreen mode

This is the important design principle behind the final deployment job:

Deployment success should be defined by observable application behaviour, not by the success of the deployment command itself.

Final-GHA-deploy-job-The final deployment job validates internal service health as well as the externally accessible HTTPS endpoint

Complete Deployment Path

The finished application path became:

Developer changes source code
            ↓
Git commit
            ↓
GitHub Actions
            ↓
Dependency validation
            ↓
Docker image build
            ↓
Trivy scan
            ↓
SHA-tagged images
            ↓
GHCR
            ↓
EC2
            ↓
pipeline-generated runtime configuration
            ↓
Docker Compose
            ↓
┌──────────────────────────────────┐
│ backend                          │
│ frontend                         │
│ nginx                            │
│ certbot utility                  │
└──────────────────────────────────┘
            ↓
Nginx TLS termination
            ↓
HTTPS
            ↓
Users
Enter fullscreen mode Exit fullscreen mode

MongoDB Atlas remained an externally managed service reached only by the backend.

The final platform had several important properties:

  • Source code was not built on the production server.
  • Application images were versioned and stored centrally.
  • The build environment was separated from the runtime environment.
  • The backend port was not publicly exposed.
  • Service communication used Docker's internal DNS.
  • HTTPS terminated at the Nginx container.
  • Runtime configuration remained separate from the images.
  • A deployment could be traced back to a source commit.
  • The platform could be recreated on another compatible server.

Failure and Debugging Summary

These were the most important incidents from this phase. This table is not a replacement for the implementation story. It is a compact memory aid for future interviews.

Failure Initial suspicion Root cause Engineering lesson
Node build failure Package issue Node runtime versions differed Align runtimes across local, Docker and CI
GHCR push failed Docker build Missing package-write permission CI identity is part of delivery architecture
Registry path failed GHCR issue Invalid image-name casing Normalise generated identifiers
Dependency audit failed Direct dependency Some findings came from transitive/dev dependencies Investigate dependency provenance
Internal API returned 404 Docker networking Incorrect route An HTTP response often proves connectivity
Frontend unhealthy Application failure Malformed health-check command Health checks can themselves contain defects
Deployment file missing Compose/SCP Workflow and remote paths disagreed Automation stages require explicit path contracts
MongoDB query timed out Atlas networking Runtime database URI missing Start with application logs before blaming the network
Mongoose URI undefined Driver failure MONGODB_URI vs MONGO_URI mismatch Environment-variable names must match end-to-end
Env file correct but container env empty Secret injection Existing container retained creation-time environment Env-file changes may require recreation
Nginx returned 502 Internet/network Proxy could not reach backend upstream Diagnose proxy and upstream separately
HTTPS config existed but 443 failed Certificate Running Nginx had not loaded new configuration state Inspect runtime state, not only files
Certbot bootstrap failed Let's Encrypt DNS/HTTP/ACME path was incomplete TLS issuance depends on the complete request path

What I Would Improve Before Calling This a More Mature Production Platform

This implementation deliberately stops at a production-style single-server architecture. That is not the same thing as claiming that one EC2 instance is an ideal architecture for every production workload.

Understanding what I would change next is part of understanding the current design.

Replace long-lived AWS credentials with OIDC

The current GitHub Actions workflow authenticates to AWS using stored credentials. A stronger implementation would use GitHub Actions OIDC federation with AWS IAM.

This would remove long-lived AWS access keys from GitHub Secrets and give each workflow short-lived credentials based on an IAM role.

Move runtime secrets closer to the runtime

Generating backend.env from GitHub Secrets works for this project. At greater scale, I would consider AWS Secrets Manager or Systems Manager Parameter Store so that secrets are retrieved by the runtime environment rather than transported through CI.

Add high availability

The most obvious availability limitation in the current architecture is a single EC2 instance running a single Docker Compose platform represents a single point of failure. A more highly available architecture could evolve toward Internet -> Application load balancers -> Multiple application instances -> Managed database.

This would allow traffic to be distributed across multiple application instances so that the failure of a single host would not necessarily make the entire application unavailable.

At that point, the operational problem begins moving beyond what a single-host Compose deployment is designed to solve elegantly.

That is where orchestration platforms such as Amazon ECS or Kubernetes become much more compelling.

Lessons Learned

A container is not a deployment platform

Docker solved packaging. The platform still required release identity, registry management, networking, configuration, secrets, health, ingress, TLS and automation.

Build once and deploy the validated artifact

The EC2 instance did not need the source tree. Its role was to run prebuilt, versioned, scanned and traceable images.

“Running”, “healthy” and “ready” are different states

This project repeatedly reinforced

process running
     ≠
HTTP responding
     ≠
database connected
     ≠
service ready
     ≠
public application reachable
Enter fullscreen mode Exit fullscreen mode

Health checks need to reflect the level of readiness that actually matters.

Docker Compose can express a serious single-server platform

Compose defined:

  • service relationships,
  • private networking,
  • runtime configuration,
  • health checks,
  • restart behaviour,
  • storage mounts,
  • and public exposure.

The limitation was not that Compose was “only for development.” Its limitation was the scale and operational model it was being asked to support.

Runtime state beats configuration intent

I repeatedly encountered situations where file looks correct did not imply running process uses it

Examples included:

  • Nginx configuration
  • container environment variables
  • upstream resolution

This is why commands such as:

docker inspect
docker compose ps
docker compose logs
nginx -T
ss -lntp
curl
wget
Enter fullscreen mode Exit fullscreen mode

became more valuable than simply rereading configuration files.

Most difficult bugs live at boundaries

The hardest problems occurred between systems:

GitHub Actions ↔ GHCR
workflow ↔ filesystem
Compose ↔ env files
Nginx ↔ Docker DNS
backend ↔ MongoDB Atlas
DNS ↔ Let's Encrypt
host ↔ container
Enter fullscreen mode Exit fullscreen mode

That is becoming one of the most important lessons of this entire platform project.

Platform engineering is often less about knowing each component independently and more about understanding the contracts between them.

Refactoring infrastructure is not redoing work

I originally worried that moving Nginx and Certbot into containers meant I was repeating the deployment. It didn't.

The first architecture taught me:

host proxying
systemd service management
host TLS configuration
loopback container publishing
Enter fullscreen mode Exit fullscreen mode

The refactor taught me:

private Compose networking
containerised ingress
bind-mounted runtime state
TLS bootstrapping
ACME webroot validation
runtime configuration selection
Enter fullscreen mode Exit fullscreen mode

Seeing both designs gave me more engineering context than implementing only the final one.

What Comes Next?

At this point, the platform could answer the original question:

How do I move from application source code to a securely deployed production platform on AWS?

The source code was packaged into production images.
The images were built and scanned automatically.
Versioned artifacts were stored in GHCR.
Every release can be traced to a Git commit.
Amazon EC2 acted as a controlled runtime environment.
Docker Compose defined the services.
The backend remains private.
Nginx exposed one public gateway.
Certbot and Let's Encrypt secured the application with HTTPS.
Deployment and runtime configuration are automated.

But successful deployment creates a new question:

What is actually happening inside the system after it starts?

A successful health request cannot tell me:

  • how much CPU the host is using
  • whether memory pressure is increasing
  • how many requests the backend receives
  • how response latency changes
  • whether 5xx responses are increasing
  • which container is consuming resources
  • how the application behaves under load

In the next part of this series, I will add an observability layer using:

  • Prometheus
  • Grafana
  • Node Exporter
  • cAdvisor
  • application-level metrics
  • alerting
  • and k6 load testing

Because deployment answers: can I build and reliably deploy the application?

Observability answers: can I understand it while it is running?

And that is the next engineering problem.

Top comments (0)