backend-architecture — independently scanned and version-tracked by SaferSkills.
SaferSkills independently audited backend-architecture (Agent Skill) and scored it 100/100 (green). The audit ran 55 deterministic rules across Security, Supply Chain, Maintenance, Transparency, and Community; it found 0 high-severity and 0 lower-severity findings. The full rule-by-rule trace and per-finding evidence are below. Free, methodology-open.
Findings & checks · 0 flagged
Every scanned point with the score it earned and what moved between them.
First recorded scan — no prior version to compare against.
The primary manifest — the file an agent reads to learn what this artifact does.
The most common mistake in vibe-coded backends is treating the application server as if it were a desktop computer: state lives where the code lives, the disk is permanent, restarts are exceptional, one instance runs forever. None of that survives contact with production.
This skill is the architectural baseline that makes a backend production-ready. It is not security-specific — but a backend that loses data, drops jobs, or rolls back to a half-state during deploys is also a backend whose incident-response surface looks much larger than it should. Pairs with the hardening skills, but operates at a different layer.
App servers are cattle. State lives elsewhere.
Any data that must survive a deploy, reboot, container restart, or instance replacement is state. State does not belong on the app server's local disk, in its memory, or anywhere bound to the instance's lifetime. It belongs in a system designed for durability.
This rule, followed consistently, eliminates 80% of "where did my data go?" incidents.
For every piece of data your app handles, decide where it lives before writing the code:
| Data type | Right place | Wrong place |
|---|---|---|
| User-uploaded files (images, PDFs, docs) | Object storage (S3, R2, B2, Spaces) behind a CDN | App server's uploads/ directory |
| Generated thumbnails / derivatives | Object storage, or regenerated on-demand from originals | App server's filesystem |
| Database rows (orders, users, posts) | Managed Postgres / MySQL, or a dedicated DB VM with backups | SQLite next to the app code |
| Session data | Redis, managed cache, or DB-backed sessions | In-process memory (Map, WeakMap) |
| Cache (computed data, hot reads) | Redis / Memcached / Cloudflare KV | Local memory if and only if loss-tolerant |
| Background-job state | Persistent queue (BullMQ + Redis, AWS SQS, GCP Pub/Sub, NATS JetStream) | setTimeout / setInterval in the web process |
| Logs | Centralized log destination (Loki, hosted, syslog forwarding) | App server's /var/log only |
| Secrets | Env vars from a secret manager, or runtime injection | .env checked into the image / repo |
| Search index | Managed search (Algolia, Meilisearch, Elasticsearch, Postgres GIN) | Recomputed from DB on every request |
| Email-sending | Transactional ESP (Postmark, Mailgun, SES, Resend) | Direct SMTP from the app server in production |
Every "Wrong place" row in the table is a real outage waiting to happen. Walk this list against your current codebase — anything misplaced is a refactor candidate.
This one bites everyone once. Anatomy:
./uploads/ (relative to working directory inside the container).uploads/ directory exists but is empty.The same trap occurs without Docker: PaaS platforms (Heroku, Railway, Fly, Render) typically run on ephemeral filesystems. The "disk" is a feature of the container, not the platform.
// Bad — writes to local disk that disappears on redeploy
import fs from 'node:fs/promises';
async function saveUpload(file: Buffer, key: string) {
await fs.writeFile(`./uploads/${key}`, file);
return `/uploads/${key}`; // served by app, lost on deploy
}
// Good — write to S3-compatible storage; URL is permanent
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
const s3 = new S3Client({
region: 'auto',
endpoint: process.env.S3_ENDPOINT, // R2: https://<acct>.r2.cloudflarestorage.com
credentials: {
accessKeyId: process.env.S3_ACCESS_KEY_ID!,
secretAccessKey: process.env.S3_SECRET_ACCESS_KEY!,
},
});
async function saveUpload(file: Buffer, key: string) {
await s3.send(new PutObjectCommand({
Bucket: process.env.S3_BUCKET,
Key: key,
Body: file,
ContentType: detectMime(file),
}));
return `${process.env.CDN_BASE_URL}/${key}`; // served by CDN, survives redeploy
}Recommended stack (cheap and fast for small/medium projects):
Alternatives: AWS S3 + CloudFront, Backblaze B2, DigitalOcean Spaces, MinIO if you must self-host.
See cloudflare-hardening for the R2-specific security configuration.
If your sessions live in-process (Express express-session with default MemoryStore, Next.js naive session-in-Map), every deploy logs out every user. Same problem if you scale to two instances — half of requests land on the instance that never saw the login.
// Bad — default Express in-memory session store
import session from 'express-session';
app.use(session({ secret: process.env.SESSION_SECRET! })); // MemoryStore by default!
// Good — Redis-backed session
import { createClient } from 'redis';
import RedisStore from 'connect-redis';
const redis = createClient({ url: process.env.REDIS_URL });
await redis.connect();
app.use(session({
store: new RedisStore({ client: redis }),
secret: process.env.SESSION_SECRET!,
cookie: { httpOnly: true, secure: true, sameSite: 'lax', maxAge: 7 * 24 * 60 * 60 * 1000 },
}));Stateless alternatives:
auth-hardening for the tradeoffs.setTimeout is not a queue// Bad — process is killed on deploy, job never runs
app.post('/api/sign-up', async (req, res) => {
await db.users.create(req.body);
setTimeout(() => sendWelcomeEmail(req.body.email), 60_000); // ☠️
res.json({ ok: true });
});
// Good — persistent queue, retries, observability
import { Queue, Worker } from 'bullmq';
const emailQueue = new Queue('emails', { connection: { url: process.env.REDIS_URL }});
app.post('/api/sign-up', async (req, res) => {
await db.users.create(req.body);
await emailQueue.add('welcome', { email: req.body.email }, {
attempts: 5,
backoff: { type: 'exponential', delay: 1000 },
removeOnComplete: 1000,
});
res.json({ ok: true });
});
// Worker — separate process, can be on the same or different host
new Worker('emails', async (job) => {
await sendWelcomeEmail(job.data.email);
}, { connection: { url: process.env.REDIS_URL }});What you get:
For cron-style "every hour" jobs, the right place is also the queue (BullMQ has repeat, or use a dedicated cron worker), or a managed scheduler (AWS EventBridge, Cloudflare Workers Cron). Not a setInterval in the web process.
Three patterns that together make deploys boring:
A deploy is "ship this exact image / artifact to production." Not "pull, install, configure." The same artifact that ran in staging runs in prod. If something breaks in prod, rolling back means pointing at the previous artifact — no re-install, no risk of a different state.
myapp:1.2.3 or SHA-digest), not :latestTwo endpoints, two purposes:
// Liveness — is the process alive? Cheap, no dependencies.
app.get('/healthz', (req, res) => res.json({ ok: true }));
// Readiness — is the process actually ready to serve traffic?
app.get('/readyz', async (req, res) => {
try {
await Promise.all([
db.$queryRaw`SELECT 1`,
redis.ping(),
]);
res.json({ ok: true });
} catch (err) {
res.status(503).json({ ok: false, err: String(err) });
}
});The orchestrator (Docker, Kubernetes, your reverse proxy) routes traffic only when /readyz returns 200. Without this, new containers get traffic before they have warmed connections to the DB and produce 500s for the first 30 seconds of every deploy.
When the orchestrator sends SIGTERM, stop accepting new requests, finish in-flight ones, close connections, then exit. Most frameworks ship this; some make you wire it.
const server = app.listen(port);
const shutdown = async (signal: string) => {
console.log(`${signal} — draining`);
server.close(async () => {
await redis.quit();
await db.$disconnect();
process.exit(0);
});
// Hard cap — if drain takes too long, force exit
setTimeout(() => process.exit(1), 30_000).unref();
};
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));Combined: zero-downtime rolling deploys become trivial.
ADD COLUMN ... DEFAULT NULL (instant) over ADD COLUMN ... DEFAULT 'x' NOT NULL (rewrites the table). Use CREATE INDEX CONCURRENTLY for indexes on large tables.App processes open too many connections to Postgres by default. Postgres struggles past ~100 active connections. Use a pooler:
Set per-instance pool size = (maxConnections / numInstances) − headroom. For 4 instances and a 100-connection budget, that's ~20 connections per instance with 20 spare.
See postgres-hardening for the security side.
Every state-changing operation should produce the same effect whether it runs once or ten times. This is what makes retries, queues, and replays safe.
stripe-webhook-security.X-Idempotency-Key header), not on auto-incremented IDs.(template, recipient, trigger-id).A retry storm should be boring, not catastrophic.
For a small backend, "observability" is not a buzzword — it is three concrete things:
log-strategy. Cloud logging, Loki, hosted. Not "ssh in to read /var/log".request_id at the edge (Cloudflare adds one for free), thread it through every log line and every downstream service call. When something breaks, you can reconstruct the full path.Metrics (Prometheus / Grafana / Datadog) are great when traffic grows; not day-one critical.
Most vibe-coded projects do not need Kubernetes, microservices, multi-region, or a service mesh. The cost of premature complexity is real: more failure modes, more "why is this not working" hours, more bills.
Reasonable trajectory:
| Stage | Architecture |
|---|---|
| 0–1k users | Single VPS, app + Postgres + Redis colocated, daily off-host backups, Cloudflare in front |
| 1k–10k users | Same, plus object storage for files, separate worker process, managed Postgres or DB on a dedicated VM |
| 10k–100k users | Multiple app instances behind a load balancer, managed Postgres with read replica, queue is its own service, CDN is essential |
| 100k+ users | Dedicated DB nodes, autoscaling app tier, regional split if latency demands, real SRE practices |
Skip stages only if you have a specific reason. "We might need Kubernetes someday" is not a reason for today.
Most of this skill is a practical application of The Twelve-Factor App, an excellent guide written in 2011 that has aged remarkably well. The twelve factors:
Read it once. Then read it again every six months. Most "production incidents" in small-team backends are violations of one of these twelve.
Before declaring a backend production-ready, walk through:
setTimeout / setInterval/healthz (liveness) and /readyz (readiness) endpointsSIGTERM gracefully — drains connections, finishes in-flight workrequest_id threading.env is not in the image, not in the repoIf anything is "no", you have an outage waiting. Fix before scaling, not after.
~30 seconds. Free. No account. Every finding cites a rule and a line of evidence.