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.
WHEN
WHAT HAPPENS
External API fails or falls within 4xx/5xx
Execution recorded as failed
Network error
Execution recorded as failed
DB down during write
Processor throws, BullMQ retries
Notification provider down
Notification retries, execution unaffected
Worker crash
BullMQ requeues the stalled job
OOM
The 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
LAYER
REAL
MOCKED
Unit
Nothing
Repositories, cache, scheduler
Route
The route
The service
Integration
Real DB, real Redis
Nothing
Worker
Real BullMQ, real DB
External fetch
E2E
Everything
External 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.
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:
NEED
WHAT I USE
INSTEAD OF
Runtime
Docker
Lambda, platform-specific runtimes
Database
PostgreSQL in Docker
RDS, DynamoDB
API front door
Nginx
API Gateway
Scheduler
cron in a container
EventBridge
Queue
RabbitMQ in Docker
SQS
Secrets
.env + SOPS
Secrets Manager
Logs
Grafana + Loki
CloudWatch
Container registry
GitHub Container Registry (GHCR)
ECR
CI/CD
GitHub Actions
CodePipeline
DNS
Cloudflare
Route 53
SSL
Let's Encrypt
ACM
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:
|| 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.