Skip to content
HoginHogin
Go back

The same architecture, self-hosted: rebuilding the part-1 AWS serverless diagram on your own VM for $0/mo (part 2/2)

11 мин чтения

In part 1 we took the serverless AWS diagram apart layer by layer and rebuilt a smaller copy of it with a single CloudFormation template. The same illustration cycle that produced the first diagram has a twin: a grid of nine services on one ubuntu-vm with a “$0/mo” badge — Docker, Kong, Node.js, PostgreSQL, MinIO, NGINX, Prometheus, Keycloak, RabbitMQ. It is the same architecture, assembled at home: we’ll walk the boxes one by one, mark honestly where “the same thing” stops being the same, and assemble the whole stack with one docker-compose.yml.

Table of contents

Open Table of contents

Recap: what part 1 gave us

The AWS diagram read by one rule: a request has two paths — statics (Route 53 → CloudFront → S3) and dynamics (API Gateway → Lambda → DynamoDB), and everything else is plumbing: SQS with a worker for the heavy lifting, Cognito for tokens, CloudWatch for logs and metrics. Deploying that diagram is one template.yaml: update the stack — production is updated.

The twin diagram promises the same, but on your own VM: nine rectangles with familiar roles — only instead of managed AWS services, mature open-source projects. The roles match so closely that the diagram can be assembled with a translator, line by line.

The translator: AWS → self-hosted

Diagram translator

In the cloud diagramRoleOn your own VM
API GatewayAPI entry, tokens, limitsKong
Lambdayour codea Node.js container
DynamoDBthe databasePostgreSQL
S3filesMinIO
CloudFrontstatics, TLS, compressionNGINX
CloudWatchlogs and metricsPrometheus
Cognitosign-up, login, tokensKeycloak
SQS + Workerbackground jobsRabbitMQ + a Node.js consumer
Route 53DNSan A record to the VM
CloudFormation template.yamlthe whole stack in one filedocker-compose.yml

Every row on the right is a project with a decade of production behind it: Kong — an API gateway with declarative configuration, RabbitMQ — queues with delivery acknowledgement, MinIO — S3-compatible storage, Keycloak — an IAM server speaking OpenID Connect. An honest swap: same number of roles, zero licenses.

Why the diagram has both NGINX and Kong

In the cloud diagram, two gates stood in front of the code: CloudFront (TLS, statics, compression) and API Gateway (routing, token checks, limits). The self-hosted version repeats the pair: NGINX up front — terminates TLS on a free Let’s Encrypt certificate, serves the frontend statics from a volume, and compresses responses; Kong behind it — accepts only /api, verifies the JWT from Keycloak, applies rate limits, and routes to the app.

“Why two, when NGINX has proxy_pass?” — it does. But then routing, limits, and token checks move into the NGINX config and into application code. Kong keeps that work exactly where it lived in the cloud diagram, is configured by a declarative file — the analog of template.yaml — and never faces the internet: the only open ports on the VM are 80 and 443 on NGINX.

“The same architecture” ≠ “the same code”

Request paths on the VM

The diagram carries over; the code does not, all of it. Three places where swapping the box changes the nature of the work:

Monitoring repeats the same logic: Prometheus collects metrics from all nine services — what CloudWatch did in AWS. The difference is that CloudWatch turned itself on, while the exporters for Postgres, NGINX, and RabbitMQ are yours to set up.

Backups and updates — the layer the diagram doesn’t have

The AWS diagram had no “backup” box at all — and that’s not an omission: the service replicates DynamoDB and S3. On the VM it’s the opposite: nine containers, and each one’s data lives on a disk that dies with the disk. The minimum layer:

Without this layer, self-hosted becomes a lottery — with the odds not in your favor.

What “$0/mo” really costs

The badge on the reference, broken into honest lines:

So the real bill starts at ~$7/mo, not zero. But the part-1 serverless stack also costs nearly nothing at hobby load: the AWS free tier gives a million Lambda requests and 25 GB of DynamoDB per month, free, forever. At small load both options are almost free — which means the choice between them isn’t about money:

Serverless on AWSSelf-hosted on your VM
Software billper request$0 — all open source
Hardware billinside the per-request billthe whole VM, 24/7, idle included
At hobby load≈ $0 (free tier)≈ $7–15/mo (VM + domain)
Under constant loadgrows with every requestfixed by the VM size
A Hacker News wavescales on its ownhits the VM ceiling, scaling is yours
Updates and patchestheirsyours: docker compose pull
Backupsthe service’syours: pg_dump + restore drills
Where the data liveswith the providerentirely with you
Entry barrierlearning AWS services and limitsLinux, Docker, and compose

The break-even line reads straight off the table: constant, predictable load — the fixed price of a VM eventually beats the per-request bill, and self-hosted becomes cheaper; rare spikes, or no load yet — serverless holds the zero and requires no on-call. One more reason to move to your own VM isn’t about money at all: data that must not sit with a provider.

What you need to rebuild the diagram in one evening

The whole stack in one file

You need a VM with Ubuntu, Docker with the compose plugin, and a domain whose A record points at the VM. The project is five files and two folders:

selfhosted-app/
├── docker-compose.yml
├── nginx.conf
├── kong.yml
├── prometheus.yml
├── site/            # the built frontend
└── app/             # Node.js: server.js + worker.js

docker-compose.yml — the whole stack, the analog of part 1’s template.yaml:

services:
  nginx:
    image: nginx:1.27
    ports: ["80:80", "443:443"]
    volumes:
      - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
      - ./site:/var/www/site:ro
      - /etc/letsencrypt:/etc/letsencrypt:ro
    depends_on: [kong]

  kong:
    image: kong:3.7
    environment:
      KONG_DATABASE: "off"                      # DB-less: config from a file
      KONG_DECLARATIVE_CONFIG: /kong/kong.yml
    volumes:
      - ./kong.yml:/kong/kong.yml:ro

  app:                                          # the dynamics path
    build: ./app
    environment: &app_env
      DATABASE_URL: postgres://app:secret@postgres:5432/app
      AMQP_URL: amqp://app:secret@rabbitmq:5672/
    depends_on: [postgres, rabbitmq]

  worker:                                      # the queue consumer
    build: ./app
    command: node worker.js
    environment: *app_env
    depends_on: [postgres, rabbitmq]

  postgres:
    image: postgres:17
    environment:
      POSTGRES_USER: app
      POSTGRES_PASSWORD: secret
      POSTGRES_DB: app
    volumes: [pgdata:/var/lib/postgresql/data]

  rabbitmq:
    image: rabbitmq:4-management

  minio:
    image: minio/minio
    command: server /data --console-address ":9001"
    environment:
      MINIO_ROOT_USER: app
      MINIO_ROOT_PASSWORD: secret
    volumes: [miniodata:/data]

  prometheus:
    image: prom/prometheus
    volumes: [./prometheus.yml:/etc/prometheus/prometheus.yml:ro]

  keycloak:
    image: quay.io/keycloak/keycloak:26
    command: start --proxy-headers=xforwarded --hostname=auth.example.com
    environment:
      KC_BOOTSTRAP_ADMIN_USERNAME: admin
      KC_BOOTSTRAP_ADMIN_PASSWORD: secret
      KC_DB: postgres
      KC_DB_URL: jdbc:postgresql://postgres:5432/app
    depends_on: [postgres]

volumes:
  pgdata: {}
  miniodata: {}

kong.yml — the same API gate, declaratively:

_format_version: "3.0"
services:
  - name: app
    url: http://app:3000
    routes:
      - name: api
        paths: ["/api"]
    plugins:
      - name: jwt                  # the Keycloak JWT; openid-connect is Kong Enterprise
      - name: rate-limiting
        config:
          minute: 60

nginx.conf — the CloudFront + S3 pair in one file:

server {
    listen 443 ssl;
    server_name app.example.com;

    ssl_certificate     /etc/letsencrypt/live/app.example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/app.example.com/privkey.pem;
    gzip on;

    location / {                    # statics — from a volume on disk
        root /var/www/site;
        try_files $uri /index.html;
    }

    location /api {                 # dynamics — onward to Kong
        proxy_pass http://kong:8000;
        proxy_set_header Host $host;
    }
}

app/server.js — what Lambda did in part 1 is now just an HTTP server:

import express from "express";
import pg from "pg";

const db = new pg.Pool({ connectionString: process.env.DATABASE_URL });
const app = express();

app.get("/api/visits", async (_req, res) => {
  await db.query("insert into visits(at) values (now())");
  const { rows } = await db.query("select count(*)::int as n from visits");
  res.json({ ok: true, visits: rows[0].n });
});

app.listen(3000);

app/worker.js — what SQS used to trigger:

import amqp from "amqplib";
import pg from "pg";

const db = new pg.Pool({ connectionString: process.env.DATABASE_URL });
const ch = await (await amqp.connect(process.env.AMQP_URL)).createChannel();
await ch.assertQueue("jobs");

ch.consume("jobs", async (msg) => {
  await db.query("insert into jobs_log(payload) values ($1)", [msg.content.toString()]);
  ch.ack(msg);                      // no ack — the message returns to the queue
});

Backups — one cron line, shipping into the same MinIO:

# /etc/cron.d/app-backup
30 3 * * * cd /srv/app && docker compose exec -T postgres pg_dump -U app app | gzip \
  | docker run --rm -i minio/mc sh -c \
    'mc alias set b http://<VM-IP>:9000 app secret && mc pipe b/backups/app-$(date +\%F).sql.gz'

Deploy — one command, like sam deploy in part 1:

docker compose up -d --build

How to verify it works

First check — all nine containers alive:

docker compose ps

Second — an anonymous caller is stopped by Kong, just as API Gateway stopped them in part 1:

curl -i https://app.example.com/api/visits   # 401 — no token

Third — the full loop: a token from Keycloak, a request, the counter grows:

TOKEN=$(curl -s https://auth.example.com/realms/app/protocol/openid-connect/token \
  -d grant_type=password -d client_id=app \
  -d [email protected] -d password='Demo-pass-1!' | jq -r .access_token)

curl -H "Authorization: Bearer $TOKEN" https://app.example.com/api/visits
# {"ok":true,"visits":1,...}

Fourth — monitoring and backups: all Prometheus targets alive, the dump restores:

curl -s localhost:9090/api/v1/query?query=up | jq '.data.result[].metric.job'

zcat backups/app-2026-10-20.sql.gz | \
  docker compose exec -T postgres psql -U app -d app_check   # restore drill

Four checks — and the reference diagram is alive: the gate stops anonymous callers, the token opens the door, the code writes a row, metrics and backups are in place.

Bottom line

The diagram really is the same: two request paths, a queue for the heavy work, IAM for tokens, metrics so you can sleep at night — and the whole stack in one file, only template.yaml became docker-compose.yml. What changed is the owner of the boxes: updates, health, backups, and the 3 a.m. restart are now yours, and the “$0/mo” badge means $0 for software, not $0 for operations. So the honest choice between this diagram and part-1 serverless is not between two prices but between control over every byte and zero operations to run; under constant load and with data that must stay yours, the own VM wins, and under rare spikes and “ship by Friday”, serverless does. And if the VM is already standing, the natural next box is a WAF on a budget, which installs right in front of this NGINX.


Share this post:

Next Post
A minimal app architecture on AWS: a serverless stack — what every service does and how to read the diagram (part 1/2)