Skip to main content

SMAIL — Serverless Email for goodintechnology.com

Replaced an EC2-hosted mail server with a serverless SES/Lambda/DynamoDB email platform for goodintechnology.com, then had to solve a bigger problem than the architecture itself: the GitLab CI cost of building its Android client.

AWS LambdaSESDynamoDBTerraformGitLab CI/CD

Background

I'd owned goodintechnology.com for years without doing much with it. When I started sharpening my GitLab skills, I wanted a real project instead of working through tutorial after tutorial — and a professional site looked incomplete if I couldn't receive email at the same domain.

My first solution was a Mail-in-a-Box server on a Terraform-provisioned EC2 t2.micro instance. It worked, but automation and recreatability were poor, and AWS costs were increasing. I also wanted to refresh my serverless skills, so replacing the mail server became the next project. That project became SMAIL.

Architecture

Inbound mail — Amazon SES receives the message and invokes an inbound-processing Lambda. The Lambda stores the raw message in S3 and the mailbox data in DynamoDB, which is encrypted with KMS.

Reading mail — the SMAIL web and Android clients authenticate with Cognito, then call API Gateway endpoints such as mail_list and mail_read, which read mailbox data from DynamoDB.

Sending mail — the client calls the API Gateway send endpoint, a mail-send Lambda submits the message to SES, and raw/mailbox copies are stored in S3 and DynamoDB respectively. SQS provides a dead-letter queue for failed asynchronous processing — that piece came with the architecture pattern rather than being something I designed in depth.

Terraform provisions the supporting AWS infrastructure, spanning multiple domains and environments including goodintechnology.com and poolczar.com.

Challenge

Keeping the full architecture in my head while Terraform-managed development and production environments spread across multiple domains was one of the harder parts — not any single failed resource, but the interconnected state.

The bigger, less expected challenge came later, from a completely different direction: GitLab CI. I wasn't satisfied with the mobile experience of the web interface, so I built a dedicated Android app. Between Android builds, automated tests, linting, and QA pipelines, the project started consuming large amounts of GitLab shared-runner time — at the same time I was actively developing SMAIL, PoolCzar, GoodinTechnology, and Sourdough. Buying additional shared-runner minutes was becoming increasingly expensive.

Approach

For the multi-domain Terraform complexity, I leaned on an AI assistant as an engineering review partner: I'd describe the architecture, provide the existing Terraform and state context, and ask it to validate proposed changes before I applied them. The value wasn't that it made architecture decisions for me — it reduced the amount of second-guessing required when reasoning across interconnected state and infrastructure. I stayed responsible for the decisions and the implementation.

For the CI cost problem, I moved the workload to dedicated GitLab runners on AWS, provisioned with an Auto Scaling Group so capacity could scale with build demand. CI configuration routed high-memory jobs — especially the SMAIL Android builds — to larger EC2 capacity only when those jobs actually needed it.

Result

The dedicated runner platform solved a problem outside SMAIL, too: Sourdough had exhausted its GitLab shared-runner credits, and I connected its GitLab project to the same EC2-backed runner infrastructure to unblock its builds.

The custom runners solved the GitLab runner-minute problem but introduced another cost curve — high-memory EC2 usage started driving AWS costs back up. For the largest builds, I eventually moved some execution to my local laptop rather than pay for large-memory EC2 instances that were only needed intermittently.

Lessons

CI infrastructure deserves the same cost scrutiny as production infrastructure. Build systems can get expensive quickly once mobile builds, automated testing, linting, and multiple projects share one CI platform.

Custom runners create more options than shared runners: capacity shared across several projects, scaling with demand, memory-intensive workloads routed differently, and no need to keep buying ever-larger pools of shared-runner minutes.

And the underlying lesson generalizes past CI: infrastructure choices aren't permanent. Moving from shared GitLab runners to AWS runners was the right call at one point; moving some high-memory builds from AWS to local hardware was the right call later. The best infrastructure decision is usually the one that matches the current workload, not the one that looked best when the system was first designed.