Sung J. Kang
sjk@sungjkang.com:~/writing $  
2026-06-18 postgresbackendarchitectureinfrastructurebank-of-parents 7 min read

Just Use Postgres for Everything

Bank of Parents is a side project, and I am the only person working on it. It runs on one database. The database is Postgres, and it does every job a backend usually spreads across four or five services. There is no Redis, no SQS, no separate job runner, no document store, no analytics warehouse. There is a Postgres instance, an API, and a worker, all on one small server. That is the whole backend.

I built it this way for two reasons, and both come from it being a one-person project. The first is cost. Every managed service you add is a line on a bill that has to be paid whether the app makes money yet or not. A hosted queue, a cache cluster, an identity provider, and a warehouse are each a monthly charge, and together they would cost more than the rest of the app combined. Bank of Parents runs on one server and one database, so the bill is one server and one database.

The second reason is that infrastructure is work, and I am the only one who does it. Every service you add is a thing that can break at 2am, a thing you have to deploy, and a thing you have to patch and back up. A queue is a server. A cache is a server. Each one has its own client library and its own failure modes. With no team to share the on-call load, I would rather operate one system I understand deeply than five systems I half-watch. Postgres already does most of these jobs well, so I let it.

Here is what that looks like in practice.

The job queue is a table

Bank of Parents schedules transactions, sends reminders, and cleans up expired data. Normally you reach for Sidekiq, Hangfire, or SQS to do that. I used a table called scheduled_jobs instead.

The table holds everything a queue needs. There is a job_type, a payload stored as JSONB, a schedule_at timestamp, and a cron string for recurring jobs. It also tracks failure_count, last_error, and an is_dead_letter flag. A worker wakes up on a timer, asks Postgres for the jobs that are due, and runs them.

SELECT * FROM scheduled_jobs
WHERE is_dead_letter = FALSE
  AND schedule_at <= now()
  AND (next_run_at IS NULL OR next_run_at <= now());

The behavior people want from a real queue is all there. A one-time job gets deleted after it runs. A recurring job gets its next_run_at recomputed from its cron expression. A job that throws an exception has its failure_count incremented, and after five failures it is marked as a dead letter so it stops retrying. I did not build a queue. I wrote a SELECT, a DELETE, and an UPDATE, and the queue fell out of that.

The timer that wakes the worker is the only outside piece, and it does nothing but say “check the table now.” All of the state lives in Postgres, which means I can inspect the queue, retry a job, or clear a backlog with plain SQL. No special dashboard, no separate broker to keep alive.

The cache is also a table

The app needs a key-value store with expiring entries, which is the job Redis usually gets hired for. I used a table called kv_store.

It has a key, a value, and an expires_at. Writing a value is a single upsert.

INSERT INTO kv_store (key, value, expires_at)
VALUES (@Key, @Value, @ExpiresAt)
ON CONFLICT (key) DO UPDATE
SET value = EXCLUDED.value,
    expires_at = EXCLUDED.expires_at,
    updated_at = now();

Reading a value filters out anything expired, so a stale entry is invisible the moment it lapses even before it is deleted. The actual deletion is handled by a recurring job that runs once a day and removes expired rows. That cleanup job lives in the same scheduled_jobs table as everything else. The cache and the thing that prunes the cache are both Postgres. There is no second system to provision, secure, or watch.

Flexible data goes in JSONB

Some data does not want a fixed shape. Push notification subscriptions are different for web, iOS, and Android, and I did not want a table that tried to model all three. I stored the raw subscription as JSONB in a subscription_data column.

Postgres lets you index into JSON, so I can still enforce a unique constraint on the endpoint inside that blob without flattening it into columns. JSONB is also where job payloads live. This is the part of the workload people stand up MongoDB for, and Postgres covers it without a second database.

Auth lives there too

Bank of Parents stores OAuth tokens and runs an OpenID Connect provider, and all of that state sits in Postgres. There are tables for tokens, applications, scopes, and authorizations. Sessions and refresh tokens are rows.

People often push this into Redis or a hosted identity service. I kept it in the database because tokens are just data with an expiry, and Postgres already handles data with an expiry. Keeping it there means a user, their permissions, and their active tokens are all queryable in one place, in one transaction.

Reporting is a view

The app shows parents a monthly budget with carryover from previous months. That is a reporting query, and the instinct is to precompute it into a denormalized table or push it into a separate analytics tool.

I made it a view called budget_month_values. It uses generate_series to emit a row per category per month, window functions to compute the running carryover, and joins to pull in the most recent target and the actual spending. The computation is defined once, in SQL, and it is always current because it reads live data. There is no pipeline to run and no precomputed table to fall out of date.

Analytics reads the same database

When I want to look at the data for analysis, I connect to the same Postgres with a read-only role. There is a readonly group role and a login that can select but not write.

This is the part where people build a warehouse and a sync process to copy production into it. For a side project that is a paid service and a pipeline to maintain, both to answer questions I can answer with a SELECT against a read-only role. When the data grows enough to need a warehouse, I will build one. Today it would be a monthly charge and more to operate, for no benefit I can use yet.

When this stops working

This approach has a ceiling, and I want to be honest about where it is. The scheduled_jobs poller does not scale to millions of jobs a second the way a dedicated broker does. The kv_store will never be as fast as Redis for raw key lookups. Querying production for analytics stops being fine once the queries get heavy enough to fight with live traffic.

None of those limits are close for a side project with the users Bank of Parents has. I am a strong believer in adding infrastructure when a real problem demands it, not before, and on a one-person project the cost of adding it early is real money and real time I do not get back. Every one of these patterns can be swapped for the specialized tool later, because they all expose the same boring interface, a row in a table. If the app grows enough to need Redis or a warehouse, that growth will pay for them. Until then, one database means one bill, one thing to deploy, one thing to back up, one thing to reason about, and one place where all the data is consistent with itself.