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
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
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
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
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.
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.
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.
Presentation layer
Finally, I started the React application and confirmed that the quiz questions appeared in the browser.
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
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.
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
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"
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
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"]
Exec-form CMD form does not invoke a shell. Therefore:
|| exit 1
was not interpreted as shell logic. It became part of the URL. Nginx logs revealed the absurd request:
GET / || exit 1 HTTP/1.1
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/"]
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.
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
A matrix strategy allowed frontend and backend to follow the same CI structure without duplicating the entire job definition.
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.
What went wrong: GHCR permissions
Images built successfully but initially could not be published. The missing piece was:
permissions:
contents: read
packages: write
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
Every deployment could now be traced back to the Git commit that produced it.

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/
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.
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
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"
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
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
Nginx now communicated with application services directly through Docker DNS:
frontend:80
backend:3000
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
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
through bind mounts.
Certbot
│ writes token
▼
certbot/www/.well-known/acme-challenge/<token>
▲
│ Nginx serves
Let's Encrypt
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 ──┘
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
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
That was a much stronger deployment test than simply checking a container.
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
Nginx forwarded the request to Express. But Express returned:
{
"message": "Operation `questions.find()` buffering timed out..."
}
Backend logs exposed the real problem:
The `uri` parameter to `openUri()` must be a string, got "undefined"
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
The next logs finally showed Connected to MongoDB...
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 ❌
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';
For this architecture, the fallback is actually desirable. The browser requests:
https://kene-quiz.duckdns.org/api/questions
Nginx then proxies internally:
/api/questions
↓
backend:3000/api/questions
The frontend therefore does not need to know:
- the backend container IP
- the EC2 IP
- Docker's
backendhostname - 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
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
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.
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
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
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
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
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
The refactor taught me:
private Compose networking
containerised ingress
bind-mounted runtime state
TLS bootstrapping
ACME webroot validation
runtime configuration selection
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)