EMMANUELAcquah
(BACKEND) — WRITINGS · NO VIDEOS

The quiet part, written down.

Case files and field notes from the systems behind the screens — cron infra, queues, Postgres and deploys. Read instead of watch.

INDEX · 02 ENTRIES2025—2026 · ACCRA, GHANANEED THIS BUILT? →
001CASE FILE · OCT 2025 · 6 MIN READNODETYPESCRIPTBULLMQPOSTGRESREDISDOCKER

Building Freecron: Architecture Behind a Cron-as-a-Service Platform.

Running scheduled HTTP jobs reliably, without maintaining cron infrastructure. No servers to keep awake, no timezone math, no silent failures.

When I started building Freecron, I had one goal which was to run scheduled HTTP jobs reliably without maintaining cron infrastructure. I acknowledge the previous cron jobs that i used but they all fell short of somethings i needed the most including deep logs and metrics, not paying for infrastructure just to ping my server, poor user journey, silent failures and many more

Here is the architecture that made it work.

/The three layers

Freecron splits into three layers, each knowing only about the layer below it like an inward pattern where authority trancends from top to down.

  • Presentation — HTTP routes, validation, controllers/Http handlers .These layers dont know about data modelling operations nor businnes logic .
  • Application — Five main modules holding the business logic: Jobs, Scheduler, Executions, Notifications, User.
  • Infrastructure — Repositories, Postgres, Redis, BullMQ, Slack, email, node-cron, logging. There's no business logic here.
Routes call services, services call repositories, repositories talk to the database and external services. Nobody skips a layer.

/The five modules

  • Jobs owns the lifecycle , create, read, update, delete, toggle, run now. It handles defaults, ownership checks, and DTO mapping.
  • Scheduler owns when jobs run. Two workers, one service.
  • Executions owns the history , every run recorded with status, duration, response, error.
  • Notifications owns outbound alerts , email and Slack, enqueued and delivered separately.
  • User owns user's operations.
NB: Each module is self-contained: routes, service, repository, types, and tests together. The project uses OOP approach where there's less to no shallow modules , low cognitive load for interfaces and modules, more information hiding and deep modules hence reducing code complexity, encourages code maintainance just like John Ousterhout always wanted .

/The two workers for a job lifecycle

  • The Watchman is a node-cron worker. Every minute it finds due jobs, updates nextRunAt, and hands over the job to Schedular service who enqueues the job and pushes to BullMQ , the message broker.
  • The Executor is a BullMQ worker. It works in the background 24/7 and picks up queued jobs, makes the HTTP call, calls Execution service to record the execution, and fires the lifecycle listeners.

/Five decisions that mattered

  • Update nextRunAt before queueing. If the update happens after, a long-running job gets queued again on the next tick. Duplicate execution. Updating first makes the job invisible to the next tick immediately.
  • The queue carries IDs, not entities making it lightweight and fast. The data payload includes { jobId, userId }. Each service called during the job lifecycle can easily fetch entities as a result without having to pass huge data around in the worker.The data is mostly always fresh and acid compliant .
  • Lifecycle lives in the processor, not the listeners. The processor runs inside the job's lock, so database writes are atomic with the run. Listeners run after the worker processor marks the job complete and Execution service records the result , it's purposely designed as such to avoid async work in the event listeners, a fire and forget pattern
  • Notifications are decoupled through a queue. The Executor does not send emails just as it dont know about jobs logic. It dequeues jobs from the background running broker and makes the http call.The Executor stays fast, and a failing provider does not fail the job.
  • The repository handles views, not just entities. Joins are a data concern. Keeping SQL in the repository keeps the database shape out of the services.

/The failure model

Freecron is designed to fail in specific, observable and predictable ways.

WHENWHAT HAPPENS
External API fails or falls within 4xx/5xxExecution recorded as failed
Network errorExecution recorded as failed
DB down during writeProcessor throws, BullMQ retries
Notification provider downNotification retries, execution unaffected
Worker crashBullMQ requeues the stalled job
OOMThe container logs it, not the app

/Observability from day one

Pino, JSON to stdout to Grafana. One base logger per process: freecron-api, freecron-worker, freecron-scheduler. Child loggers carry jobId, executionId, userId. Log the important events, not every read.

Metrics and tracing come later. Logs tell you what happened; metrics tell you how often.

/The testing model

LAYERREALMOCKED
UnitNothingRepositories, cache, scheduler
RouteThe routeThe service
IntegrationReal DB, real RedisNothing
WorkerReal BullMQ, real DBExternal fetch
E2EEverythingExternal fetch, providers

/The takeaway

The strongest part of this architecture is not the diagram. It is the decisions and the trade-offs behind them. Anyone can list modules. What matters is knowing why each choice was made, and what it cost.

Freecron is not perfect. But it runs jobs reliably, records every run, and tells you when something breaks. That is the point.

— E. ACQUAH

002REFLECTION · OCT 2026 · 12 MIN READAWSDOCKERCI/CDNETWORKINGDEVOPSBACKEND

Cloud without the crutch: how I think about building on AWS.

A reflection on broken deployments, silent timeouts, and the slow realization that the cloud rewards discipline over cleverness.

  • The bug is almost never where you are staring. Walk the layers one at a time and prove the cause before you fix the symptom.
  • A timeout and a refusal are different problems. A timeout points at the network. A refusal points at the service.
  • Choose boring, portable tools. If I cannot move a stack to a VPS in one day, it is too complex for what I am building.
  • A pipeline is a written promise. Every deploy happens the same way, and a rollback is one docker pull away.

/Recalling my very first bug that cost me some hours

I still remember the first time I broke a production server with a single dropdown. It was a NAT Gateway, one of those AWS services you create once and then forget about, until it stops working.

I had spent an entire morning setting up a private subnet, a route table, a Security Group, and an Internet Gateway. Everything was carefully configured. Everything looked correct.

Then I tried to curl Google from inside a private instance, and nothing happened.

BASH
ubuntu@ip-10-0-0-56:~$ curl https://google.com
^C
ubuntu@ip-10-0-0-56:~$ ping google.com
PING google.com (142.251.142.238) 56(84) bytes of data.
^C
--- google.com ping statistics ---
5 packets transmitted, 0 received, 100% packet loss

Not an error. Not a refusal. Just a long, silent pause that ended in a timeout.

I spent hours staring at the console. The Security Group allowed outbounds to 0.0.0.0. The NACL was very open. The route table pointed 0.0.0.0/0 at the NAT Gateway. The NAT Gateway was marked Available. The Internet Gateway was attached to the VPC. Everything, by every visible measure, was fine.

The problem was one field I forgot to look at. The NAT Gateway was sitting in a private subnet, i take full accountability and feel a bit ashamed now when i look back . A NAT Gateway has to live in a public one which is a basic. Otherwise it has no route to the internet, and nothing behind it can reach the outside world.

BASH
# On the EC2, the route looked correct:
$ ip route
default via 10.0.0.49 dev ens5
10.0.0.0/24 dev ens5
10.0.0.2 via 10.0.0.49 dev ens5

# But on the console, the NAT Gateway was in:
Subnet: astroverse-api  (10.0.0.48/28)   <- private
# It should have been in:
Subnet: astroverse-web  (10.0.0.32/28)   <- public

One wrong click in a dropdown. Three hours of debugging. A lesson I have never forgotten.

The fix was small:

  • Delete the NAT Gateway in the private subnet.
  • Create a new NAT Gateway in the public subnet.
  • Attach an Elastic IP.
  • Update the private route table's target to point to the new NAT Gateway.

Two minutes of work, once I found the bug.

That experience is the foundation of how I think about cloud today. Not as a platform to master, but as a set of layers to understand. Every layer has a job. Every layer can fail. And the bug is almost never in the place you are staring at.

/Why I do not trust managed services by default

AWS is very good at selling convenience. RDS is easier than running Postgres. Lambda is easier than running a server. Secrets Manager is easier than a .env file. Elastic Beanstalk is easier than Docker. The marketing is persuasive, and the documentation is excellent.

But every managed service is a decision to trust someone else's abstraction, and a decision to accept a certain kind of lock-in. When you use Lambda, you are tying your application to AWS's runtime. When you use RDS, you are accepting that migration will be painful. When you use DynamoDB, you are adopting a data model that does not translate. The convenience is real, but so is the cost.

I do not reject managed services. I just choose them carefully. For most of my projects, I do not need what they offer. Docker on EC2, PostgreSQL in a container, Nginx on the host: these are boring, portable, and cheap. They also work on Hetzner, DigitalOcean, or a Raspberry Pi. That matters more to me than the polish of a managed service.

There is a second reason, and it is less philosophical: I can see them. When a container fails, I read docker logs. When Nginx returns a 502, I read the error log. When Postgres is slow, I run EXPLAIN ANALYZE. I do not need a console to tell me what is wrong, because the system tells me itself. That kind of visibility is worth more than the convenience of a managed service.

/Portability is a design goal, not a nice-to-have

The first time I migrated a project from AWS to a VPS, it took an afternoon. Not because I am clever, but because I had made portability a design goal from the start.

Here is what I use instead of the AWS-native option:

NEEDWHAT I USEINSTEAD OF
RuntimeDockerLambda, platform-specific runtimes
DatabasePostgreSQL in DockerRDS, DynamoDB
API front doorNginxAPI Gateway
Schedulercron in a containerEventBridge
QueueRabbitMQ in DockerSQS
Secrets.env + SOPSSecrets Manager
LogsGrafana + LokiCloudWatch
Container registryGitHub Container Registry (GHCR)ECR
CI/CDGitHub ActionsCodePipeline
DNSCloudflareRoute 53
SSLLet's EncryptACM

Every choice points toward the exit. And the exit is what makes the entry safe. If you know you can leave, you are not trapped. If you are not trapped, you can negotiate. If you can negotiate, you are not paying too much.

This is not just a technical preference. It is a business decision. Startups die from lock-in. So do side projects. So do portfolios, sometimes.

The standard I hold every architecture decision to is simple: if I cannot port this to a VPS in one day, it is too complex for what I am building.

Here is the Dockerfile that works on AWS, Hetzner, DigitalOcean, or a Raspberry Pi:

DOCKERFILE
FROM node:lts AS builder
WORKDIR /app
RUN corepack enable
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
COPY . .
RUN pnpm build

FROM node:lts-alpine AS runner
WORKDIR /app
ENV NODE_ENV production
COPY --from=builder /app/public ./public
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
EXPOSE 3005
ENV PORT 3005
CMD ["node", "server.js"]

No platform-specific code. No AWS SDK in the app. No environment variables that only exist in one console. The app does not know where it is running. It only knows it has a port and a filesystem. That is the portability test I hold every decision to.

/The pipeline taught me the rest

The NAT Gateway bug taught me to walk the layers. The CI/CD pipeline taught me what comes after: reproducibility.

Before I had a pipeline, every deployment was a small adventure. SSH in, pull the code, install dependencies, build, copy files, restart the service. Five commands, and five places to make a mistake. I once shipped a broken build because I forgot to run pnpm build after a rebase. The site was down for forty minutes because I was "just doing a quick deploy."

A CI/CD pipeline is not just automation. It is a written promise that every deployment will happen the same way. Same build. Same image. Same environment. Same commands. The container that runs on my Mac is the container that runs on the EC2 is the container that runs in production. That guarantee is worth more than the time it saves.

The pipeline takes a git push and turns it into a live deployment in under three minutes. It builds the image on GitHub's runners, pushes it to GHCR, SSHes into the EC2, pulls the new image, and restarts the container. Here is the full workflow:

YAML
name: Build and Deploy

on:
  push:
    branches: [main]

jobs:
  build-and-deploy:
    runs-on: ubuntu-latest

    permissions:
      contents: read
      packages: write

    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Login to GHCR
        uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Build and push
        uses: docker/build-push-action@v5
        with:
          context: .
          push: true
          tags: |
            ghcr.io/${{ github.repository }}:latest
            ghcr.io/${{ github.repository }}:${{ github.sha }}

      - name: Deploy to EC2
        uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.EC2_HOST }}
          username: ${{ secrets.EC2_USER }}
          key: ${{ secrets.EC2_SSH_KEY }}
          script: |
            docker pull ghcr.io/${{ github.repository }}:latest
            docker stop rosait || true
            docker rm rosait || true
            docker run -d \
              --name rosait \
              -p 127.0.0.1:3005:3005 \
              --restart unless-stopped \
            ghcr.io/${{ github.repository }}:latest

Two details here are intentional:

  • || true on stop and rm. On the first deploy there is nothing to stop. On later deploys there is. The pipeline does not fail because of the difference.
  • -p 127.0.0.1:3005:3005. The container is only reachable from the host. Nginx on the host handles the public side, so the port is never exposed to the internet.

Every step is visible. Every failure is documented. And every rollback is a docker pull away, because every build is also tagged with the Git SHA:

BASH
# Rollback: pull the previous SHA tag and restart the container.
docker pull ghcr.io/emman-tt/rosait:a1b2c3d4
docker stop rosait
docker rm rosait
docker run -d --name rosait -p 127.0.0.1:3005:3005 \
  ghcr.io/emman-tt/rosait:a1b2c3d4

If latest breaks, a1b2c3d4 still works. The old image stays in the registry until I delete it. The pipeline gives me a way back.

I still feel the same satisfaction watching the green checkmarks appear as I did the first time. Not because automation is magic, but because it is the closest thing to a guarantee I can build.

/The philosophy, in one sentence

Cloud should make you faster, not more dependent.

When the credits run out, and they always do, I want to move my stack in an afternoon, not rewrite it in a quarter. That is the standard I hold every architecture decision to. That is why I use Docker instead of Lambda. That is why I use Postgres instead of DynamoDB. That is why I use GitHub Actions instead of CodePipeline. That is why the NAT Gateway bug mattered so much: because it forced me to understand the system instead of trusting it.

I do not think everyone should work this way. Some teams need what managed services offer, and that is fine. But for the things I build (portfolios, side projects, small products), the quiet discipline of portability is worth more than the noisy convenience of the cloud.

The bug that cost me three hours taught me more than any tutorial ever did. It taught me to trust the layers, not the platform. To verify the assumption, not the configuration. To ask, always: where would I go if this stopped working tomorrow?

That is how I think about cloud. Not as a service to master, but as a set of decisions to own.

/Quick reference

Every lesson above has code attached. If you just want the pieces:

  • The Dockerfile: portable, multi-stage, framework-agnostic. See Portability.
  • The CI/CD pipeline: GitHub Actions, GHCR, SSH deploy. See The pipeline.
  • The debugging commands: nc, curl, ip route, docker logs. See The two errors and The layers.
  • The rollback pattern: pull the previous SHA, restart the container. See The pipeline.

— E. ACQUAH

New entries land whenever a system ships. The backlog is the good kind of long.

START A PROJECT →