acoelho.dev
Portfolio Website
Category:
Full-Stack Web Application
Tech Stack:
Scope of AI:
Code review and structural support. Architecture decisions were my own.
A portfolio website engineered as a real product, with localization support, tests, CI/CD, security hardening, and a reproducible deployment I fully control. Built to display my technical abilities to deliver production-grade projects and showcase my interests on gamified applications that drive engagement metrics. The website you are visiting is the project.
Architecture
Every layer was purposefully designed to be reproducible as something I can rebuild from scratch on demand. The production environment updates from a single push toΒ main. GitHub Actions runs the test suite, builds the image from a multi-stage Dockerfile, and provisions the host with a docker-compose file.
Each layer's place in the stack was a deliberate choice, arrived through several stages of evolution until it reached its current form (detailed in section 4). Here's the reasoning behind them:
CI/CD Layer β GitHub
GitHub push β Actions (CI: scan + test) β Actions (CD: SSH deploy)
The suite covers routing, page rendering, the coin-collection flow, and database-backed model logic. Deliberately written so a broken build doesn't reach production.
Server Layer β Hostinger VPS & Docker
Caddy (TLS) β Docker Compose β web (Puma) + db (Postgres)
A single Hostinger VPS turned out to be the right balance of cost and operational simplicity for a project this size. This layer went through the most evolution β see more details in section 4.
App Layer β Ruby on Rails
Locale routing with i18n β Gamification JS β Event API β Metrics caching β PostgreSQL
The project began as plain HTML and JavaScript, but Rails was the right tool as it grew: a robust, lean monolith that consolidated every service without the overhead of external micro-services this project didn't need.
Content Layer β Rails i18n
Supporting multiple languages was a must for this portfolio. Rails' native i18n made it straightforward with locale-scoped routes, strings pulling from YAML files, and quickly serving the full experience in English and Portuguese.
Client Layer β Gamification JS
An interactive game where visitors collect three hidden coins, track their own progress, and see the running total collected by everyone else. It's a small, honest demonstration of how game-like design can drive engagement, and a reflection of my interest in building gamified systems.
Data Layer β PostgreSQL
Each visitor's coin progress lives in their browser's localStorage, while an anonymous event log in Postgres aggregates collection across all visitors, giving the experience a sense of shared community without storing any personal information.
Concepts
This project was created with the intention to display three core concepts:
- A living portfolio.
A production environment that's self-hosted, easily reproducible, and fully under my control that ships with oneΒ git push.
- Production discipline.
Multi-stage Docker builds, SSH deployment pipeline, 12-hour API caching, CI security scans, a real test suite, CSP, and rate limiting. Project management and attention to detail that isnβt necessary for a personal portfolio, but that demonstrates how Iβd treat any clientβs system.
- Measurable engagement with gamification.
Coin game that feeds a real API, and a homepage that loops the experience with live global counts. A gamified user experience that showcases measurable engagement.
These core concepts were the guiding principles that guided every page and navigation decisions. The gamification experience was designed as the entry point to earn the visitor's attention before leading them through the portfolio content toward contact. The flow below is the user journey that guided every navigation and layout decision before a single line of code was written.
Evolution
While developing this project, the back-end structure and hosting architecture evolved through different stages, each as a response to real constraints and a consistent push to reduce complexity and cost without losing technical control.
Phase 1 β GitHub Pages, no back-end
GitHub Pages, raw HTML, Javascript, CSS
The goal was to get the project live quickly using only free resources, with no backend and no external services. GitHub Pages handled hosting and was the perfect kick-start to the project.
With the static foundation in place, the next step was giving the gamification loop a closing step. Visitors could collect coins, but with no backend there was no way to record collections or surface community progress.
Migrated due to:
- The gamification loop had no closing step. Visitors collected coins but couldn't see community impact. A real API was needed to close that loop.
Phase 2 β AWS + GitHub Pages
AWS (EC2 + RDS), Node.js, Express.js, MySQL, HTML, Javascript, CSS
To back the gamification experience with real data, I built a Node.js API on AWS EC2 and connected it to an RDS MySQL instance. AWS made the setup straightforward, but for a single-developer personal project it meant maintaining two managed services and absorbing South American datacenter costs. Switching to North American AWS servers would have reduced the bill, but the two managed services and the variable, usage-based pricing were still project risks.
With the architecture in place, I focused on keeping it lean by adding server-side caching to reduce the number of hits to the RDS instance on every page load, and unified the cache invalidation with the coin collection write transaction so the cache stayed accurate without a separate step.
Key decisions:
- Added server-side caching to reduce RDS traffic on every page visit.
- Unified cache invalidation with the coin write transaction for accuracy and efficiency.
Migrated due to:
- Two managed services (EC2 + RDS) added operational overhead that was hard to justify for a single-developer project.
- AWS was running at around $40 USD per month via the free-tier credits, with no ceiling on future costs as usage could grow. Switching to North American AWS servers would have only partially addressed the pricing while still leaving two managed services and a variable monthly bill. Hostinger's 2-year fixed cost plan at $30 BRL (~$6 USD) per month, also hosted in North America, eliminated both problems at once.
Phase 3 (current) β Hostinger VPS + Rails
Hostinger, Docker, Ruby on Rails, PostgreSQL, Caddy
I refactored the project into a Rails monolith and hosted it on a single Hostinger VPS provisioned with Docker Compose. Everything previously split across GitHub Pages, EC2 and RDS now lives in one reproducible environment, deployed automatically on every push to main.
The monolith eliminated the round-trip latency of an external API call and the EC2 + RDS running costs. It also made the community counter update in real time on every coin collection with no page reload and no polling β something that wasn't practical with an external API.
Railway was evaluated as an alternative, but a VPS gives full visibility into the environment with no abstractions hiding the infrastructure, and the flexibility to keep extending it with new services down the line.
Key decisions:
- Consolidated EC2 API + RDS into a single Rails monolith with one codebase, one server, and lower fixed cost.
- Docker Compose provisioning, fully reproducible from source, and deployed automatically on every push.
- Real-time community counter, with updates on every collection, no page reload or polling required.
Decisions
Some decisions taken in this project were deliberate from day one, while others emerged as the project evolved. Both reflect how I evaluated tradeoffs and made technical choices during this process.
Decisions I deliberately made:
Bilingual i18n with locales-scoped routes (scope '(:locale)', locale: /en|pt/) to reach both audiences without maintaining separate pages or codebases.
Locale detection fallback chain (URL β cookie β Accept-Language header in ApplicationController) so visitors get the right language automatically, with no manual selection required.
Hand-built CSS design system with design tokens.css and no CSS framework, to develop full ownership of the visual language and avoid introducing a framework dependency.
Gamified experience with coin-collection and three distinct mini-games: index.js initiating the hidden box coin, momentum-based bar to fill on fillBar.js, and rock-paper-scissors.js game; with localStorage persistence via gameState.js to demonstrate how gamification can drive engagement and reflect my genuine interest in building gamified systems.
Event-tracking API POST /api/record-event recording each coin per anonymous client to give the gamification loop a measurable end-point and connect individual actions to a shared community total.
Server-aggregated live metrics MetricsCalculator surfaced as global counters on the homepage to add a community dimension that gives each individual collection a shared meaning and motivates further participation.
12-hour Rails cache on MetricsCalculator#L3 data to keep database traffic lean and avoid recalculating the same values on every page load.
Cache invalidation on new collection MetricsCalculator#L8 refreshing metrics in the same transaction as the coin write to keep the counter accurate without a separate step.
Anonymous client identity via httpOnly, SameSite=Lax, 1-year UUID cookie EventsController#L48 to collect participation data without requiring accounts, consent dialogs, or any personal information from the visitor.
Migrating off AWS (EC2 + RDS) to a single Docker host for cost and operational simplicity.
Docker Compose docker-compose.yml on a single VPS for a consolidated, reproducible environment deployed automatically on every push.
GitHub Actions CI test suite (unit + system tests, Brakeman, bundler-audit, importmap audit, RuboCop) and retry-hardened SSH CD .github/workflows/ to maintain production discipline and ensure a broken build never reaches the live environment.
Security hardening: CSP, rate limiting Rack::Attack, CSRF, model-level coin validation CoinEvent, full test suite; to prevent regressions and apply the same security standards I would on any client project.
Rails 8 defaults I adopted:
Puma as the Rails application server and Caddy as the reverse proxy, both Rails-friendly defaults for a single-VPS deployment.
Migrated from MySQL (used in Phase 2 due to AWS RDS defaults) to PostgreSQL in Phase 3, which has deeper Active Record support and is Rails' preferred database (config/database.yml).
Gamification logic was written before the Rails migration and kept in vanilla JS. Stimulus is designed for light server-rendered interactivity, not stateful game loops, so the original approach remained the right fit.
Production Docker hardening with non-root user, jemalloc memory allocator, and bootsnap precompile (Dockerfile) for a leaner and faster container and to keep resource usage low on a fixed-cost VPS.
Solid Cache running off the database for the MetricsCalculator's 12-hour cache (production.rb) to avoid running a separate Redis instance and keep all persistence within the existing database.
Importmap-rails shipping ES modules (config/importmap.rb) to serve JavaScript without a build step, keeping the deployment pipeline simple and removing Node as a dependency.
PWA manifest enabled for mobile UX with no additional offline logic (application.html.erb#L19) so visitors on mobile can install the site as an app if they choose to.
Gamification
Gamification is the application of game elements to motivate user engagement, an area I have a genuine interest in. The goal was to demonstrate how simple game mechanics can make a personal portfolio page memorable, with the entire experience designed to unfold across a single scroll of the homepage.
The diagram above outlines the three-stage flow: discovery across the page scroll, goal clarity through the overlay, and the community payoff at the end. Each game was hand-written in vanilla JS as a small independent module, with no game framework involved. The sections below cover how each piece was built.
Core elements of the game experience:
- Hidden Coin index.js
A coin intentionally hidden on the page as the first discovery challenge. The overlay hints at its location so visitors can return to collect it if they scroll past.
- Fillbar Game fillBar.js
A momentum-based bar that gets filled by repeated presses of the button and decays over time if left alone. A built-in timer tracks completion speed, stores the personal best, and encourages repeated attempts to beat it.
- Rock Paper Scissors Game rock-paper-scissors.js
A three-option game where the computer picks at random. Results are tracked across sessions with a running win, tie, and loss count displayed in the overlay.
- Central Game State gameState.js#L11
A state manager that coordinates all coin logic without a framework. Each collection follows a consistent pattern: update state, persist to localStorage, check for completion, sync to the server, and refresh the community metrics. Coin state is stored in localStorage for persistence across visits and synced to the server on each collection. This keeps the experience stateful without requiring a login, while still recording events for the community metrics. No external library involved.
- Overlay Summary uiElements.js
The connecting element that turns three separate mini-games into one coherent experience. It tracks collection progress, hints at each coin location, and displays the community view with live participation stats.
- Live Community Metrics gameState.js#L50
Each collection is logged anonymously to the database, contributing to a live community count surfaced to every visitor. The metrics are cached on each write and protected by rate limiting to keep the data reliable.
// gameState.js
async collectCoverCoin() {
if (!this.flags.cover) {
this.flags.cover = true;
this.save(); // localStorage
this.checkCompletion();
await recordCoinCollected("boxCoin"); // POST /api/record-event
fetchAndDisplayMetrics();
trackEvent("boxCoin");
return true;
}
return false;
}
API
Each coin collection is recorded as an anonymous event β no account, no personal data, just a UUID stored in an httpOnly cookie that persists across visits.
On collection, gameState.js writes to localStorage to keep the game state client-side, then calls the API to record the event server-side. Coin names are validated against a fixed list before the write, and the endpoint is rate-limited to prevent abuse.
MetricsCalculator aggregates the coin_events table into community totals and caches the result for 12 hours, so every new visitor gets fast homepage counters without triggering a fresh database query on each arrival.
# metrics_calculator.rb
def self.stats(cache: Rails.cache)
cache.fetch("coin_metrics", expires_in: 12.hours) { calculate_stats }
end
def self.calculate_stats
{
totalCoinsCollected: CoinEvent.count,
totalUsersWithCoins: CoinEvent.distinct.count(:client_id),
totalUsersWithAllThreeCoins: Client.joins(:coin_events)
.group("clients.id")
.having("COUNT(DISTINCT coin_events.coin_name) >= 3")
.count.length
}
end
0
total coins were collected
by everyone who played the game
Deploy
A push to main runs the full CI flow on GitHub Actions (.github/workflows/ci.yml). Security scans (Brakeman, bundler-audit, importmap audit), a RuboCop linting pass, and a test suite covering controller responses, model validations, MetricsCalculator caching logic, and a Selenium headless browser smoke test all run in parallel. Only a green build proceeds to deploy.
The deploy workflow (.github/workflows/deploy.yml) connects via SSH, runs the multi-stage Docker build, and brings the stack up with docker-compose. Retry logic was added after hitting intermittent SSH timeouts from the GitHub Actions runners.
Design
The decision to hand-build the design system rather than reach for Tailwind or Bootstrap was intentional. This project was an opportunity to develop my own design skills and build something that was genuinely mine from end to end. A framework would have been faster, but speed wasn't the goal here.
The result is a tokens.css file that drives the entire visual language β typography, colour scale, spacing, and breakpoints β keeping the styling consistent across the games, the project pages, and the bilingual layout without a framework dependency.
A few other deliberate design choices were: glass-morphism styled buttons, a bottom navigation bar on mobile, and a mobile-first layout that was fully designed in Figma before any CSS was written.
Plan
Each phase of this project was treated as a complete, shippable product before moving forward. The planning phase reflects a deliberate decision to design the full user experience in Figma before writing any code. The monitoring phase at the end was not unplanned cleanup; it represents the point where the architecture was stable enough to layer in production polishing, comprehensive testing, and security hardening.
Phase 3 was not part of the original project plan. It was introduced as a necessary step after the constraints found in Phase 2. AWS felt like the right production environment at the time, but the operational overhead and cost of running EC2 and RDS for a single-developer project proved hard to justify. That constraint led to discovering the Rails monolith approach and consolidating everything onto a single VPS. The result was a better fit for the scope of this project that wouldn't have been found without going through Phase 2 first.
Learnings
This project set out to be a living portfolio. A place to demonstrate my interest in gamification and showcase my abilities as a solutions engineer and developer. Along the way, a more specific question emerged: how much production discipline could I bring to a personal site? The answer covered infrastructure decisions, back-end architecture, gamification design, and continuous deployment. Every decision was deliberately made to reflect how I would treat any client's system.
The end result is the page that you are visiting right now as this documentation and the product are one and the same.
These are the most significant insights from building this project:
- Right-sizing infrastructure.Β
The need for an external API uncovered how many infrastructure options exist and how easy it is to reach for more than a project actually needs. Implementing AWS in Phase 2 taught me that cloud-native can be over-engineered for the wrong scope.
Two managed services added cost and complexity the project didn't need. That constraint led to discovering the Rails monolith approach. When paired with a Docker host I can fully control, it proved to be the better engineering decision. The stack became simpler to operate, easier to reproduce, and a better fit for the actual scope of the project.
- Ruby on Rails lets a solo dev run lean.
The monolith structure meant fewer moving parts to build, monitor, and maintain. With the database directly connected to the application layer, the data model could evolve without negotiating an API contract between services.
Having the front-end and back-end in one codebase reduced context switching and allowed a more agile workflow throughout the project. Rails is not the right fit for every infrastructure, but for a solo developer with full ownership of the stack, it was the most productive decision in the project.
- Gamification really shines when it's measurable.
One of the main goals was to showcase how simple gamification elements can engage visitors in a meaningful way. The three mini-games created a cohesive flow with a clear end goal, but the gamification only became truly compelling when it became measurable.
Adding live community metrics based on past visitor participation was the decision that closed the loop. It transformed individual engagement into a shared number that every visitor can see and contribute to. In my view, that community layer is what separates simple game-like elements from memorable gamification experiences.
- A design-token system pays off across pages.
One token file kept every page, game, and bilingual layout visually coherent without a framework dependency. The scope stayed small, the bundle stayed lean, and any style change propagated consistently across the whole site.
The decision to hand-build the design system rather than reach for a framework was deliberate. This project was an opportunity to develop my own design skills and build something entirely mine. A framework would have been faster, but full ownership of the visual language was the goal, and the token structure proved to be the right foundation for a project of this scope.
- Treating a small project like production is the point.
This project was engineered with the same discipline I would apply to any client's system. Security hardening, rate limiting, a full test suite, and a proper CI/CD pipeline are all deliberate exercises. A personal portfolio does not strictly require any of them, but the habits formed on small projects like this one are the baseline any serious production system demands.
Enterprise systems are infinitely more complex, but the attention to detail that keeps them maintainable starts with exactly this kind of practice. The scale of the project was small. The standard applied was not.
Thank you for checking out this project!
Explore other projects:
JIKO App
A gamified productivity app designed to track focus sessions and visualize personal growth.
N8N RSS Bot
A robust automation engine that orchestrates participant workflows and delivers RSS updates via WhatsApp.
Trofy
A βTo-Do likeβ mobile app that gamifies personal objectives to reinforce the development of good habits.
Born Survivor
The second demo Unity game with a βPick 1 of 3β rogue-like style mechanic. Inspired by the game Vampire Survivors.
Flappy Astronaut
A demo Unity game to implement game development and C-sharp concepts. Inspired by the game Flappy Bird.