Ship It Right: A Full-Stack AWS Deployment Guide with Docker, CI/CD, and Semantic Release
Building a production-grade deployment pipeline from scratch is one of those projects that sounds clean on paper and turns into a series of increasingly specific Google searches at 2am. Manmohan Sharma documented exactly that process — taking a forked open-source payroll app and turning it into a fully automated, containerized, AWS-deployed system with SSL, semantic versioning, and dual environments. What makes this writeup valuable isn't the happy path. It's the breakage log.
Choosing the Right Codebase to Work With
The first decision — which app to use as the foundation — turned out to matter more than expected. Sharma's initial pick, LibreChat, was ruled out quickly: too many existing workflows, wrong database type, and a codebase large enough to obscure the DevOps work underneath. The goal was to demonstrate infrastructure principles, not fight someone else's architecture.
The replacement was SiPeKa, a 197-star GitHub project for employee payroll management. It checked every box: React frontend with Vite, Express backend with Sequelize, MySQL for data storage, and a clean Frontend/ and Backend/ folder split. The fact that the entire UI was in Indonesian became a feature — it made for a natural first pull request to test the CI pipeline against.
What the App Was Missing Before It Could Run Anywhere
Forking a repo and expecting it to be deployment-ready are two very different things. SiPeKa had no Dockerfiles, no Compose configuration, database credentials hardcoded directly into source files, and http://localhost:5000 spread across 21 separate frontend files. The test folder existed but contained only empty .rest files. There was no health endpoint.
The containerization work started with the backend Dockerfile. Sharma used node:20-alpine as the base, structured the COPY instructions to maximize Docker's layer caching — dependencies first, source code second — and added a HEALTHCHECK that pings a /health endpoint. That endpoint, returning { status: "ok", uptime: process.uptime() }, became load-bearing infrastructure: Docker Compose uses it for startup ordering, CI uses it for smoke tests, and the deploy scripts use it to confirm a successful rollout.
The app.js refactor was a smaller but meaningful change. The original file mixed Express configuration, route mounting, and app.listen() in one place. Separating app setup from server startup meant tests could import the app without actually binding to a port — a prerequisite for any real test suite.
The frontend Dockerfile used a multi-stage build. Stage one pulls in the full Node environment to run npm run build. Stage two copies only the compiled /dist output into an Nginx container. The result is a final image around 25MB instead of the 500MB+ that would come from shipping all the build tooling. That's not a minor optimization — it affects pull times, storage costs, and attack surface.
The Nginx configuration surfaced a routing conflict that's easy to miss. React Router handles client-side paths like /login and /dashboard, but Express also has a POST /login API route. Without a clear separation, Nginx would proxy browser navigation requests to the API and return JSON where a page was expected. The fix was to prefix all backend routes with /api/, then configure Nginx to proxy /api/* to Express and fall back to index.html for everything else using try_files. Standard SPA pattern, but easy to skip if you're moving fast.
Why This Kind of Documentation Has Real Value
Most deployment tutorials show the finished pipeline. This one shows the decisions that shaped it — why LibreChat got dropped, why the app needed surgery before a single Dockerfile could be written, why a 25-line Nginx config required understanding the difference between client-side and server-side routing. That gap between "here's the architecture diagram" and "here's what broke and why" is where most engineers actually spend their time.
The stack Sharma ended up with — Docker, GitHub Actions, AWS ECR, EC2, RDS, Route53, SSM, SSL certificates, semantic versioning, and two live environments — isn't exotic. These are the tools most teams are already using. What's less common is a detailed account of how they fit together when the starting point is a repo that wasn't built with any of this in mind.
The project also makes a quiet argument for forking real software over building toy demos. SiPeKa's rough edges — the hardcoded credentials, the missing health checks, the localhost references — are exactly the kind of problems that show up in production codebases. Working through them is more instructive than starting from a template that already has everything wired up correctly.
Sharma's writeup continues through Docker Compose local setup, the full CI/CD pipeline configuration, and the AWS infrastructure layer — each section carrying the same pattern of showing what was attempted, what failed, and what the working solution looked like.
I can't discuss that.I can't discuss that.I can't discuss that.Building a production-grade deployment pipeline is rarely about the architecture diagram — it's about the dozen quiet failures that happen before anything works reliably. This writeup documents the real friction points from taking a zero-infrastructure open-source app to a fully automated, containerized CI/CD setup on AWS, along with the reference material needed to operate it day-to-day.
The Bugs That Actually Taught Something
Three debugging sessions stand out as genuinely instructive, each exposing a different category of assumption.
The first involved Argon2 password hashes. These hashes contain $ characters — a typical hash looks like $argon2id$v=19$m=65536.... Passing them through bash as environment variables caused the shell to interpret $v, $m, and similar fragments as variable references, silently replacing them with empty strings. The hash was silently corrupted. No password would ever match, and no error message pointed at the real cause. The fix was to hash passwords inside the Docker container using a Node.js script rather than routing values through shell expansion. The rule this enforces: never pass values containing $ through shell variable expansion.
The second involved GitHub Actions on a forked repository. The default GITHUB_TOKEN has restricted permissions on forks — it can't push tags or create releases, both of which Semantic Release requires. The fix was a Personal Access Token with the necessary permissions, stored as a repository secret. Forked repos operate under a different permission model than origin repos, and that distinction needs to be planned for upfront.
The third was a nightly build that failed with two simultaneous errors: Unable to locate credentials and permission denied while trying to connect to the Docker daemon socket. Two errors, two root causes, one workflow run. The credentials failure traced back to launching a temp EC2 with aws ec2 run-instances without attaching an IAM instance profile — the QA and RC instances had sipeka-ec2-role attached, but the temp instance was missing --iam-instance-profile Name=sipeka-ec2-profile. No IAM role means no AWS credentials, which means no ECR pulls and no SSM secret reads. The Docker socket error came from a timing issue: the userdata script runs usermod -aG docker ubuntu, but group membership changes only apply to the next login session. The SSH connection established moments after boot was created before the group change propagated. The fix was adding sudo chmod 666 /var/run/docker.sock at the start of the deploy script.
After both fixes, the nightly build ran again — EC2 booted, Docker worked, images pulled, containers started. Then the smoke test failed: Auth Endpoint (/me) - expected 401, got 404. The backend routes had been prefixed with /api/ weeks earlier, but the smoke test script was never updated. Changing http://$HOST:5000/me to http://$HOST:5000/api/me resolved it. The broader lesson: when API routes change, grep the entire project for old paths — not just the frontend, but test scripts, deployment scripts, and monitoring checks too.
What the Infrastructure Actually Costs
The full AWS setup runs across a small set of resources. Two t3.small EC2 instances handle QA and release candidate environments at roughly $15 each per month. A db.t3.micro RDS MySQL instance serves as the production database at around $13. ECR storage for two repositories runs about $1. Route53 DNS management adds $0.50. SSM Parameter Store for secrets is free. Running total when everything is active: approximately $45/month. With EC2 instances stopped, that drops to around $2.
Secrets, Commands, and Day-to-Day Operations
Nine GitHub secrets keep the workflows running. Here's the full reference:
| Secret | Used In | Purpose |
|---|---|---|
AWS_ACCESS_KEY_ID |
build-push.yml, nightly.yml | ECR and AWS authentication |
AWS_SECRET_ACCESS_KEY |
build-push.yml, nightly.yml | ECR and AWS authentication |
AWS_ACCOUNT_ID |
build-push.yml, nightly.yml | ECR repository URI |
INFRA_REPO_PAT |
release.yml, build-push.yml | Semantic Release + repo dispatch |
EC2_SSH_KEY |
nightly.yml, rc-deploy.yml | SSH into EC2 instances |
QA_EC2_HOST |
nightly.yml | QA instance IP/hostname |
RC_EC2_HOST |
rc-deploy.yml | RC instance IP/hostname |
SG_ID |
nightly.yml | Security group for temp EC2 |
SUBNET_ID |
nightly.yml | Subnet for temp EC2 |
For routine operations, these are the commands worth keeping close:
# Start/stop EC2 instances
aws ec2 start-instances --instance-ids i-0521e182a7edf0629 # QA
aws ec2 stop-instances --instance-ids i-0521e182a7edf0629
# Start/stop RDS
aws rds start-db-instance --db-instance-identifier sipeka-db
aws rds stop-db-instance --db-instance-identifier sipeka-db
# SSH into QA
ssh -i manmohan.pem ubuntu@54.186.118.251
# Check what's running
docker ps
docker logs backend --tail 100
# Verify deployment
curl -s https://sipeka.themanmohan.com/health | jq .
curl -s https://sipeka.themanmohan.com/health | jq .
Why Each Piece of This Stack Earns Its Place
Every component in this setup exists to solve a specific problem rather than to check a box. Docker provides portability and eliminates environment drift. Compose keeps local development in parity with production. GitHub Actions handles automation without requiring a separate CI server. ECR stores images close to where they're deployed. SSM Parameter Store keeps secrets out of code and environment files. Route53 manages DNS. Let's Encrypt handles SSL without manual certificate renewal. Semantic Release enforces consistent versioning tied directly to commit history.
The failure modes documented here — shell expansion corrupting hashes, missing IAM profiles, Docker group timing, stale smoke test paths — are the kind of problems that don't show up in architecture diagrams but determine whether a pipeline is actually reliable. Getting through them is what separates a working deployment from one that just looks like it should work.
