[{"data":1,"prerenderedAt":11574},["ShallowReactive",2],{"blog-paginated-count":3,"blog-paginated-4":4,"blog-paginated-cats":10928},640,[5,294,1331,2557,3432,4009,4311,5534,5792,6161,6474,6870,8171,9862,10612],{"id":6,"title":7,"author":8,"body":11,"category":274,"date":275,"description":276,"extension":277,"featured":278,"image":279,"keywords":280,"meta":283,"navigation":284,"path":285,"readTime":286,"seo":287,"stem":288,"tags":289,"__hash__":293},"blog/blog/code-review-best-practices.md","Code Review Best Practices: Making Reviews Worth Everyone's Time",{"name":9,"bio":10},"James Ross Jr.","Strategic Systems Architect & Enterprise Software Developer",{"type":12,"value":13,"toc":262},"minimark",[14,19,23,26,29,32,36,39,46,52,58,64,70,73,75,79,85,88,94,116,119,125,131,137,139,143,149,155,158,164,170,172,176,179,182,185,187,191,194,197,214,217,219,230,232,236],[15,16,18],"h2",{"id":17},"the-code-review-that-makes-people-dread-sending-prs","The Code Review That Makes People Dread Sending PRs",[20,21,22],"p",{},"I've worked in codebases where submitting a pull request felt like sending your work to a tribunal. Comments were harsh, nit-picks were prolific, the definition of \"done\" shifted with the reviewer's mood, and approval could take days of back-and-forth. People started avoiding reviews — submitting large PRs infrequently, self-merging minor changes, or just getting a teammate to rubber-stamp it without reading it.",[20,24,25],{},"I've also worked on teams where code review was genuinely useful — where reviews were fast, comments were constructive, and the back-and-forth made the final code noticeably better. The difference wasn't intelligence or experience. It was process and norms.",[20,27,28],{},"Good code review is a learnable practice. Here's what it looks like.",[30,31],"hr",{},[15,33,35],{"id":34},"what-code-review-is-actually-for","What Code Review Is Actually For",[20,37,38],{},"Before fixing the process, align on the purpose. Code review serves several functions, and teams that conflate them end up with confused review cultures:",[20,40,41,45],{},[42,43,44],"strong",{},"Correctness and logic validation."," Does the code actually do what it's supposed to do? Are there edge cases the author missed? Does the business logic match the requirements?",[20,47,48,51],{},[42,49,50],{},"Maintainability and readability."," Will the next developer who reads this code understand it? Is it unnecessarily complex? Are the variable names clear? Is the structure consistent with the rest of the codebase?",[20,53,54,57],{},[42,55,56],{},"Security review."," Are there injection vulnerabilities? Is user input properly validated? Are permissions being checked appropriately?",[20,59,60,63],{},[42,61,62],{},"Knowledge sharing."," Reviews are one of the main ways teams transfer knowledge about the codebase, the business domain, and engineering patterns. A thorough review comment teaches the author something they didn't know.",[20,65,66,69],{},[42,67,68],{},"Style and conventions."," Is this consistent with how the team writes code?",[20,71,72],{},"These are not equally important. Correctness and security issues must be addressed. Readability issues should be addressed if they're significant. Style should be handled by automated linting, not human reviewers.",[30,74],{},[15,76,78],{"id":77},"the-reviewers-responsibilities","The Reviewer's Responsibilities",[20,80,81,84],{},[42,82,83],{},"Review promptly."," The most common complaint about code review isn't the quality of feedback — it's the delay. A PR that sits unreviewed for three days creates a cascade of problems: merge conflicts accumulate, the author moves on to other work mentally, and the feedback arrives in a context where making changes feels disruptive.",[20,86,87],{},"Agree on a team norm for review turnaround. 24 hours is a reasonable target for most teams. For urgent changes, 4 hours.",[20,89,90,93],{},[42,91,92],{},"Distinguish comment severity."," Not all review comments are equal. A comment about a security vulnerability is different from a comment about variable naming. Train yourself to communicate the severity explicitly:",[95,96,97,101,104],"ul",{},[98,99,100],"li",{},"\"Must fix before merge: This allows SQL injection by interpolating user input directly.\"",[98,102,103],{},"\"Suggestion: Consider extracting this into a separate function for testability.\"",[98,105,106,107,111,112,115],{},"\"Nit: Naming — ",[108,109,110],"code",{},"userList"," could be ",[108,113,114],{},"users"," per our convention.\"",[20,117,118],{},"When everything is the same tone, authors can't tell which comments are blockers and which are optional. Label them.",[20,120,121,124],{},[42,122,123],{},"Ask questions rather than making declarations."," \"This will cause a race condition\" is a declaration that puts the author on the defensive. \"Could this cause a race condition if two requests hit this endpoint simultaneously? What happens to the counter if they both read before either writes?\" is a question that invites thought and might lead to the reviewer being wrong gracefully. One of these builds trust. The other builds resentment.",[20,126,127,130],{},[42,128,129],{},"Explain the why, not just the what."," \"Don't do it this way\" is unhelpful. \"I'd avoid this approach because it creates a circular dependency between these two modules — here's the pattern we use instead\" is a review comment that teaches something.",[20,132,133,136],{},[42,134,135],{},"Approve when the code is good enough."," Perfection is the enemy of shipping. If the code is correct, secure, and reasonably maintainable, approve it. Your personal preference for a different architecture or a different naming convention is not a blocking issue.",[30,138],{},[15,140,142],{"id":141},"the-authors-responsibilities","The Author's Responsibilities",[20,144,145,148],{},[42,146,147],{},"Keep PRs small."," The single biggest variable in review quality is PR size. A 50-line PR with a clear description gets thorough, fast review. A 1,200-line PR covering three features gets a rubber stamp or a three-day ordeal. Break large features into reviewable chunks. One logical change per PR.",[20,150,151,154],{},[42,152,153],{},"Write a meaningful description."," The PR description should answer: What does this change do? Why was this approach chosen? Is there anything the reviewer should pay special attention to? Are there known limitations or follow-up tasks?",[20,156,157],{},"\"Fix bug\" is not a description. \"Fix off-by-one error in pagination that caused the last item on each page to be skipped — added a test covering this case\" is a description.",[20,159,160,163],{},[42,161,162],{},"Test your own code before requesting review."," The reviewer's job is not to find your bugs. Run the tests. Manually verify the feature works. Check the obvious edge cases yourself. Review is for the things you couldn't find yourself.",[20,165,166,169],{},[42,167,168],{},"Respond to feedback constructively."," If you disagree with a comment, explain why in the PR thread. \"I see the point, but I went this direction because...\" is a legitimate response. Silently changing things to satisfy the reviewer while disagreeing shows disrespect for the process. Silent non-compliance on a blocking comment is worse.",[30,171],{},[15,173,175],{"id":174},"automating-the-low-value-work","Automating the Low-Value Work",[20,177,178],{},"Linters, formatters, and static analysis tools exist precisely so that humans don't have to spend their review attention on things machines can catch. If your team is still discussing spacing, quotation marks, trailing commas, import order, and unused variables in human reviews, you have an automation gap.",[20,180,181],{},"Set up ESLint (or the equivalent for your language), Prettier or similar formatters, and run them in CI. No PR merges if the automated checks fail. This removes an entire class of review friction at zero cost to human attention.",[20,183,184],{},"Similarly, type systems catch a category of errors that don't need to be a human review responsibility. TypeScript in strict mode, with no untyped escape hatches, removes whole categories of bugs from the review queue.",[30,186],{},[15,188,190],{"id":189},"review-culture-at-the-team-level","Review Culture at the Team Level",[20,192,193],{},"Code review norms don't emerge spontaneously. They need to be set explicitly, usually in a CONTRIBUTING.md or team handbook, and reinforced by the most senior engineers modeling the behavior they want to see.",[20,195,196],{},"The behaviors to explicitly establish:",[95,198,199,202,205,208,211],{},[98,200,201],{},"Review turnaround expectations (24 hours)",[98,203,204],{},"PR size expectations (under 400 lines as a default target)",[98,206,207],{},"Comment severity labeling (must fix / suggestion / nit)",[98,209,210],{},"The criteria for approval (correct, secure, maintainable — not perfect)",[98,212,213],{},"How to handle disagreements (thread discussion, escalate to tech lead if unresolved in 2 days)",[20,215,216],{},"Codifying these norms removes the ambiguity that causes most review friction. When everyone knows the rules, the rules aren't personal.",[30,218],{},[20,220,221,222,229],{},"Code review is one of the highest-leverage engineering investments a team can make — it catches bugs, spreads knowledge, and maintains quality without adding a separate QA cycle. If you're building or reforming an engineering team and want to think through how to structure your review practice, book a call at ",[223,224,228],"a",{"href":225,"rel":226},"https://calendly.com/jamesrossjr",[227],"nofollow","calendly.com/jamesrossjr",".",[30,231],{},[15,233,235],{"id":234},"keep-reading","Keep Reading",[95,237,238,244,250,256],{},[98,239,240],{},[223,241,243],{"href":242},"/blog/tailwind-css-nuxt-setup","Tailwind CSS with Nuxt: Setup, Configuration, and Best Practices",[98,245,246],{},[223,247,249],{"href":248},"/blog/agile-for-small-teams","Agile for Small Teams: What to Keep, What to Skip",[98,251,252],{},[223,253,255],{"href":254},"/blog/api-performance-optimization","API Performance Optimization: Making Your Endpoints Fast at Scale",[98,257,258],{},[223,259,261],{"href":260},"/blog/b2b-saas-development","B2B SaaS Development: What's Different About Building for Businesses",{"title":263,"searchDepth":264,"depth":264,"links":265},"",3,[266,268,269,270,271,272,273],{"id":17,"depth":267,"text":18},2,{"id":34,"depth":267,"text":35},{"id":77,"depth":267,"text":78},{"id":141,"depth":267,"text":142},{"id":174,"depth":267,"text":175},{"id":189,"depth":267,"text":190},{"id":234,"depth":267,"text":235},"Engineering","2026-03-03","Code reviews are one of the highest-leverage engineering practices when done well — and a source of friction and resentment when done poorly. Here's how to do them right.","md",false,null,[281,282],"code review best practices","engineering culture",{},true,"/blog/code-review-best-practices",7,{"title":7,"description":276},"blog/code-review-best-practices",[290,291,292],"Code Review","Engineering Culture","Software Quality","4gpKa0bor06rEVG_eswpOGlux-GacVv7MDssK_fulA8",{"id":295,"title":296,"author":297,"body":298,"category":1317,"date":275,"description":1318,"extension":277,"featured":278,"image":279,"keywords":1319,"meta":1322,"navigation":284,"path":1323,"readTime":286,"seo":1324,"stem":1325,"tags":1326,"__hash__":1330},"blog/blog/container-security-guide.md","Container Security: Hardening Docker for Production",{"name":9,"bio":10},{"type":12,"value":299,"toc":1304},[300,304,307,310,314,317,320,430,445,459,463,470,481,484,488,491,494,587,601,604,608,611,677,684,687,691,694,697,753,756,760,763,930,936,940,943,946,949,1032,1035,1039,1042,1045,1136,1139,1143,1146,1149,1153,1156,1259,1262,1264,1270,1272,1274,1300],[301,302,296],"h1",{"id":303},"container-security-hardening-docker-for-production",[20,305,306],{},"That something runs in a container does not make it secure. I have reviewed containerized applications running as root, with the Docker socket mounted inside the container, with secrets baked into the image layers, using base images that were two years out of date and full of known CVEs. Containerization is packaging technology — it provides isolation, not security guarantees, and the isolation is only as strong as the configuration you put around it.",[20,308,309],{},"Here is the hardening checklist I apply to every production container.",[15,311,313],{"id":312},"never-run-as-root","Never Run as Root",[20,315,316],{},"The single most impactful container security change you can make. By default, Docker containers run as root inside the container. If an attacker exploits a vulnerability in your application, they have root access inside the container. If there is any path out of the container — a misconfigured volume mount, a Docker socket, a kernel vulnerability — they have root on the host.",[20,318,319],{},"Create a non-root user in your Dockerfile:",[321,322,326],"pre",{"className":323,"code":324,"language":325,"meta":263,"style":263},"language-dockerfile shiki shiki-themes github-dark","FROM node:20-alpine\n\n# Create a non-root user and group\nRUN addgroup -g 1001 -S appgroup && \\\n adduser -u 1001 -S appuser -G appgroup\n\nWORKDIR /app\n\nCOPY --chown=appuser:appgroup package*.json ./\nRUN npm ci --only=production\n\nCOPY --chown=appuser:appgroup . .\n\n# Switch to non-root user before the final CMD\nUSER appuser\n\nEXPOSE 3000\nCMD [\"node\", \"src/index.js\"]\n","dockerfile",[108,327,328,336,341,346,352,358,363,368,373,379,385,390,396,401,407,413,418,424],{"__ignoreMap":263},[329,330,333],"span",{"class":331,"line":332},"line",1,[329,334,335],{},"FROM node:20-alpine\n",[329,337,338],{"class":331,"line":267},[329,339,340],{"emptyLinePlaceholder":284},"\n",[329,342,343],{"class":331,"line":264},[329,344,345],{},"# Create a non-root user and group\n",[329,347,349],{"class":331,"line":348},4,[329,350,351],{},"RUN addgroup -g 1001 -S appgroup && \\\n",[329,353,355],{"class":331,"line":354},5,[329,356,357],{}," adduser -u 1001 -S appuser -G appgroup\n",[329,359,361],{"class":331,"line":360},6,[329,362,340],{"emptyLinePlaceholder":284},[329,364,365],{"class":331,"line":286},[329,366,367],{},"WORKDIR /app\n",[329,369,371],{"class":331,"line":370},8,[329,372,340],{"emptyLinePlaceholder":284},[329,374,376],{"class":331,"line":375},9,[329,377,378],{},"COPY --chown=appuser:appgroup package*.json ./\n",[329,380,382],{"class":331,"line":381},10,[329,383,384],{},"RUN npm ci --only=production\n",[329,386,388],{"class":331,"line":387},11,[329,389,340],{"emptyLinePlaceholder":284},[329,391,393],{"class":331,"line":392},12,[329,394,395],{},"COPY --chown=appuser:appgroup . .\n",[329,397,399],{"class":331,"line":398},13,[329,400,340],{"emptyLinePlaceholder":284},[329,402,404],{"class":331,"line":403},14,[329,405,406],{},"# Switch to non-root user before the final CMD\n",[329,408,410],{"class":331,"line":409},15,[329,411,412],{},"USER appuser\n",[329,414,416],{"class":331,"line":415},16,[329,417,340],{"emptyLinePlaceholder":284},[329,419,421],{"class":331,"line":420},17,[329,422,423],{},"EXPOSE 3000\n",[329,425,427],{"class":331,"line":426},18,[329,428,429],{},"CMD [\"node\", \"src/index.js\"]\n",[20,431,432,433,436,437,440,441,444],{},"The ",[108,434,435],{},"--chown=appuser:appgroup"," flags on ",[108,438,439],{},"COPY"," instructions ensure the application files are owned by the non-root user. The ",[108,442,443],{},"USER appuser"," directive ensures the container process runs as that user, not root.",[20,446,447,448,451,452,455,456,229],{},"Verify this is working: ",[108,449,450],{},"docker exec my-container whoami"," should return ",[108,453,454],{},"appuser",", not ",[108,457,458],{},"root",[15,460,462],{"id":461},"use-minimal-base-images","Use Minimal Base Images",[20,464,465,466,469],{},"Every layer of your base image is a potential attack surface. The Debian-based ",[108,467,468],{},"node:20"," image contains curl, wget, apt, bash, and hundreds of other utilities you do not need in your application container. They exist for developer convenience, and they are available to an attacker who gains container access.",[20,471,472,473,476,477,480],{},"Use Alpine-based images (",[108,474,475],{},"node:20-alpine",") for most applications. Alpine's minimal package set means fewer attack vectors. For applications with native dependencies that do not compile on Alpine's musl libc, use ",[108,478,479],{},"node:20-slim"," instead — still smaller than the full Debian image.",[20,482,483],{},"Better still, use distroless images where your framework supports them. Google's distroless images contain only the application runtime — no shell, no package manager, no utilities. If an attacker gets code execution in a distroless container, they are working in an environment with almost no tools available.",[15,485,487],{"id":486},"scan-images-for-vulnerabilities","Scan Images for Vulnerabilities",[20,489,490],{},"Build-time scanning catches known CVEs in your base image and installed packages before the image reaches production.",[20,492,493],{},"Integrate Trivy into your CI pipeline:",[321,495,499],{"className":496,"code":497,"language":498,"meta":263,"style":263},"language-yaml shiki shiki-themes github-dark","- name: Scan image for vulnerabilities\n uses: aquasecurity/trivy-action@master\n with:\n image-ref: \"myapp:${{ github.sha }}\"\n format: \"table\"\n exit-code: \"1\"\n severity: \"CRITICAL,HIGH\"\n ignore-unfixed: true\n","yaml",[108,500,501,518,528,536,546,556,566,576],{"__ignoreMap":263},[329,502,503,507,511,514],{"class":331,"line":332},[329,504,506],{"class":505},"s95oV","- ",[329,508,510],{"class":509},"s4JwU","name",[329,512,513],{"class":505},": ",[329,515,517],{"class":516},"sU2Wk","Scan image for vulnerabilities\n",[329,519,520,523,525],{"class":331,"line":267},[329,521,522],{"class":509}," uses",[329,524,513],{"class":505},[329,526,527],{"class":516},"aquasecurity/trivy-action@master\n",[329,529,530,533],{"class":331,"line":264},[329,531,532],{"class":509}," with",[329,534,535],{"class":505},":\n",[329,537,538,541,543],{"class":331,"line":348},[329,539,540],{"class":509}," image-ref",[329,542,513],{"class":505},[329,544,545],{"class":516},"\"myapp:${{ github.sha }}\"\n",[329,547,548,551,553],{"class":331,"line":354},[329,549,550],{"class":509}," format",[329,552,513],{"class":505},[329,554,555],{"class":516},"\"table\"\n",[329,557,558,561,563],{"class":331,"line":360},[329,559,560],{"class":509}," exit-code",[329,562,513],{"class":505},[329,564,565],{"class":516},"\"1\"\n",[329,567,568,571,573],{"class":331,"line":286},[329,569,570],{"class":509}," severity",[329,572,513],{"class":505},[329,574,575],{"class":516},"\"CRITICAL,HIGH\"\n",[329,577,578,581,583],{"class":331,"line":370},[329,579,580],{"class":509}," ignore-unfixed",[329,582,513],{"class":505},[329,584,586],{"class":585},"sDLfK","true\n",[20,588,432,589,592,593,596,597,600],{},[108,590,591],{},"exit-code: \"1\""," with ",[108,594,595],{},"severity: \"CRITICAL,HIGH\""," fails the build when critical or high-severity unfixed vulnerabilities are found. ",[108,598,599],{},"ignore-unfixed: true"," prevents failures for vulnerabilities that do not yet have a patch available — you cannot fix what does not have a fix, but you should know about fixable issues.",[20,602,603],{},"Run image scans on a schedule against your production images, not just at build time. CVEs are discovered continuously. An image that was clean when built may have known vulnerabilities six months later. Daily scans catch this.",[15,605,607],{"id":606},"read-only-root-filesystem","Read-Only Root Filesystem",[20,609,610],{},"Mount your container's root filesystem as read-only. This prevents an attacker who achieves code execution from writing malicious files to the filesystem:",[321,612,614],{"className":496,"code":613,"language":498,"meta":263,"style":263},"# Docker Compose\nservices:\n api:\n image: myapp:latest\n read_only: true\n tmpfs:\n - /tmp\n - /var/run\n",[108,615,616,622,629,636,646,655,662,670],{"__ignoreMap":263},[329,617,618],{"class":331,"line":332},[329,619,621],{"class":620},"sAwPA","# Docker Compose\n",[329,623,624,627],{"class":331,"line":267},[329,625,626],{"class":509},"services",[329,628,535],{"class":505},[329,630,631,634],{"class":331,"line":264},[329,632,633],{"class":509}," api",[329,635,535],{"class":505},[329,637,638,641,643],{"class":331,"line":348},[329,639,640],{"class":509}," image",[329,642,513],{"class":505},[329,644,645],{"class":516},"myapp:latest\n",[329,647,648,651,653],{"class":331,"line":354},[329,649,650],{"class":509}," read_only",[329,652,513],{"class":505},[329,654,586],{"class":585},[329,656,657,660],{"class":331,"line":360},[329,658,659],{"class":509}," tmpfs",[329,661,535],{"class":505},[329,663,664,667],{"class":331,"line":286},[329,665,666],{"class":505}," - ",[329,668,669],{"class":516},"/tmp\n",[329,671,672,674],{"class":331,"line":370},[329,673,666],{"class":505},[329,675,676],{"class":516},"/var/run\n",[20,678,679,680,683],{},"Applications that need to write files — for example, applications that use ",[108,681,682],{},"/tmp"," for temporary files — need those specific directories mounted as writable tmpfs volumes. This is a much smaller attack surface than a fully writable filesystem.",[20,685,686],{},"If your application writes to disk as part of its operation (not just temp files), mount a dedicated volume for that purpose rather than making the entire filesystem writable.",[15,688,690],{"id":689},"restrict-capabilities","Restrict Capabilities",[20,692,693],{},"Linux capabilities are the mechanism by which root privileges are divided into discrete units. By default, Docker grants containers a set of capabilities that include more privileges than most applications need.",[20,695,696],{},"Drop all capabilities and add back only what you need:",[321,698,700],{"className":496,"code":699,"language":498,"meta":263,"style":263},"services:\n api:\n image: myapp:latest\n cap_drop:\n - ALL\n cap_add:\n - NET_BIND_SERVICE # Only if you need to bind to ports \u003C 1024\n",[108,701,702,708,714,722,729,736,743],{"__ignoreMap":263},[329,703,704,706],{"class":331,"line":332},[329,705,626],{"class":509},[329,707,535],{"class":505},[329,709,710,712],{"class":331,"line":267},[329,711,633],{"class":509},[329,713,535],{"class":505},[329,715,716,718,720],{"class":331,"line":264},[329,717,640],{"class":509},[329,719,513],{"class":505},[329,721,645],{"class":516},[329,723,724,727],{"class":331,"line":348},[329,725,726],{"class":509}," cap_drop",[329,728,535],{"class":505},[329,730,731,733],{"class":331,"line":354},[329,732,666],{"class":505},[329,734,735],{"class":516},"ALL\n",[329,737,738,741],{"class":331,"line":360},[329,739,740],{"class":509}," cap_add",[329,742,535],{"class":505},[329,744,745,747,750],{"class":331,"line":286},[329,746,666],{"class":505},[329,748,749],{"class":516},"NET_BIND_SERVICE",[329,751,752],{"class":620}," # Only if you need to bind to ports \u003C 1024\n",[20,754,755],{},"Most web applications need no capabilities at all if they run on ports above 1024. An API running on port 3000 as a non-root user needs zero Linux capabilities. Drop them all.",[15,757,759],{"id":758},"network-segmentation","Network Segmentation",[20,761,762],{},"Do not put all your containers on the same Docker network. An attacker who compromises your frontend container should not be able to reach your database container directly.",[321,764,766],{"className":496,"code":765,"language":498,"meta":263,"style":263},"services:\n frontend:\n image: frontend:latest\n networks:\n - public\n\n api:\n image: api:latest\n networks:\n - public\n - internal\n\n db:\n image: postgres:16-alpine\n networks:\n - internal\n\nNetworks:\n public:\n driver: bridge\n internal:\n driver: bridge\n internal: true\n",[108,767,768,774,781,790,797,804,808,814,823,829,835,842,846,853,862,868,874,878,885,893,904,912,921],{"__ignoreMap":263},[329,769,770,772],{"class":331,"line":332},[329,771,626],{"class":509},[329,773,535],{"class":505},[329,775,776,779],{"class":331,"line":267},[329,777,778],{"class":509}," frontend",[329,780,535],{"class":505},[329,782,783,785,787],{"class":331,"line":264},[329,784,640],{"class":509},[329,786,513],{"class":505},[329,788,789],{"class":516},"frontend:latest\n",[329,791,792,795],{"class":331,"line":348},[329,793,794],{"class":509}," networks",[329,796,535],{"class":505},[329,798,799,801],{"class":331,"line":354},[329,800,666],{"class":505},[329,802,803],{"class":516},"public\n",[329,805,806],{"class":331,"line":360},[329,807,340],{"emptyLinePlaceholder":284},[329,809,810,812],{"class":331,"line":286},[329,811,633],{"class":509},[329,813,535],{"class":505},[329,815,816,818,820],{"class":331,"line":370},[329,817,640],{"class":509},[329,819,513],{"class":505},[329,821,822],{"class":516},"api:latest\n",[329,824,825,827],{"class":331,"line":375},[329,826,794],{"class":509},[329,828,535],{"class":505},[329,830,831,833],{"class":331,"line":381},[329,832,666],{"class":505},[329,834,803],{"class":516},[329,836,837,839],{"class":331,"line":387},[329,838,666],{"class":505},[329,840,841],{"class":516},"internal\n",[329,843,844],{"class":331,"line":392},[329,845,340],{"emptyLinePlaceholder":284},[329,847,848,851],{"class":331,"line":398},[329,849,850],{"class":509}," db",[329,852,535],{"class":505},[329,854,855,857,859],{"class":331,"line":403},[329,856,640],{"class":509},[329,858,513],{"class":505},[329,860,861],{"class":516},"postgres:16-alpine\n",[329,863,864,866],{"class":331,"line":409},[329,865,794],{"class":509},[329,867,535],{"class":505},[329,869,870,872],{"class":331,"line":415},[329,871,666],{"class":505},[329,873,841],{"class":516},[329,875,876],{"class":331,"line":420},[329,877,340],{"emptyLinePlaceholder":284},[329,879,880,883],{"class":331,"line":426},[329,881,882],{"class":509},"Networks",[329,884,535],{"class":505},[329,886,888,891],{"class":331,"line":887},19,[329,889,890],{"class":509}," public",[329,892,535],{"class":505},[329,894,896,899,901],{"class":331,"line":895},20,[329,897,898],{"class":509}," driver",[329,900,513],{"class":505},[329,902,903],{"class":516},"bridge\n",[329,905,907,910],{"class":331,"line":906},21,[329,908,909],{"class":509}," internal",[329,911,535],{"class":505},[329,913,915,917,919],{"class":331,"line":914},22,[329,916,898],{"class":509},[329,918,513],{"class":505},[329,920,903],{"class":516},[329,922,924,926,928],{"class":331,"line":923},23,[329,925,909],{"class":509},[329,927,513],{"class":505},[329,929,586],{"class":585},[20,931,432,932,935],{},[108,933,934],{},"internal: true"," on the internal network prevents containers on that network from reaching the internet directly. Your database and internal services are isolated from outbound network access. The API bridges both networks, acting as the only path between the public-facing services and the database.",[15,937,939],{"id":938},"secrets-management-never-bake-into-images","Secrets Management: Never Bake Into Images",[20,941,942],{},"Secrets embedded in Docker images are a critical vulnerability. Images are stored in registries, passed between environments, shared among team members. Any secret in a Docker layer is accessible to anyone who can pull the image.",[20,944,945],{},"The rule is simple: no secrets in Dockerfiles, no secrets in environment variables baked into the image, no secrets in image labels.",[20,947,948],{},"Inject secrets at runtime. For Docker Compose:",[321,950,952],{"className":496,"code":951,"language":498,"meta":263,"style":263},"services:\n api:\n image: api:latest\n secrets:\n - db_password\n environment:\n DB_PASSWORD_FILE: /run/secrets/db_password\n\nSecrets:\n db_password:\n external: true\n",[108,953,954,960,966,974,981,988,995,1005,1009,1016,1023],{"__ignoreMap":263},[329,955,956,958],{"class":331,"line":332},[329,957,626],{"class":509},[329,959,535],{"class":505},[329,961,962,964],{"class":331,"line":267},[329,963,633],{"class":509},[329,965,535],{"class":505},[329,967,968,970,972],{"class":331,"line":264},[329,969,640],{"class":509},[329,971,513],{"class":505},[329,973,822],{"class":516},[329,975,976,979],{"class":331,"line":348},[329,977,978],{"class":509}," secrets",[329,980,535],{"class":505},[329,982,983,985],{"class":331,"line":354},[329,984,666],{"class":505},[329,986,987],{"class":516},"db_password\n",[329,989,990,993],{"class":331,"line":360},[329,991,992],{"class":509}," environment",[329,994,535],{"class":505},[329,996,997,1000,1002],{"class":331,"line":286},[329,998,999],{"class":509}," DB_PASSWORD_FILE",[329,1001,513],{"class":505},[329,1003,1004],{"class":516},"/run/secrets/db_password\n",[329,1006,1007],{"class":331,"line":370},[329,1008,340],{"emptyLinePlaceholder":284},[329,1010,1011,1014],{"class":331,"line":375},[329,1012,1013],{"class":509},"Secrets",[329,1015,535],{"class":505},[329,1017,1018,1021],{"class":331,"line":381},[329,1019,1020],{"class":509}," db_password",[329,1022,535],{"class":505},[329,1024,1025,1028,1030],{"class":331,"line":387},[329,1026,1027],{"class":509}," external",[329,1029,513],{"class":505},[329,1031,586],{"class":585},[20,1033,1034],{},"Docker Swarm and Kubernetes have native secrets mechanisms. For simpler deployments, tools like Doppler or HashiCorp Vault provide secret injection at container startup. At minimum, use environment variables set in your deployment platform's secret store — not in any file that touches your repository.",[15,1036,1038],{"id":1037},"limit-resource-usage","Limit Resource Usage",[20,1040,1041],{},"An unbounded container is a denial-of-service vector. If an attacker can exhaust a container's resources — through algorithmic complexity attacks, memory leaks, or deliberate resource consumption — they affect other services on the same host.",[20,1043,1044],{},"Set explicit resource limits:",[321,1046,1048],{"className":496,"code":1047,"language":498,"meta":263,"style":263},"services:\n api:\n image: api:latest\n deploy:\n resources:\n limits:\n cpus: \"1.0\"\n memory: 512M\n reservations:\n cpus: \"0.25\"\n memory: 128M\n",[108,1049,1050,1056,1062,1070,1077,1084,1091,1101,1111,1118,1127],{"__ignoreMap":263},[329,1051,1052,1054],{"class":331,"line":332},[329,1053,626],{"class":509},[329,1055,535],{"class":505},[329,1057,1058,1060],{"class":331,"line":267},[329,1059,633],{"class":509},[329,1061,535],{"class":505},[329,1063,1064,1066,1068],{"class":331,"line":264},[329,1065,640],{"class":509},[329,1067,513],{"class":505},[329,1069,822],{"class":516},[329,1071,1072,1075],{"class":331,"line":348},[329,1073,1074],{"class":509}," deploy",[329,1076,535],{"class":505},[329,1078,1079,1082],{"class":331,"line":354},[329,1080,1081],{"class":509}," resources",[329,1083,535],{"class":505},[329,1085,1086,1089],{"class":331,"line":360},[329,1087,1088],{"class":509}," limits",[329,1090,535],{"class":505},[329,1092,1093,1096,1098],{"class":331,"line":286},[329,1094,1095],{"class":509}," cpus",[329,1097,513],{"class":505},[329,1099,1100],{"class":516},"\"1.0\"\n",[329,1102,1103,1106,1108],{"class":331,"line":370},[329,1104,1105],{"class":509}," memory",[329,1107,513],{"class":505},[329,1109,1110],{"class":516},"512M\n",[329,1112,1113,1116],{"class":331,"line":375},[329,1114,1115],{"class":509}," reservations",[329,1117,535],{"class":505},[329,1119,1120,1122,1124],{"class":331,"line":381},[329,1121,1095],{"class":509},[329,1123,513],{"class":505},[329,1125,1126],{"class":516},"\"0.25\"\n",[329,1128,1129,1131,1133],{"class":331,"line":387},[329,1130,1105],{"class":509},[329,1132,513],{"class":505},[329,1134,1135],{"class":516},"128M\n",[20,1137,1138],{},"The limit prevents the container from consuming more than its allocation. The reservation guarantees it always has the reserved amount available. Size these based on your application's normal usage with headroom for traffic spikes.",[15,1140,1142],{"id":1141},"keep-images-updated","Keep Images Updated",[20,1144,1145],{},"Outdated base images are the source of most container CVEs. Build new images regularly — weekly at minimum — even when your application code has not changed. A fresh build picks up the latest Alpine or Debian package versions, which include security patches.",[20,1147,1148],{},"Automate this. A GitHub Actions workflow that runs on a weekly schedule, rebuilds your images, scans them, and pushes to your registry keeps your production images current without manual intervention.",[15,1150,1152],{"id":1151},"the-container-security-audit","The Container Security Audit",[20,1154,1155],{},"For existing deployments, audit your running containers with these commands:",[321,1157,1161],{"className":1158,"code":1159,"language":1160,"meta":263,"style":263},"language-bash shiki shiki-themes github-dark","# Find containers running as root\ndocker ps -q | xargs docker inspect --format='{{.Name}}: {{.Config.User}}'\n\n# Find containers with privileged mode enabled\ndocker ps -q | xargs docker inspect --format='{{.Name}}: {{.HostConfig.Privileged}}'\n\n# Find containers with the Docker socket mounted\ndocker ps -q | xargs docker inspect --format='{{.Name}}: {{.HostConfig.Binds}}'\n","bash",[108,1162,1163,1168,1199,1203,1208,1229,1233,1238],{"__ignoreMap":263},[329,1164,1165],{"class":331,"line":332},[329,1166,1167],{"class":620},"# Find containers running as root\n",[329,1169,1170,1174,1177,1180,1184,1187,1190,1193,1196],{"class":331,"line":267},[329,1171,1173],{"class":1172},"svObZ","docker",[329,1175,1176],{"class":516}," ps",[329,1178,1179],{"class":585}," -q",[329,1181,1183],{"class":1182},"snl16"," |",[329,1185,1186],{"class":1172}," xargs",[329,1188,1189],{"class":516}," docker",[329,1191,1192],{"class":516}," inspect",[329,1194,1195],{"class":585}," --format=",[329,1197,1198],{"class":516},"'{{.Name}}: {{.Config.User}}'\n",[329,1200,1201],{"class":331,"line":264},[329,1202,340],{"emptyLinePlaceholder":284},[329,1204,1205],{"class":331,"line":348},[329,1206,1207],{"class":620},"# Find containers with privileged mode enabled\n",[329,1209,1210,1212,1214,1216,1218,1220,1222,1224,1226],{"class":331,"line":354},[329,1211,1173],{"class":1172},[329,1213,1176],{"class":516},[329,1215,1179],{"class":585},[329,1217,1183],{"class":1182},[329,1219,1186],{"class":1172},[329,1221,1189],{"class":516},[329,1223,1192],{"class":516},[329,1225,1195],{"class":585},[329,1227,1228],{"class":516},"'{{.Name}}: {{.HostConfig.Privileged}}'\n",[329,1230,1231],{"class":331,"line":360},[329,1232,340],{"emptyLinePlaceholder":284},[329,1234,1235],{"class":331,"line":286},[329,1236,1237],{"class":620},"# Find containers with the Docker socket mounted\n",[329,1239,1240,1242,1244,1246,1248,1250,1252,1254,1256],{"class":331,"line":370},[329,1241,1173],{"class":1172},[329,1243,1176],{"class":516},[329,1245,1179],{"class":585},[329,1247,1183],{"class":1182},[329,1249,1186],{"class":1172},[329,1251,1189],{"class":516},[329,1253,1192],{"class":516},[329,1255,1195],{"class":585},[329,1257,1258],{"class":516},"'{{.Name}}: {{.HostConfig.Binds}}'\n",[20,1260,1261],{},"If you find containers running privileged or with the Docker socket mounted, treat that as a critical security finding requiring immediate remediation. A container with the Docker socket has effectively unlimited access to the host system.",[30,1263],{},[20,1265,1266,1267,229],{},"If you want a security review of your containerized infrastructure, I can help identify gaps and prioritize remediation. Book a session at ",[223,1268,225],{"href":225,"rel":1269},[227],[30,1271],{},[15,1273,235],{"id":234},[95,1275,1276,1282,1288,1294],{},[98,1277,1278],{},[223,1279,1281],{"href":1280},"/blog/docker-for-developers-guide","Docker for Developers: From Zero to Production Containers",[98,1283,1284],{},[223,1285,1287],{"href":1286},"/blog/server-security-hardening","Server Security Hardening: The Checklist I Run on Every New VPS",[98,1289,1290],{},[223,1291,1293],{"href":1292},"/blog/secrets-management-guide","Secrets Management: Keeping Credentials Out of Your Codebase",[98,1295,1296],{},[223,1297,1299],{"href":1298},"/blog/continuous-deployment-guide","Continuous Deployment: From Code Push to Production in Minutes",[1301,1302,1303],"style",{},"html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html pre.shiki code .s95oV, html code.shiki .s95oV{--shiki-default:#E1E4E8}html pre.shiki code .s4JwU, html code.shiki .s4JwU{--shiki-default:#85E89D}html pre.shiki code .sU2Wk, html code.shiki .sU2Wk{--shiki-default:#9ECBFF}html pre.shiki code .sDLfK, html code.shiki .sDLfK{--shiki-default:#79B8FF}html pre.shiki code .sAwPA, html code.shiki .sAwPA{--shiki-default:#6A737D}html pre.shiki code .svObZ, html code.shiki .svObZ{--shiki-default:#B392F0}html pre.shiki code .snl16, html code.shiki .snl16{--shiki-default:#F97583}",{"title":263,"searchDepth":264,"depth":264,"links":1305},[1306,1307,1308,1309,1310,1311,1312,1313,1314,1315,1316],{"id":312,"depth":267,"text":313},{"id":461,"depth":267,"text":462},{"id":486,"depth":267,"text":487},{"id":606,"depth":267,"text":607},{"id":689,"depth":267,"text":690},{"id":758,"depth":267,"text":759},{"id":938,"depth":267,"text":939},{"id":1037,"depth":267,"text":1038},{"id":1141,"depth":267,"text":1142},{"id":1151,"depth":267,"text":1152},{"id":234,"depth":267,"text":235},"DevOps","A practical guide to Docker container security — non-root users, image scanning, read-only filesystems, network policies, and secrets management in containers.",[1320,1321],"container security","Docker security",{},"/blog/container-security-guide",{"title":296,"description":1318},"blog/container-security-guide",[1327,1328,1317,1329],"Container Security","Docker","Security","Fpk2yeA9JmbrfH3MvQrEW9ezZfildQuYa9JJQzMoZ4o",{"id":1332,"title":1333,"author":1334,"body":1335,"category":1329,"date":275,"description":2545,"extension":277,"featured":278,"image":279,"keywords":2546,"meta":2549,"navigation":284,"path":2550,"readTime":286,"seo":2551,"stem":2552,"tags":2553,"__hash__":2556},"blog/blog/content-security-policy-guide.md","Content Security Policy: Stopping XSS at the Browser Level",{"name":9,"bio":10},{"type":12,"value":1336,"toc":2535},[1337,1340,1343,1346,1350,1357,1368,1371,1375,1385,1388,1633,1636,1702,1709,1712,1723,1727,1730,1770,1778,1781,1788,1792,1795,1807,1810,1816,1819,1825,1836,1840,1843,1853,1859,1862,1965,1971,1977,2008,2011,2015,2018,2025,2293,2299,2303,2306,2309,2312,2491,2494,2496,2502,2504,2506,2532],[301,1338,1333],{"id":1339},"content-security-policy-stopping-xss-at-the-browser-level",[20,1341,1342],{},"Content Security Policy is the closest thing we have to a silver bullet against XSS. It does not prevent XSS vulnerabilities from existing in your code — if you are injecting unsanitized HTML, you still have a vulnerability. But a properly configured CSP prevents that vulnerability from being exploited, because the browser refuses to execute the injected script.",[20,1344,1345],{},"The reason more applications do not implement CSP is that doing it correctly is not trivial. Inline scripts, CDN dependencies, analytics tools, chat widgets, and third-party embeds all require careful allowlisting. Getting the policy wrong breaks your application. This guide walks through the correct implementation approach.",[15,1347,1349],{"id":1348},"how-csp-actually-works","How CSP Actually Works",[20,1351,1352,1353,1356],{},"CSP is a response header your server sends. The browser reads it and enforces the rules on every resource request made by the page. When a resource violates the policy — a script loaded from an unlisted domain, an inline script when ",[108,1354,1355],{},"'unsafe-inline'"," is not permitted — the browser blocks it and (if configured) reports the violation to an endpoint.",[20,1358,1359,1360,1363,1364,1367],{},"The critical insight: an attacker who achieves XSS can inject a ",[108,1361,1362],{},"\u003Cscript>"," tag. Without CSP, the browser happily executes it. With CSP specifying ",[108,1365,1366],{},"script-src 'self'",", the browser sees the injected script, checks the policy, determines that the script is not from an allowed source, and refuses to execute it. The XSS vulnerability exists but cannot be exploited.",[20,1369,1370],{},"This does not mean CSP replaces proper output encoding and input validation. Defense in depth — CSP is an additional layer, not a substitute for secure coding.",[15,1372,1374],{"id":1373},"nonces-the-right-way-to-allow-inline-scripts","Nonces: The Right Way to Allow Inline Scripts",[20,1376,1377,1378,1380,1381,1384],{},"The biggest challenge with CSP is inline scripts. Many applications and third-party integrations use inline scripts. Adding ",[108,1379,1355],{}," to your ",[108,1382,1383],{},"script-src"," defeats much of CSP's value — it allows any inline script, including injected ones.",[20,1386,1387],{},"The solution is nonces. A nonce is a cryptographically random value generated per request and added to both the CSP header and any legitimate inline scripts on the page. The browser executes an inline script only if its nonce matches the policy.",[321,1389,1393],{"className":1390,"code":1391,"language":1392,"meta":263,"style":263},"language-typescript shiki shiki-themes github-dark","import { randomBytes } from \"crypto\";\n\nFunction generateNonce(): string {\n return randomBytes(16).toString(\"base64\");\n}\n\n// Middleware that adds a nonce to each request\napp.use((req, res, next) => {\n res.locals.cspNonce = generateNonce();\n\n res.setHeader(\n \"Content-Security-Policy\",\n [\n `default-src 'self'`,\n `script-src 'self' 'nonce-${res.locals.cspNonce}'`,\n `style-src 'self' 'unsafe-inline'`,\n `object-src 'none'`,\n `frame-ancestors 'none'`,\n ].join(\"; \")\n );\n\n next();\n});\n","typescript",[108,1394,1395,1412,1416,1427,1455,1460,1464,1469,1504,1518,1522,1533,1541,1546,1553,1575,1582,1589,1596,1612,1617,1621,1628],{"__ignoreMap":263},[329,1396,1397,1400,1403,1406,1409],{"class":331,"line":332},[329,1398,1399],{"class":1182},"import",[329,1401,1402],{"class":505}," { randomBytes } ",[329,1404,1405],{"class":1182},"from",[329,1407,1408],{"class":516}," \"crypto\"",[329,1410,1411],{"class":505},";\n",[329,1413,1414],{"class":331,"line":267},[329,1415,340],{"emptyLinePlaceholder":284},[329,1417,1418,1421,1424],{"class":331,"line":264},[329,1419,1420],{"class":505},"Function ",[329,1422,1423],{"class":1172},"generateNonce",[329,1425,1426],{"class":505},"(): string {\n",[329,1428,1429,1432,1435,1438,1441,1444,1447,1449,1452],{"class":331,"line":348},[329,1430,1431],{"class":1182}," return",[329,1433,1434],{"class":1172}," randomBytes",[329,1436,1437],{"class":505},"(",[329,1439,1440],{"class":585},"16",[329,1442,1443],{"class":505},").",[329,1445,1446],{"class":1172},"toString",[329,1448,1437],{"class":505},[329,1450,1451],{"class":516},"\"base64\"",[329,1453,1454],{"class":505},");\n",[329,1456,1457],{"class":331,"line":354},[329,1458,1459],{"class":505},"}\n",[329,1461,1462],{"class":331,"line":360},[329,1463,340],{"emptyLinePlaceholder":284},[329,1465,1466],{"class":331,"line":286},[329,1467,1468],{"class":620},"// Middleware that adds a nonce to each request\n",[329,1470,1471,1474,1477,1480,1484,1487,1490,1492,1495,1498,1501],{"class":331,"line":370},[329,1472,1473],{"class":505},"app.",[329,1475,1476],{"class":1172},"use",[329,1478,1479],{"class":505},"((",[329,1481,1483],{"class":1482},"s9osk","req",[329,1485,1486],{"class":505},", ",[329,1488,1489],{"class":1482},"res",[329,1491,1486],{"class":505},[329,1493,1494],{"class":1482},"next",[329,1496,1497],{"class":505},") ",[329,1499,1500],{"class":1182},"=>",[329,1502,1503],{"class":505}," {\n",[329,1505,1506,1509,1512,1515],{"class":331,"line":375},[329,1507,1508],{"class":505}," res.locals.cspNonce ",[329,1510,1511],{"class":1182},"=",[329,1513,1514],{"class":1172}," generateNonce",[329,1516,1517],{"class":505},"();\n",[329,1519,1520],{"class":331,"line":381},[329,1521,340],{"emptyLinePlaceholder":284},[329,1523,1524,1527,1530],{"class":331,"line":387},[329,1525,1526],{"class":505}," res.",[329,1528,1529],{"class":1172},"setHeader",[329,1531,1532],{"class":505},"(\n",[329,1534,1535,1538],{"class":331,"line":392},[329,1536,1537],{"class":516}," \"Content-Security-Policy\"",[329,1539,1540],{"class":505},",\n",[329,1542,1543],{"class":331,"line":398},[329,1544,1545],{"class":505}," [\n",[329,1547,1548,1551],{"class":331,"line":403},[329,1549,1550],{"class":516}," `default-src 'self'`",[329,1552,1540],{"class":505},[329,1554,1555,1558,1560,1562,1565,1567,1570,1573],{"class":331,"line":409},[329,1556,1557],{"class":516}," `script-src 'self' 'nonce-${",[329,1559,1489],{"class":505},[329,1561,229],{"class":516},[329,1563,1564],{"class":505},"locals",[329,1566,229],{"class":516},[329,1568,1569],{"class":505},"cspNonce",[329,1571,1572],{"class":516},"}'`",[329,1574,1540],{"class":505},[329,1576,1577,1580],{"class":331,"line":415},[329,1578,1579],{"class":516}," `style-src 'self' 'unsafe-inline'`",[329,1581,1540],{"class":505},[329,1583,1584,1587],{"class":331,"line":420},[329,1585,1586],{"class":516}," `object-src 'none'`",[329,1588,1540],{"class":505},[329,1590,1591,1594],{"class":331,"line":426},[329,1592,1593],{"class":516}," `frame-ancestors 'none'`",[329,1595,1540],{"class":505},[329,1597,1598,1601,1604,1606,1609],{"class":331,"line":887},[329,1599,1600],{"class":505}," ].",[329,1602,1603],{"class":1172},"join",[329,1605,1437],{"class":505},[329,1607,1608],{"class":516},"\"; \"",[329,1610,1611],{"class":505},")\n",[329,1613,1614],{"class":331,"line":895},[329,1615,1616],{"class":505}," );\n",[329,1618,1619],{"class":331,"line":906},[329,1620,340],{"emptyLinePlaceholder":284},[329,1622,1623,1626],{"class":331,"line":914},[329,1624,1625],{"class":1172}," next",[329,1627,1517],{"class":505},[329,1629,1630],{"class":331,"line":923},[329,1631,1632],{"class":505},"});\n",[20,1634,1635],{},"In your template:",[321,1637,1641],{"className":1638,"code":1639,"language":1640,"meta":263,"style":263},"language-html shiki shiki-themes github-dark","\u003C!-- The nonce attribute allows this specific script -->\n\u003Cscript nonce=\"\u003C%= cspNonce %>\">\n window.__INITIAL_STATE__ = \u003C%= JSON.stringify(initialState) %>;\n\u003C/script>\n","html",[108,1642,1643,1648,1667,1693],{"__ignoreMap":263},[329,1644,1645],{"class":331,"line":332},[329,1646,1647],{"class":620},"\u003C!-- The nonce attribute allows this specific script -->\n",[329,1649,1650,1653,1656,1659,1661,1664],{"class":331,"line":267},[329,1651,1652],{"class":505},"\u003C",[329,1654,1655],{"class":509},"script",[329,1657,1658],{"class":1172}," nonce",[329,1660,1511],{"class":505},[329,1662,1663],{"class":516},"\"\u003C%= cspNonce %>\"",[329,1665,1666],{"class":505},">\n",[329,1668,1669,1672,1674,1677,1680,1682,1685,1688,1691],{"class":331,"line":264},[329,1670,1671],{"class":505}," window.__INITIAL_STATE__ ",[329,1673,1511],{"class":1182},[329,1675,1676],{"class":1182}," \u003C%=",[329,1678,1679],{"class":585}," JSON",[329,1681,229],{"class":505},[329,1683,1684],{"class":1172},"stringify",[329,1686,1687],{"class":505},"(initialState) ",[329,1689,1690],{"class":1182},"%>",[329,1692,1411],{"class":505},[329,1694,1695,1698,1700],{"class":331,"line":348},[329,1696,1697],{"class":505},"\u003C/",[329,1699,1655],{"class":509},[329,1701,1666],{"class":505},[20,1703,1704,1705,1708],{},"An attacker who injects ",[108,1706,1707],{},"\u003Cscript>alert(1)\u003C/script>"," does not have the nonce, so the browser blocks it. Your legitimate inline script has the correct nonce and executes. This is the correct approach for applications that need inline scripts.",[20,1710,1711],{},"The nonce must be:",[95,1713,1714,1717,1720],{},[98,1715,1716],{},"Cryptographically random (at least 128 bits)",[98,1718,1719],{},"Different for every response (not reused between requests)",[98,1721,1722],{},"Never derived from anything an attacker can predict or influence",[15,1724,1726],{"id":1725},"hash-based-csp-for-static-inline-scripts","Hash-Based CSP for Static Inline Scripts",[20,1728,1729],{},"If you have inline scripts that are static — the same content every time — you can use a hash instead of a nonce. Compute the SHA-256 hash of the script content and add it to the policy:",[321,1731,1733],{"className":1158,"code":1732,"language":1160,"meta":263,"style":263},"echo -n \"console.log('hello');\" | openssl dgst -sha256 -binary | base64\n# Output: abc123def456... (your hash)\n",[108,1734,1735,1765],{"__ignoreMap":263},[329,1736,1737,1740,1743,1746,1748,1751,1754,1757,1760,1762],{"class":331,"line":332},[329,1738,1739],{"class":585},"echo",[329,1741,1742],{"class":585}," -n",[329,1744,1745],{"class":516}," \"console.log('hello');\"",[329,1747,1183],{"class":1182},[329,1749,1750],{"class":1172}," openssl",[329,1752,1753],{"class":516}," dgst",[329,1755,1756],{"class":585}," -sha256",[329,1758,1759],{"class":585}," -binary",[329,1761,1183],{"class":1182},[329,1763,1764],{"class":1172}," base64\n",[329,1766,1767],{"class":331,"line":267},[329,1768,1769],{"class":620},"# Output: abc123def456... (your hash)\n",[321,1771,1776],{"className":1772,"code":1774,"language":1775},[1773],"language-text","Content-Security-Policy: script-src 'self' 'sha256-abc123def456...'\n","text",[108,1777,1774],{"__ignoreMap":263},[20,1779,1780],{},"The browser executes inline scripts whose content hashes match the allowlisted hashes. A modified script (which an attacker's injected content would be) has a different hash and is blocked.",[20,1782,1783,1784,1787],{},"This works for scripts that never change. For scripts that include dynamic content (like ",[108,1785,1786],{},"window.__INITIAL_STATE__"," above), use nonces.",[15,1789,1791],{"id":1790},"handling-third-party-dependencies","Handling Third-Party Dependencies",[20,1793,1794],{},"Analytics tools, chat widgets, A/B testing platforms, payment processors — they all require adding to your CSP. The correct approach:",[1796,1797,1798,1801,1804],"ol",{},[98,1799,1800],{},"Check the vendor's documentation for their CSP requirements. Most major vendors document this.",[98,1802,1803],{},"Add only the domains they actually load resources from, not wildcards.",[98,1805,1806],{},"Test in a staging environment with the policy in report-only mode first.",[20,1808,1809],{},"For Google Analytics 4:",[321,1811,1814],{"className":1812,"code":1813,"language":1775},[1773],"script-src 'self' https://www.googletagmanager.com https://www.google-analytics.com;\nimg-src 'self' https://www.google-analytics.com;\nconnect-src 'self' https://analytics.google.com https://www.google-analytics.com;\n",[108,1815,1813],{"__ignoreMap":263},[20,1817,1818],{},"For Stripe.js:",[321,1820,1823],{"className":1821,"code":1822,"language":1775},[1773],"script-src 'self' https://js.stripe.com;\nframe-src https://js.stripe.com https://hooks.stripe.com;\nconnect-src 'self' https://api.stripe.com;\n",[108,1824,1822],{"__ignoreMap":263},[20,1826,1827,1828,1831,1832,1835],{},"Avoid ",[108,1829,1830],{},"*"," wildcards in your CSP domains. ",[108,1833,1834],{},"https://*.example.com"," allows any subdomain of example.com — if any of those subdomains is compromised or user-controlled, it breaks your CSP protection.",[15,1837,1839],{"id":1838},"the-csp-migration-strategy","The CSP Migration Strategy",[20,1841,1842],{},"Deploying CSP on an existing application without breaking it requires a phased approach.",[20,1844,1845,1848,1849,1852],{},[42,1846,1847],{},"Phase 1: Audit mode."," Deploy with ",[108,1850,1851],{},"Content-Security-Policy-Report-Only"," pointing to a reporting endpoint. Do not block anything yet.",[321,1854,1857],{"className":1855,"code":1856,"language":1775},[1773],"Content-Security-Policy-Report-Only: default-src 'self'; report-uri /api/csp-violations\n",[108,1858,1856],{"__ignoreMap":263},[20,1860,1861],{},"Implement the reporting endpoint to log violations:",[321,1863,1865],{"className":1390,"code":1864,"language":1392,"meta":263,"style":263},"app.post(\"/api/csp-violations\", express.json({ type: \"application/csp-report\" }), (req, res) => {\n const report = req.body[\"csp-report\"];\n logger.warn({ cspViolation: report }, \"CSP violation detected\");\n res.status(204).end();\n});\n",[108,1866,1867,1906,1926,1942,1961],{"__ignoreMap":263},[329,1868,1869,1871,1874,1876,1879,1882,1885,1888,1891,1894,1896,1898,1900,1902,1904],{"class":331,"line":332},[329,1870,1473],{"class":505},[329,1872,1873],{"class":1172},"post",[329,1875,1437],{"class":505},[329,1877,1878],{"class":516},"\"/api/csp-violations\"",[329,1880,1881],{"class":505},", express.",[329,1883,1884],{"class":1172},"json",[329,1886,1887],{"class":505},"({ type: ",[329,1889,1890],{"class":516},"\"application/csp-report\"",[329,1892,1893],{"class":505}," }), (",[329,1895,1483],{"class":1482},[329,1897,1486],{"class":505},[329,1899,1489],{"class":1482},[329,1901,1497],{"class":505},[329,1903,1500],{"class":1182},[329,1905,1503],{"class":505},[329,1907,1908,1911,1914,1917,1920,1923],{"class":331,"line":267},[329,1909,1910],{"class":1182}," const",[329,1912,1913],{"class":585}," report",[329,1915,1916],{"class":1182}," =",[329,1918,1919],{"class":505}," req.body[",[329,1921,1922],{"class":516},"\"csp-report\"",[329,1924,1925],{"class":505},"];\n",[329,1927,1928,1931,1934,1937,1940],{"class":331,"line":264},[329,1929,1930],{"class":505}," logger.",[329,1932,1933],{"class":1172},"warn",[329,1935,1936],{"class":505},"({ cspViolation: report }, ",[329,1938,1939],{"class":516},"\"CSP violation detected\"",[329,1941,1454],{"class":505},[329,1943,1944,1946,1949,1951,1954,1956,1959],{"class":331,"line":348},[329,1945,1526],{"class":505},[329,1947,1948],{"class":1172},"status",[329,1950,1437],{"class":505},[329,1952,1953],{"class":585},"204",[329,1955,1443],{"class":505},[329,1957,1958],{"class":1172},"end",[329,1960,1517],{"class":505},[329,1962,1963],{"class":331,"line":354},[329,1964,1632],{"class":505},[20,1966,1967,1970],{},[42,1968,1969],{},"Phase 2: Build your policy from violations."," Run in report-only mode for one to two weeks in production. Every violation is a resource your policy needs to allow. Review the violations, categorize them as legitimate (add to policy) or suspicious (investigate), and build your allowlist.",[20,1972,1973,1976],{},[42,1974,1975],{},"Phase 3: Progressive enforcement."," Start with a permissive policy that does not break anything and tighten it progressively:",[1796,1978,1979,1986,1993,1999],{},[98,1980,1981,1982,1985],{},"Deploy with ",[108,1983,1984],{},"default-src 'self' 'unsafe-inline' 'unsafe-eval' https:"," — very permissive, probably does not break anything",[98,1987,1988,1989,1992],{},"Remove ",[108,1990,1991],{},"https:"," and replace with specific domains as you identify what is needed",[98,1994,1995,1996,1998],{},"Replace ",[108,1997,1355],{}," with nonces for scripts once you have identified all inline scripts",[98,2000,1988,2001,2004,2005],{},[108,2002,2003],{},"'unsafe-eval'"," once you have confirmed nothing uses ",[108,2006,2007],{},"eval()",[20,2009,2010],{},"This migration can take weeks or months for complex applications. The reward is a policy that genuinely protects against XSS exploitation.",[15,2012,2014],{"id":2013},"csp-in-nextjs-and-nuxtjs","CSP in Next.js and Nuxt.js",[20,2016,2017],{},"Both frameworks require specific handling for their runtime JavaScript.",[20,2019,2020,2021,2024],{},"For Next.js, configure CSP in ",[108,2022,2023],{},"next.config.js"," with nonce support through middleware:",[321,2026,2028],{"className":1390,"code":2027,"language":1392,"meta":263,"style":263},"// middleware.ts\nimport { NextResponse } from \"next/server\";\nimport type { NextRequest } from \"next/server\";\nimport { randomBytes } from \"crypto\";\n\nExport function middleware(request: NextRequest) {\n const nonce = Buffer.from(randomBytes(16)).toString(\"base64\");\n\n const csp = [\n `default-src 'self'`,\n `script-src 'self' 'nonce-${nonce}' 'strict-dynamic'`,\n `style-src 'self' 'unsafe-inline'`,\n `object-src 'none'`,\n `frame-ancestors 'none'`,\n ].join(\"; \");\n\n const response = NextResponse.next({\n request: { headers: new Headers(request.headers) },\n });\n\n response.headers.set(\"Content-Security-Policy\", csp);\n response.headers.set(\"x-nonce\", nonce);\n\n return response;\n}\n",[108,2029,2030,2035,2049,2065,2077,2081,2106,2139,2143,2154,2160,2172,2178,2184,2190,2202,2206,2223,2237,2242,2246,2262,2276,2280,2288],{"__ignoreMap":263},[329,2031,2032],{"class":331,"line":332},[329,2033,2034],{"class":620},"// middleware.ts\n",[329,2036,2037,2039,2042,2044,2047],{"class":331,"line":267},[329,2038,1399],{"class":1182},[329,2040,2041],{"class":505}," { NextResponse } ",[329,2043,1405],{"class":1182},[329,2045,2046],{"class":516}," \"next/server\"",[329,2048,1411],{"class":505},[329,2050,2051,2053,2056,2059,2061,2063],{"class":331,"line":264},[329,2052,1399],{"class":1182},[329,2054,2055],{"class":1182}," type",[329,2057,2058],{"class":505}," { NextRequest } ",[329,2060,1405],{"class":1182},[329,2062,2046],{"class":516},[329,2064,1411],{"class":505},[329,2066,2067,2069,2071,2073,2075],{"class":331,"line":348},[329,2068,1399],{"class":1182},[329,2070,1402],{"class":505},[329,2072,1405],{"class":1182},[329,2074,1408],{"class":516},[329,2076,1411],{"class":505},[329,2078,2079],{"class":331,"line":354},[329,2080,340],{"emptyLinePlaceholder":284},[329,2082,2083,2086,2089,2092,2094,2097,2100,2103],{"class":331,"line":360},[329,2084,2085],{"class":505},"Export ",[329,2087,2088],{"class":1182},"function",[329,2090,2091],{"class":1172}," middleware",[329,2093,1437],{"class":505},[329,2095,2096],{"class":1482},"request",[329,2098,2099],{"class":1182},":",[329,2101,2102],{"class":1172}," NextRequest",[329,2104,2105],{"class":505},") {\n",[329,2107,2108,2110,2112,2114,2117,2119,2121,2124,2126,2128,2131,2133,2135,2137],{"class":331,"line":286},[329,2109,1910],{"class":1182},[329,2111,1658],{"class":585},[329,2113,1916],{"class":1182},[329,2115,2116],{"class":505}," Buffer.",[329,2118,1405],{"class":1172},[329,2120,1437],{"class":505},[329,2122,2123],{"class":1172},"randomBytes",[329,2125,1437],{"class":505},[329,2127,1440],{"class":585},[329,2129,2130],{"class":505},")).",[329,2132,1446],{"class":1172},[329,2134,1437],{"class":505},[329,2136,1451],{"class":516},[329,2138,1454],{"class":505},[329,2140,2141],{"class":331,"line":370},[329,2142,340],{"emptyLinePlaceholder":284},[329,2144,2145,2147,2150,2152],{"class":331,"line":375},[329,2146,1910],{"class":1182},[329,2148,2149],{"class":585}," csp",[329,2151,1916],{"class":1182},[329,2153,1545],{"class":505},[329,2155,2156,2158],{"class":331,"line":381},[329,2157,1550],{"class":516},[329,2159,1540],{"class":505},[329,2161,2162,2164,2167,2170],{"class":331,"line":387},[329,2163,1557],{"class":516},[329,2165,2166],{"class":505},"nonce",[329,2168,2169],{"class":516},"}' 'strict-dynamic'`",[329,2171,1540],{"class":505},[329,2173,2174,2176],{"class":331,"line":392},[329,2175,1579],{"class":516},[329,2177,1540],{"class":505},[329,2179,2180,2182],{"class":331,"line":398},[329,2181,1586],{"class":516},[329,2183,1540],{"class":505},[329,2185,2186,2188],{"class":331,"line":403},[329,2187,1593],{"class":516},[329,2189,1540],{"class":505},[329,2191,2192,2194,2196,2198,2200],{"class":331,"line":409},[329,2193,1600],{"class":505},[329,2195,1603],{"class":1172},[329,2197,1437],{"class":505},[329,2199,1608],{"class":516},[329,2201,1454],{"class":505},[329,2203,2204],{"class":331,"line":415},[329,2205,340],{"emptyLinePlaceholder":284},[329,2207,2208,2210,2213,2215,2218,2220],{"class":331,"line":420},[329,2209,1910],{"class":1182},[329,2211,2212],{"class":585}," response",[329,2214,1916],{"class":1182},[329,2216,2217],{"class":505}," NextResponse.",[329,2219,1494],{"class":1172},[329,2221,2222],{"class":505},"({\n",[329,2224,2225,2228,2231,2234],{"class":331,"line":426},[329,2226,2227],{"class":505}," request: { headers: ",[329,2229,2230],{"class":1182},"new",[329,2232,2233],{"class":1172}," Headers",[329,2235,2236],{"class":505},"(request.headers) },\n",[329,2238,2239],{"class":331,"line":887},[329,2240,2241],{"class":505}," });\n",[329,2243,2244],{"class":331,"line":895},[329,2245,340],{"emptyLinePlaceholder":284},[329,2247,2248,2251,2254,2256,2259],{"class":331,"line":906},[329,2249,2250],{"class":505}," response.headers.",[329,2252,2253],{"class":1172},"set",[329,2255,1437],{"class":505},[329,2257,2258],{"class":516},"\"Content-Security-Policy\"",[329,2260,2261],{"class":505},", csp);\n",[329,2263,2264,2266,2268,2270,2273],{"class":331,"line":914},[329,2265,2250],{"class":505},[329,2267,2253],{"class":1172},[329,2269,1437],{"class":505},[329,2271,2272],{"class":516},"\"x-nonce\"",[329,2274,2275],{"class":505},", nonce);\n",[329,2277,2278],{"class":331,"line":923},[329,2279,340],{"emptyLinePlaceholder":284},[329,2281,2283,2285],{"class":331,"line":2282},24,[329,2284,1431],{"class":1182},[329,2286,2287],{"class":505}," response;\n",[329,2289,2291],{"class":331,"line":2290},25,[329,2292,1459],{"class":505},[20,2294,432,2295,2298],{},[108,2296,2297],{},"'strict-dynamic'"," directive is important for modern frameworks — it allows scripts loaded by a trusted (nonce-verified) script to run, even if they are not explicitly allowlisted. This handles the dynamic script loading that bundlers use.",[15,2300,2302],{"id":2301},"monitoring-csp-violations-in-production","Monitoring CSP Violations in Production",[20,2304,2305],{},"CSP violations in production fall into three categories: legitimate resources your policy does not cover (fix by updating policy), browser extensions injecting content (expected, do not block), and actual attack attempts.",[20,2307,2308],{},"Tools that aggregate CSP reports and help you distinguish signal from noise: Sentry (has built-in CSP violation reporting), Report URI (purpose-built for CSP reporting), and a simple custom endpoint feeding into your logging stack.",[20,2310,2311],{},"Set up an alert for CSP violations that match patterns suggesting actual attacks rather than browser extensions:",[321,2313,2315],{"className":1390,"code":2314,"language":1392,"meta":263,"style":263},"app.post(\"/api/csp-violations\", (req, res) => {\n const report = req.body[\"csp-report\"];\n\n // Filter likely browser extension injections\n const isBrowserExtension = report[\"source-file\"]?.startsWith(\"chrome-extension:\");\n const isMozExtension = report[\"source-file\"]?.startsWith(\"moz-extension:\");\n\n if (!isBrowserExtension && !isMozExtension) {\n logger.warn({ cspViolation: report }, \"CSP violation - potential attack\");\n // Alert if blocked-uri looks like XSS payload\n }\n\n res.status(204).end();\n});\n",[108,2316,2317,2342,2356,2360,2365,2393,2417,2421,2444,2457,2462,2467,2471,2487],{"__ignoreMap":263},[329,2318,2319,2321,2323,2325,2327,2330,2332,2334,2336,2338,2340],{"class":331,"line":332},[329,2320,1473],{"class":505},[329,2322,1873],{"class":1172},[329,2324,1437],{"class":505},[329,2326,1878],{"class":516},[329,2328,2329],{"class":505},", (",[329,2331,1483],{"class":1482},[329,2333,1486],{"class":505},[329,2335,1489],{"class":1482},[329,2337,1497],{"class":505},[329,2339,1500],{"class":1182},[329,2341,1503],{"class":505},[329,2343,2344,2346,2348,2350,2352,2354],{"class":331,"line":267},[329,2345,1910],{"class":1182},[329,2347,1913],{"class":585},[329,2349,1916],{"class":1182},[329,2351,1919],{"class":505},[329,2353,1922],{"class":516},[329,2355,1925],{"class":505},[329,2357,2358],{"class":331,"line":264},[329,2359,340],{"emptyLinePlaceholder":284},[329,2361,2362],{"class":331,"line":348},[329,2363,2364],{"class":620}," // Filter likely browser extension injections\n",[329,2366,2367,2369,2372,2374,2377,2380,2383,2386,2388,2391],{"class":331,"line":354},[329,2368,1910],{"class":1182},[329,2370,2371],{"class":585}," isBrowserExtension",[329,2373,1916],{"class":1182},[329,2375,2376],{"class":505}," report[",[329,2378,2379],{"class":516},"\"source-file\"",[329,2381,2382],{"class":505},"]?.",[329,2384,2385],{"class":1172},"startsWith",[329,2387,1437],{"class":505},[329,2389,2390],{"class":516},"\"chrome-extension:\"",[329,2392,1454],{"class":505},[329,2394,2395,2397,2400,2402,2404,2406,2408,2410,2412,2415],{"class":331,"line":360},[329,2396,1910],{"class":1182},[329,2398,2399],{"class":585}," isMozExtension",[329,2401,1916],{"class":1182},[329,2403,2376],{"class":505},[329,2405,2379],{"class":516},[329,2407,2382],{"class":505},[329,2409,2385],{"class":1172},[329,2411,1437],{"class":505},[329,2413,2414],{"class":516},"\"moz-extension:\"",[329,2416,1454],{"class":505},[329,2418,2419],{"class":331,"line":286},[329,2420,340],{"emptyLinePlaceholder":284},[329,2422,2423,2426,2429,2432,2435,2438,2441],{"class":331,"line":370},[329,2424,2425],{"class":1182}," if",[329,2427,2428],{"class":505}," (",[329,2430,2431],{"class":1182},"!",[329,2433,2434],{"class":505},"isBrowserExtension ",[329,2436,2437],{"class":1182},"&&",[329,2439,2440],{"class":1182}," !",[329,2442,2443],{"class":505},"isMozExtension) {\n",[329,2445,2446,2448,2450,2452,2455],{"class":331,"line":375},[329,2447,1930],{"class":505},[329,2449,1933],{"class":1172},[329,2451,1936],{"class":505},[329,2453,2454],{"class":516},"\"CSP violation - potential attack\"",[329,2456,1454],{"class":505},[329,2458,2459],{"class":331,"line":381},[329,2460,2461],{"class":620}," // Alert if blocked-uri looks like XSS payload\n",[329,2463,2464],{"class":331,"line":387},[329,2465,2466],{"class":505}," }\n",[329,2468,2469],{"class":331,"line":392},[329,2470,340],{"emptyLinePlaceholder":284},[329,2472,2473,2475,2477,2479,2481,2483,2485],{"class":331,"line":398},[329,2474,1526],{"class":505},[329,2476,1948],{"class":1172},[329,2478,1437],{"class":505},[329,2480,1953],{"class":585},[329,2482,1443],{"class":505},[329,2484,1958],{"class":1172},[329,2486,1517],{"class":505},[329,2488,2489],{"class":331,"line":403},[329,2490,1632],{"class":505},[20,2492,2493],{},"CSP violations from browser extensions are normal and expected — extensions inject scripts into pages routinely. Filter them out before alerting or your monitoring is noise.",[30,2495],{},[20,2497,2498,2499,229],{},"If you need help implementing CSP for an existing application or want a review of your current policy, book a session at ",[223,2500,225],{"href":225,"rel":2501},[227],[30,2503],{},[15,2505,235],{"id":234},[95,2507,2508,2514,2520,2526],{},[98,2509,2510],{},[223,2511,2513],{"href":2512},"/blog/api-security-best-practices","API Security Best Practices: Protecting Your Endpoints in Production",[98,2515,2516],{},[223,2517,2519],{"href":2518},"/blog/authentication-security-guide","Authentication Security: What to Get Right Before Your First User Logs In",[98,2521,2522],{},[223,2523,2525],{"href":2524},"/blog/csrf-protection-guide","CSRF Protection: Understanding Cross-Site Request Forgery and Stopping It",[98,2527,2528],{},[223,2529,2531],{"href":2530},"/blog/data-encryption-guide","Data Encryption in Applications: At Rest, In Transit, and In Memory",[1301,2533,2534],{},"html pre.shiki code .snl16, html code.shiki .snl16{--shiki-default:#F97583}html pre.shiki code .s95oV, html code.shiki .s95oV{--shiki-default:#E1E4E8}html pre.shiki code .sU2Wk, html code.shiki .sU2Wk{--shiki-default:#9ECBFF}html pre.shiki code .svObZ, html code.shiki .svObZ{--shiki-default:#B392F0}html pre.shiki code .sDLfK, html code.shiki .sDLfK{--shiki-default:#79B8FF}html pre.shiki code .sAwPA, html code.shiki .sAwPA{--shiki-default:#6A737D}html pre.shiki code .s9osk, html code.shiki .s9osk{--shiki-default:#FFAB70}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html pre.shiki code .s4JwU, html code.shiki .s4JwU{--shiki-default:#85E89D}",{"title":263,"searchDepth":264,"depth":264,"links":2536},[2537,2538,2539,2540,2541,2542,2543,2544],{"id":1348,"depth":267,"text":1349},{"id":1373,"depth":267,"text":1374},{"id":1725,"depth":267,"text":1726},{"id":1790,"depth":267,"text":1791},{"id":1838,"depth":267,"text":1839},{"id":2013,"depth":267,"text":2014},{"id":2301,"depth":267,"text":2302},{"id":234,"depth":267,"text":235},"A deep dive into Content Security Policy implementation — building a strict CSP for modern JavaScript applications, handling violations, and migrating legacy apps without breaking them.",[2547,2548],"Content Security Policy","CSP header",{},"/blog/content-security-policy-guide",{"title":1333,"description":2545},"blog/content-security-policy-guide",[2547,2554,2555,1329],"CSP","XSS Prevention","vcf1t3U3voB_xNq1eAcZ4cTf4-wJQkgTp911zVrf3wg",{"id":2558,"title":1299,"author":2559,"body":2560,"category":1317,"date":275,"description":3420,"extension":277,"featured":278,"image":279,"keywords":3421,"meta":3424,"navigation":284,"path":1298,"readTime":286,"seo":3425,"stem":3426,"tags":3427,"__hash__":3431},"blog/blog/continuous-deployment-guide.md",{"name":9,"bio":10},{"type":12,"value":2561,"toc":3408},[2562,2565,2568,2571,2574,2578,2581,2598,2601,2605,2608,2614,2620,2626,2629,2633,2636,2687,2694,2697,2701,2704,2707,2887,2893,2897,2900,2954,2964,2971,2975,2978,2981,3221,3224,3228,3231,3329,3336,3340,3343,3346,3349,3353,3356,3362,3368,3371,3373,3379,3381,3383,3405],[301,2563,1299],{"id":2564},"continuous-deployment-from-code-push-to-production-in-minutes",[20,2566,2567],{},"Continuous deployment is the practice of automatically deploying every change that passes your quality gate directly to production, without human approval for each individual deployment. This sounds risky if you are used to scheduled, manually approved releases. Once you have done it correctly, going back to manual releases feels like working with your hands tied.",[20,2569,2570],{},"The key word is \"correctly.\" Continuous deployment without a solid quality gate is just automated chaos delivery. Continuous deployment with good test coverage, reliable CI, staging environment parity, and automatic rollback is the fastest, safest way to ship software.",[20,2572,2573],{},"Here is how to build the pipeline.",[15,2575,2577],{"id":2576},"the-prerequisites","The Prerequisites",[20,2579,2580],{},"Continuous deployment is only safe if:",[95,2582,2583,2586,2589,2592,2595],{},[98,2584,2585],{},"You have meaningful automated test coverage (unit + integration at minimum)",[98,2587,2588],{},"Your CI pipeline catches breaking changes reliably",[98,2590,2591],{},"You can roll back within minutes when something goes wrong",[98,2593,2594],{},"You have monitoring that tells you when a deployment caused a regression",[98,2596,2597],{},"Your deployment process is fast enough that bad deployments are short-lived",[20,2599,2600],{},"If you are missing any of these, build them first. Continuous deployment without them accelerates bad outcomes, not good ones.",[15,2602,2604],{"id":2603},"the-pipeline-architecture","The Pipeline Architecture",[20,2606,2607],{},"A CD pipeline for a typical web application has three stages:",[20,2609,2610,2613],{},[42,2611,2612],{},"Build"," — compile, bundle, and package your application. The output is a versioned, immutable artifact: a Docker image, a deployment package, a compiled binary.",[20,2615,2616,2619],{},[42,2617,2618],{},"Test"," — run all automated tests against the build artifact. This is your quality gate. The artifact does not proceed unless all tests pass.",[20,2621,2622,2625],{},[42,2623,2624],{},"Deploy"," — promote the artifact through environments (staging, then production). Each promotion may be automatic or require a deliberate trigger.",[20,2627,2628],{},"The critical property: every stage operates on the same artifact. The Docker image that passed tests in staging is the exact image deployed to production. No rebuilding, no \"build for prod\" step. If you rebuild for production, you have not tested what you deployed.",[15,2630,2632],{"id":2631},"building-immutable-artifacts","Building Immutable Artifacts",[20,2634,2635],{},"For a containerized application, the artifact is a Docker image tagged with an immutable identifier. Use the Git commit SHA:",[321,2637,2639],{"className":496,"code":2638,"language":498,"meta":263,"style":263},"- name: Build and push Docker image\n run: |\n IMAGE_TAG=\"${{ github.sha }}\"\n docker build -t myregistry/api:${IMAGE_TAG} . Docker push myregistry/api:${IMAGE_TAG}\n # Also tag as latest for human reference\n docker tag myregistry/api:${IMAGE_TAG} myregistry/api:latest\n docker push myregistry/api:latest\n",[108,2640,2641,2652,2662,2667,2672,2677,2682],{"__ignoreMap":263},[329,2642,2643,2645,2647,2649],{"class":331,"line":332},[329,2644,506],{"class":505},[329,2646,510],{"class":509},[329,2648,513],{"class":505},[329,2650,2651],{"class":516},"Build and push Docker image\n",[329,2653,2654,2657,2659],{"class":331,"line":267},[329,2655,2656],{"class":509}," run",[329,2658,513],{"class":505},[329,2660,2661],{"class":1182},"|\n",[329,2663,2664],{"class":331,"line":264},[329,2665,2666],{"class":516}," IMAGE_TAG=\"${{ github.sha }}\"\n",[329,2668,2669],{"class":331,"line":348},[329,2670,2671],{"class":516}," docker build -t myregistry/api:${IMAGE_TAG} . Docker push myregistry/api:${IMAGE_TAG}\n",[329,2673,2674],{"class":331,"line":354},[329,2675,2676],{"class":516}," # Also tag as latest for human reference\n",[329,2678,2679],{"class":331,"line":360},[329,2680,2681],{"class":516}," docker tag myregistry/api:${IMAGE_TAG} myregistry/api:latest\n",[329,2683,2684],{"class":331,"line":286},[329,2685,2686],{"class":516}," docker push myregistry/api:latest\n",[20,2688,2689,2690,2693],{},"The image tagged with the commit SHA is immutable — it will always refer to exactly this build. The ",[108,2691,2692],{},"latest"," tag is mutable and useful for tooling that expects it, but you should reference the SHA tag in deployment manifests.",[20,2695,2696],{},"For frontend applications deployed to Cloudflare Pages or Vercel, the platform manages artifact creation. Your build output is automatically immutable per deployment.",[15,2698,2700],{"id":2699},"the-staging-environment-as-a-quality-gate","The Staging Environment as a Quality Gate",[20,2702,2703],{},"Staging should be a production replica. Same infrastructure, same configuration (except pointing to a staging database), same deployment process. If staging differs from production in any meaningful way, staging validation does not actually validate production behavior.",[20,2705,2706],{},"Deploy to staging automatically on merge to main. Run your automated integration tests and end-to-end tests against staging. Only proceed to production if staging deployment and tests pass.",[321,2708,2710],{"className":496,"code":2709,"language":498,"meta":263,"style":263},"deploy-staging:\n runs-on: ubuntu-latest\n needs: [test]\n steps:\n - name: Deploy to staging\n run: |\n kubectl set image deployment/api \\\n api=myregistry/api:${{ github.sha }} \\\n -n staging\n kubectl rollout status deployment/api -n staging\n\n - name: Run smoke tests against staging\n run: npm run test:e2e -- --env=staging\n\nDeploy-production:\n runs-on: ubuntu-latest\n needs: [deploy-staging]\n environment: production # Requires configured deployment environment\n steps:\n - name: Deploy to production\n run: |\n kubectl set image deployment/api \\\n api=myregistry/api:${{ github.sha }} \\\n -n production\n kubectl rollout status deployment/api -n production\n",[108,2711,2712,2719,2729,2743,2750,2761,2769,2774,2779,2784,2789,2793,2798,2803,2807,2814,2822,2832,2844,2850,2861,2869,2873,2877,2882],{"__ignoreMap":263},[329,2713,2714,2717],{"class":331,"line":332},[329,2715,2716],{"class":509},"deploy-staging",[329,2718,535],{"class":505},[329,2720,2721,2724,2726],{"class":331,"line":267},[329,2722,2723],{"class":509}," runs-on",[329,2725,513],{"class":505},[329,2727,2728],{"class":516},"ubuntu-latest\n",[329,2730,2731,2734,2737,2740],{"class":331,"line":264},[329,2732,2733],{"class":509}," needs",[329,2735,2736],{"class":505},": [",[329,2738,2739],{"class":516},"test",[329,2741,2742],{"class":505},"]\n",[329,2744,2745,2748],{"class":331,"line":348},[329,2746,2747],{"class":509}," steps",[329,2749,535],{"class":505},[329,2751,2752,2754,2756,2758],{"class":331,"line":354},[329,2753,666],{"class":505},[329,2755,510],{"class":509},[329,2757,513],{"class":505},[329,2759,2760],{"class":516},"Deploy to staging\n",[329,2762,2763,2765,2767],{"class":331,"line":360},[329,2764,2656],{"class":509},[329,2766,513],{"class":505},[329,2768,2661],{"class":1182},[329,2770,2771],{"class":331,"line":286},[329,2772,2773],{"class":516}," kubectl set image deployment/api \\\n",[329,2775,2776],{"class":331,"line":370},[329,2777,2778],{"class":516}," api=myregistry/api:${{ github.sha }} \\\n",[329,2780,2781],{"class":331,"line":375},[329,2782,2783],{"class":516}," -n staging\n",[329,2785,2786],{"class":331,"line":381},[329,2787,2788],{"class":516}," kubectl rollout status deployment/api -n staging\n",[329,2790,2791],{"class":331,"line":387},[329,2792,340],{"emptyLinePlaceholder":284},[329,2794,2795],{"class":331,"line":392},[329,2796,2797],{"class":516}," - name: Run smoke tests against staging\n",[329,2799,2800],{"class":331,"line":398},[329,2801,2802],{"class":516}," run: npm run test:e2e -- --env=staging\n",[329,2804,2805],{"class":331,"line":403},[329,2806,340],{"emptyLinePlaceholder":284},[329,2808,2809,2812],{"class":331,"line":409},[329,2810,2811],{"class":509},"Deploy-production",[329,2813,535],{"class":505},[329,2815,2816,2818,2820],{"class":331,"line":415},[329,2817,2723],{"class":509},[329,2819,513],{"class":505},[329,2821,2728],{"class":516},[329,2823,2824,2826,2828,2830],{"class":331,"line":420},[329,2825,2733],{"class":509},[329,2827,2736],{"class":505},[329,2829,2716],{"class":516},[329,2831,2742],{"class":505},[329,2833,2834,2836,2838,2841],{"class":331,"line":426},[329,2835,992],{"class":509},[329,2837,513],{"class":505},[329,2839,2840],{"class":516},"production",[329,2842,2843],{"class":620}," # Requires configured deployment environment\n",[329,2845,2846,2848],{"class":331,"line":887},[329,2847,2747],{"class":509},[329,2849,535],{"class":505},[329,2851,2852,2854,2856,2858],{"class":331,"line":895},[329,2853,666],{"class":505},[329,2855,510],{"class":509},[329,2857,513],{"class":505},[329,2859,2860],{"class":516},"Deploy to production\n",[329,2862,2863,2865,2867],{"class":331,"line":906},[329,2864,2656],{"class":509},[329,2866,513],{"class":505},[329,2868,2661],{"class":1182},[329,2870,2871],{"class":331,"line":914},[329,2872,2773],{"class":516},[329,2874,2875],{"class":331,"line":923},[329,2876,2778],{"class":516},[329,2878,2879],{"class":331,"line":2282},[329,2880,2881],{"class":516}," -n production\n",[329,2883,2884],{"class":331,"line":2290},[329,2885,2886],{"class":516}," kubectl rollout status deployment/api -n production\n",[20,2888,432,2889,2892],{},[108,2890,2891],{},"environment: production"," block can require manual approval before proceeding (configure in GitHub > Settings > Environments > Required reviewers). For fully automated continuous deployment, remove the approval requirement. For continuous delivery with a manual production gate, keep it.",[15,2894,2896],{"id":2895},"rolling-deployments","Rolling Deployments",[20,2898,2899],{},"A rolling deployment replaces pods incrementally — new pods come up, old pods come down — without taking the service offline. Kubernetes handles this natively with the rolling update strategy:",[321,2901,2903],{"className":496,"code":2902,"language":498,"meta":263,"style":263},"strategy:\n type: RollingUpdate\n rollingUpdate:\n maxUnavailable: 0 # Never take down more pods than you add\n maxSurge: 1 # Create one new pod at a time\n",[108,2904,2905,2912,2921,2928,2941],{"__ignoreMap":263},[329,2906,2907,2910],{"class":331,"line":332},[329,2908,2909],{"class":509},"strategy",[329,2911,535],{"class":505},[329,2913,2914,2916,2918],{"class":331,"line":267},[329,2915,2055],{"class":509},[329,2917,513],{"class":505},[329,2919,2920],{"class":516},"RollingUpdate\n",[329,2922,2923,2926],{"class":331,"line":264},[329,2924,2925],{"class":509}," rollingUpdate",[329,2927,535],{"class":505},[329,2929,2930,2933,2935,2938],{"class":331,"line":348},[329,2931,2932],{"class":509}," maxUnavailable",[329,2934,513],{"class":505},[329,2936,2937],{"class":585},"0",[329,2939,2940],{"class":620}," # Never take down more pods than you add\n",[329,2942,2943,2946,2948,2951],{"class":331,"line":354},[329,2944,2945],{"class":509}," maxSurge",[329,2947,513],{"class":505},[329,2949,2950],{"class":585},"1",[329,2952,2953],{"class":620}," # Create one new pod at a time\n",[20,2955,2956,2959,2960,2963],{},[108,2957,2958],{},"maxUnavailable: 0"," ensures capacity never drops during a deployment. ",[108,2961,2962],{},"maxSurge: 1"," means one extra pod runs during the transition period. This is the safe default for most applications.",[20,2965,2966,2967,2970],{},"For larger deployments, ",[108,2968,2969],{},"maxSurge"," can be set higher to speed up the rollout at the cost of temporarily running more pods.",[15,2972,2974],{"id":2973},"deployment-verification","Deployment Verification",[20,2976,2977],{},"After a deployment completes, verify it. Do not just check that pods are running — verify that the new version is actually serving traffic correctly.",[20,2979,2980],{},"A post-deployment smoke test is the minimum:",[321,2982,2984],{"className":1158,"code":2983,"language":1160,"meta":263,"style":263},"#!/bin/bash\nBASE_URL=\"https://api.production.com\"\nMAX_RETRIES=5\nRETRY_DELAY=10\n\nFor i in $(seq 1 $MAX_RETRIES); do\n RESPONSE=$(curl -s -o /dev/null -w \"%{http_code}\" \"$BASE_URL/health\")\n if [ \"$RESPONSE\" == \"200\" ]; then\n echo \"Health check passed\"\n break\n fi\n echo \"Health check failed ($RESPONSE), retrying in ${RETRY_DELAY}s...\"\n sleep $RETRY_DELAY\ndone\n\nIf [ \"$RESPONSE\" != \"200\" ]; then\n echo \"Deployment verification failed after $MAX_RETRIES attempts\"\n exit 1\nfi\n",[108,2985,2986,2991,3001,3011,3021,3025,3051,3090,3117,3125,3130,3135,3152,3160,3165,3169,3195,3208,3216],{"__ignoreMap":263},[329,2987,2988],{"class":331,"line":332},[329,2989,2990],{"class":620},"#!/bin/bash\n",[329,2992,2993,2996,2998],{"class":331,"line":267},[329,2994,2995],{"class":505},"BASE_URL",[329,2997,1511],{"class":1182},[329,2999,3000],{"class":516},"\"https://api.production.com\"\n",[329,3002,3003,3006,3008],{"class":331,"line":264},[329,3004,3005],{"class":505},"MAX_RETRIES",[329,3007,1511],{"class":1182},[329,3009,3010],{"class":516},"5\n",[329,3012,3013,3016,3018],{"class":331,"line":348},[329,3014,3015],{"class":505},"RETRY_DELAY",[329,3017,1511],{"class":1182},[329,3019,3020],{"class":516},"10\n",[329,3022,3023],{"class":331,"line":354},[329,3024,340],{"emptyLinePlaceholder":284},[329,3026,3027,3030,3033,3036,3039,3042,3045,3048],{"class":331,"line":360},[329,3028,3029],{"class":1172},"For",[329,3031,3032],{"class":516}," i",[329,3034,3035],{"class":516}," in",[329,3037,3038],{"class":505}," $(",[329,3040,3041],{"class":1172},"seq",[329,3043,3044],{"class":585}," 1",[329,3046,3047],{"class":505}," $MAX_RETRIES); ",[329,3049,3050],{"class":1182},"do\n",[329,3052,3053,3056,3058,3061,3064,3067,3070,3073,3076,3079,3082,3085,3088],{"class":331,"line":286},[329,3054,3055],{"class":505}," RESPONSE",[329,3057,1511],{"class":1182},[329,3059,3060],{"class":505},"$(",[329,3062,3063],{"class":1172},"curl",[329,3065,3066],{"class":585}," -s",[329,3068,3069],{"class":585}," -o",[329,3071,3072],{"class":516}," /dev/null",[329,3074,3075],{"class":585}," -w",[329,3077,3078],{"class":516}," \"%{http_code}\"",[329,3080,3081],{"class":516}," \"",[329,3083,3084],{"class":505},"$BASE_URL",[329,3086,3087],{"class":516},"/health\"",[329,3089,1611],{"class":505},[329,3091,3092,3094,3097,3100,3103,3105,3108,3111,3114],{"class":331,"line":370},[329,3093,2425],{"class":1182},[329,3095,3096],{"class":505}," [ ",[329,3098,3099],{"class":516},"\"",[329,3101,3102],{"class":505},"$RESPONSE",[329,3104,3099],{"class":516},[329,3106,3107],{"class":1182}," ==",[329,3109,3110],{"class":516}," \"200\"",[329,3112,3113],{"class":505}," ]; ",[329,3115,3116],{"class":1182},"then\n",[329,3118,3119,3122],{"class":331,"line":375},[329,3120,3121],{"class":585}," echo",[329,3123,3124],{"class":516}," \"Health check passed\"\n",[329,3126,3127],{"class":331,"line":381},[329,3128,3129],{"class":1182}," break\n",[329,3131,3132],{"class":331,"line":387},[329,3133,3134],{"class":1182}," fi\n",[329,3136,3137,3139,3142,3144,3147,3149],{"class":331,"line":392},[329,3138,3121],{"class":585},[329,3140,3141],{"class":516}," \"Health check failed (",[329,3143,3102],{"class":505},[329,3145,3146],{"class":516},"), retrying in ${",[329,3148,3015],{"class":505},[329,3150,3151],{"class":516},"}s...\"\n",[329,3153,3154,3157],{"class":331,"line":398},[329,3155,3156],{"class":1172}," sleep",[329,3158,3159],{"class":505}," $RETRY_DELAY\n",[329,3161,3162],{"class":331,"line":403},[329,3163,3164],{"class":1182},"done\n",[329,3166,3167],{"class":331,"line":409},[329,3168,340],{"emptyLinePlaceholder":284},[329,3170,3171,3174,3176,3178,3180,3182,3185,3187,3190,3193],{"class":331,"line":415},[329,3172,3173],{"class":1172},"If",[329,3175,3096],{"class":505},[329,3177,3099],{"class":516},[329,3179,3102],{"class":505},[329,3181,3099],{"class":516},[329,3183,3184],{"class":516}," !=",[329,3186,3110],{"class":516},[329,3188,3189],{"class":516}," ]",[329,3191,3192],{"class":505},"; ",[329,3194,3116],{"class":1182},[329,3196,3197,3199,3202,3205],{"class":331,"line":420},[329,3198,3121],{"class":585},[329,3200,3201],{"class":516}," \"Deployment verification failed after ",[329,3203,3204],{"class":505},"$MAX_RETRIES",[329,3206,3207],{"class":516}," attempts\"\n",[329,3209,3210,3213],{"class":331,"line":426},[329,3211,3212],{"class":585}," exit",[329,3214,3215],{"class":585}," 1\n",[329,3217,3218],{"class":331,"line":887},[329,3219,3220],{"class":1182},"fi\n",[20,3222,3223],{},"A more thorough approach is a canary deployment: route 5% of traffic to the new version, monitor error rate and latency for 10 minutes, then route 100% if metrics look healthy. This requires a load balancer that supports weighted traffic routing (Nginx, Envoy, or cloud load balancers with traffic splitting capabilities).",[15,3225,3227],{"id":3226},"automatic-rollback","Automatic Rollback",[20,3229,3230],{},"When deployment verification fails, roll back automatically. Do not require human intervention for a well-defined failure condition.",[321,3232,3234],{"className":496,"code":3233,"language":498,"meta":263,"style":263},"- name: Deploy and verify\n run: |\n kubectl set image deployment/api api=myregistry/api:${{ github.sha }} -n production\n kubectl rollout status deployment/api -n production --timeout=5m || {\n echo \"Deployment failed, rolling back\"\n kubectl rollout undo deployment/api -n production\n exit 1\n }\n\n- name: Post-deployment smoke test\n run: |\n ./scripts/smoke-test.sh || {\n echo \"Smoke test failed, rolling back deployment\"\n kubectl rollout undo deployment/api -n production\n exit 1\n }\n",[108,3235,3236,3247,3255,3260,3265,3270,3275,3280,3284,3288,3299,3307,3312,3317,3321,3325],{"__ignoreMap":263},[329,3237,3238,3240,3242,3244],{"class":331,"line":332},[329,3239,506],{"class":505},[329,3241,510],{"class":509},[329,3243,513],{"class":505},[329,3245,3246],{"class":516},"Deploy and verify\n",[329,3248,3249,3251,3253],{"class":331,"line":267},[329,3250,2656],{"class":509},[329,3252,513],{"class":505},[329,3254,2661],{"class":1182},[329,3256,3257],{"class":331,"line":264},[329,3258,3259],{"class":516}," kubectl set image deployment/api api=myregistry/api:${{ github.sha }} -n production\n",[329,3261,3262],{"class":331,"line":348},[329,3263,3264],{"class":516}," kubectl rollout status deployment/api -n production --timeout=5m || {\n",[329,3266,3267],{"class":331,"line":354},[329,3268,3269],{"class":516}," echo \"Deployment failed, rolling back\"\n",[329,3271,3272],{"class":331,"line":360},[329,3273,3274],{"class":516}," kubectl rollout undo deployment/api -n production\n",[329,3276,3277],{"class":331,"line":286},[329,3278,3279],{"class":516}," exit 1\n",[329,3281,3282],{"class":331,"line":370},[329,3283,2466],{"class":516},[329,3285,3286],{"class":331,"line":375},[329,3287,340],{"emptyLinePlaceholder":284},[329,3289,3290,3292,3294,3296],{"class":331,"line":381},[329,3291,506],{"class":505},[329,3293,510],{"class":509},[329,3295,513],{"class":505},[329,3297,3298],{"class":516},"Post-deployment smoke test\n",[329,3300,3301,3303,3305],{"class":331,"line":387},[329,3302,2656],{"class":509},[329,3304,513],{"class":505},[329,3306,2661],{"class":1182},[329,3308,3309],{"class":331,"line":392},[329,3310,3311],{"class":516}," ./scripts/smoke-test.sh || {\n",[329,3313,3314],{"class":331,"line":398},[329,3315,3316],{"class":516}," echo \"Smoke test failed, rolling back deployment\"\n",[329,3318,3319],{"class":331,"line":403},[329,3320,3274],{"class":516},[329,3322,3323],{"class":331,"line":409},[329,3324,3279],{"class":516},[329,3326,3327],{"class":331,"line":415},[329,3328,2466],{"class":516},[20,3330,3331,3332,3335],{},"Kubernetes retains the last 10 deployment revisions by default (configurable via ",[108,3333,3334],{},"revisionHistoryLimit","). Each rollback undoes one revision. Rollback time is typically under 60 seconds for a Kubernetes rolling deployment.",[15,3337,3339],{"id":3338},"feature-flags-for-safe-deployment","Feature Flags for Safe Deployment",[20,3341,3342],{},"Feature flags let you deploy code to production without exposing it to users. The code is live, but gated. When you are ready to release, flip the flag.",[20,3344,3345],{},"This decouples deployment from release. You can deploy continuously without every deployment being a user-visible release event. It also enables instant rollback of a feature without a code deployment — just disable the flag.",[20,3347,3348],{},"For a simple feature flag implementation, use your environment configuration. For production-grade feature flags with targeting rules (show to 5% of users, show to users in a specific country, show to specific user IDs), use LaunchDarkly, Unleash, or Cloudflare Edge Config.",[15,3350,3352],{"id":3351},"measuring-your-deployment-pipeline","Measuring Your Deployment Pipeline",[20,3354,3355],{},"Track two metrics for your CD pipeline:",[20,3357,3358,3361],{},[42,3359,3360],{},"Deployment frequency"," — how often you deploy to production. Daily, multiple times daily, weekly. This is one of DORA's four key metrics for engineering performance. Higher frequency indicates a healthier, lower-risk deployment process.",[20,3363,3364,3367],{},[42,3365,3366],{},"Lead time for changes"," — time from code commit to running in production. For a well-functioning CD pipeline, this should be under one hour. When it is hours or days, that gap represents time where bugs are in the codebase but not yet fixed in production.",[20,3369,3370],{},"Measure these monthly. If deployment frequency is dropping or lead time is increasing, your pipeline has a bottleneck worth diagnosing.",[30,3372],{},[20,3374,3375,3376,229],{},"If you want help designing or improving your continuous deployment pipeline, book a session at ",[223,3377,225],{"href":225,"rel":3378},[227],[30,3380],{},[15,3382,235],{"id":234},[95,3384,3385,3391,3397,3401],{},[98,3386,3387],{},[223,3388,3390],{"href":3389},"/blog/zero-to-production-nuxt-vercel","Zero to Production: My Nuxt + Vercel Deployment Pipeline",[98,3392,3393],{},[223,3394,3396],{"href":3395},"/blog/github-actions-cicd-guide","GitHub Actions CI/CD: A Complete Setup Guide for Modern Projects",[98,3398,3399],{},[223,3400,296],{"href":1323},[98,3402,3403],{},[223,3404,1281],{"href":1280},[1301,3406,3407],{},"html pre.shiki code .s95oV, html code.shiki .s95oV{--shiki-default:#E1E4E8}html pre.shiki code .s4JwU, html code.shiki .s4JwU{--shiki-default:#85E89D}html pre.shiki code .sU2Wk, html code.shiki .sU2Wk{--shiki-default:#9ECBFF}html pre.shiki code .snl16, html code.shiki .snl16{--shiki-default:#F97583}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html pre.shiki code .sAwPA, html code.shiki .sAwPA{--shiki-default:#6A737D}html pre.shiki code .sDLfK, html code.shiki .sDLfK{--shiki-default:#79B8FF}html pre.shiki code .svObZ, html code.shiki .svObZ{--shiki-default:#B392F0}",{"title":263,"searchDepth":264,"depth":264,"links":3409},[3410,3411,3412,3413,3414,3415,3416,3417,3418,3419],{"id":2576,"depth":267,"text":2577},{"id":2603,"depth":267,"text":2604},{"id":2631,"depth":267,"text":2632},{"id":2699,"depth":267,"text":2700},{"id":2895,"depth":267,"text":2896},{"id":2973,"depth":267,"text":2974},{"id":3226,"depth":267,"text":3227},{"id":3338,"depth":267,"text":3339},{"id":3351,"depth":267,"text":3352},{"id":234,"depth":267,"text":235},"Build a continuous deployment pipeline that ships code to production automatically — artifact building, environment promotion, rollback strategies, and deployment verification.",[3422,3423],"continuous deployment","CI/CD pipeline",{},{"title":1299,"description":3420},"blog/continuous-deployment-guide",[3428,3429,1317,3430],"Continuous Deployment","CI/CD","Automation","m72n03InK6gBKldufsO6OwjFJiNKuFM0PsJ4rC-naSs",{"id":3433,"title":3434,"author":3435,"body":3436,"category":274,"date":275,"description":3996,"extension":277,"featured":278,"image":279,"keywords":3997,"meta":4000,"navigation":284,"path":4001,"readTime":286,"seo":4002,"stem":4003,"tags":4004,"__hash__":4008},"blog/blog/core-web-vitals-optimization.md","Core Web Vitals Optimization: A Developer's Complete Guide",{"name":9,"bio":10},{"type":12,"value":3437,"toc":3987},[3438,3442,3445,3448,3450,3454,3460,3465,3476,3481,3484,3494,3501,3504,3517,3519,3523,3528,3532,3543,3548,3551,3562,3651,3658,3661,3664,3666,3670,3675,3679,3690,3695,3710,3764,3767,3785,3788,3790,3794,3799,3810,3815,3826,3911,3914,3916,3920,3946,3948,3954,3956,3958,3984],[15,3439,3441],{"id":3440},"why-core-web-vitals-became-non-negotiable","Why Core Web Vitals Became Non-Negotiable",[20,3443,3444],{},"Google's Core Web Vitals program has made performance a ranking factor, which means slow websites don't just lose users — they lose search visibility. But beyond rankings, these metrics exist because they measure something real: the user experience of waiting for a page to be usable. A page that takes 4 seconds to display its main content is a bad page, regardless of how well-designed everything else is.",[20,3446,3447],{},"As of 2024, Google's three Core Web Vitals are Largest Contentful Paint (LCP), Interaction to Next Paint (INP), and Cumulative Layout Shift (CLS). Understanding what each measures is the prerequisite to fixing them.",[30,3449],{},[15,3451,3453],{"id":3452},"lcp-largest-contentful-paint","LCP: Largest Contentful Paint",[20,3455,3456,3459],{},[42,3457,3458],{},"What it measures:"," The time from the start of a page load to when the largest content element visible in the viewport is fully rendered. For most pages, this is a hero image, a large text block, or a banner video.",[20,3461,3462],{},[42,3463,3464],{},"The thresholds:",[95,3466,3467,3470,3473],{},[98,3468,3469],{},"Good: under 2.5 seconds",[98,3471,3472],{},"Needs improvement: 2.5-4 seconds",[98,3474,3475],{},"Poor: over 4 seconds",[20,3477,3478],{},[42,3479,3480],{},"What causes poor LCP:",[20,3482,3483],{},"Slow server response time. If the HTML document itself takes 2 seconds to arrive, LCP can't be under 2.5 seconds. Use Time to First Byte (TTFB) as the diagnostic signal. Fix: edge caching (Cloudflare), CDN-cached HTML for static content, database query optimization for dynamic pages.",[20,3485,3486,3487,3490,3491,3493],{},"Render-blocking resources. CSS and synchronous JS in the ",[108,3488,3489],{},"\u003Chead>"," that must load before the browser can render anything. Fix: critical CSS inlined in the ",[108,3492,3489],{},", deferred or async loading for all non-critical scripts.",[20,3495,3496,3497,3500],{},"Slow image loading. If the LCP element is an image that starts downloading late or is uncompressed. Fix: image preloading (",[108,3498,3499],{},"\u003Clink rel=\"preload\" as=\"image\">","), proper sizing and format (WebP/AVIF), a CDN with image optimization.",[20,3502,3503],{},"Client-side rendering. Pages that are blank until JavaScript runs and hydrates will have high LCP because the LCP element doesn't exist until the JS executes. Fix: server-side rendering or static generation so the HTML document contains the content.",[20,3505,3506,3509,3510,3513,3514,3516],{},[42,3507,3508],{},"The single most impactful fix for most sites:"," Preload the LCP image. Add ",[108,3511,3512],{},"\u003Clink rel=\"preload\" as=\"image\" href=\"/hero.webp\" fetchpriority=\"high\">"," to the ",[108,3515,3489],{},". This tells the browser to fetch the image immediately, in parallel with other resources, rather than waiting until the CSS and layout are processed.",[30,3518],{},[15,3520,3522],{"id":3521},"inp-interaction-to-next-paint","INP: Interaction to Next Paint",[20,3524,3525,3527],{},[42,3526,3458],{}," The latency between a user interaction (click, tap, keyboard input) and the next time the browser paints a visual response. This replaced FID (First Input Delay) in March 2024 because it measures the responsiveness of all interactions throughout the page lifecycle, not just the first one.",[20,3529,3530],{},[42,3531,3464],{},[95,3533,3534,3537,3540],{},[98,3535,3536],{},"Good: under 200ms",[98,3538,3539],{},"Needs improvement: 200-500ms",[98,3541,3542],{},"Poor: over 500ms",[20,3544,3545],{},[42,3546,3547],{},"What causes poor INP:",[20,3549,3550],{},"Long tasks on the main thread. JavaScript that runs for more than 50ms blocks the main thread and prevents the browser from responding to user input. Common culprits: large event handlers, synchronous computation on user interaction, rendering expensive UI components in response to input.",[20,3552,3553,3554,3557,3558,3561],{},"Fix: Break long tasks into smaller chunks using ",[108,3555,3556],{},"setTimeout"," or ",[108,3559,3560],{},"scheduler.yield()"," (Chrome 115+). The browser can handle user input between chunks.",[321,3563,3567],{"className":3564,"code":3565,"language":3566,"meta":263,"style":263},"language-javascript shiki shiki-themes github-dark","async function processLargeDataset(items) {\n for (const item of items) {\n processItem(item)\n // Yield to the browser between chunks\n if (shouldYield()) await scheduler.yield()\n }\n}\n","javascript",[108,3568,3569,3587,3606,3614,3619,3643,3647],{"__ignoreMap":263},[329,3570,3571,3574,3577,3580,3582,3585],{"class":331,"line":332},[329,3572,3573],{"class":1182},"async",[329,3575,3576],{"class":1182}," function",[329,3578,3579],{"class":1172}," processLargeDataset",[329,3581,1437],{"class":505},[329,3583,3584],{"class":1482},"items",[329,3586,2105],{"class":505},[329,3588,3589,3592,3594,3597,3600,3603],{"class":331,"line":267},[329,3590,3591],{"class":1182}," for",[329,3593,2428],{"class":505},[329,3595,3596],{"class":1182},"const",[329,3598,3599],{"class":585}," item",[329,3601,3602],{"class":1182}," of",[329,3604,3605],{"class":505}," items) {\n",[329,3607,3608,3611],{"class":331,"line":264},[329,3609,3610],{"class":1172}," processItem",[329,3612,3613],{"class":505},"(item)\n",[329,3615,3616],{"class":331,"line":348},[329,3617,3618],{"class":620}," // Yield to the browser between chunks\n",[329,3620,3621,3623,3625,3628,3631,3634,3637,3640],{"class":331,"line":354},[329,3622,2425],{"class":1182},[329,3624,2428],{"class":505},[329,3626,3627],{"class":1172},"shouldYield",[329,3629,3630],{"class":505},"()) ",[329,3632,3633],{"class":1182},"await",[329,3635,3636],{"class":505}," scheduler.",[329,3638,3639],{"class":1172},"yield",[329,3641,3642],{"class":505},"()\n",[329,3644,3645],{"class":331,"line":360},[329,3646,2466],{"class":505},[329,3648,3649],{"class":331,"line":286},[329,3650,1459],{"class":505},[20,3652,3653,3654,3657],{},"Expensive DOM operations. Modifying large portions of the DOM in response to a click triggers layout recalculation and paint, which takes time proportional to the size of the change. Fix: minimize DOM changes, batch writes with ",[108,3655,3656],{},"DocumentFragment",", avoid reading layout properties immediately after writes (this triggers forced synchronous layout).",[20,3659,3660],{},"Hydration overhead in SSR apps. The moment after a server-rendered page loads, the JavaScript framework hydrates the DOM (attaches event listeners, reconciles state). During this period, the page looks interactive but isn't. User interactions during hydration feel unresponsive.",[20,3662,3663],{},"Fix: Partial hydration (only hydrate components that need interactivity), islands architecture (Astro), or streamed hydration patterns.",[30,3665],{},[15,3667,3669],{"id":3668},"cls-cumulative-layout-shift","CLS: Cumulative Layout Shift",[20,3671,3672,3674],{},[42,3673,3458],{}," The total score of all unexpected layout shifts during the page's lifetime. A layout shift happens when a visible element changes position on the page without user input — typically because content loaded above it and pushed it down.",[20,3676,3677],{},[42,3678,3464],{},[95,3680,3681,3684,3687],{},[98,3682,3683],{},"Good: under 0.1",[98,3685,3686],{},"Needs improvement: 0.1-0.25",[98,3688,3689],{},"Poor: over 0.25",[20,3691,3692],{},[42,3693,3694],{},"The most common causes and fixes:",[20,3696,3697,3698,3701,3702,3705,3706,3709],{},"Images without explicit dimensions. When the browser loads an image and doesn't know its dimensions in advance, it can't reserve space for it. Content around it shifts when the image loads. Fix: always specify ",[108,3699,3700],{},"width"," and ",[108,3703,3704],{},"height"," attributes on images (or use ",[108,3707,3708],{},"aspect-ratio"," in CSS), even if you're also styling them responsively.",[321,3711,3713],{"className":1638,"code":3712,"language":1640,"meta":263,"style":263},"\u003Cimg src=\"hero.jpg\" width=\"1200\" height=\"630\" alt=\"...\" loading=\"lazy\">\n",[108,3714,3715],{"__ignoreMap":263},[329,3716,3717,3719,3722,3725,3727,3730,3733,3735,3738,3741,3743,3746,3749,3751,3754,3757,3759,3762],{"class":331,"line":332},[329,3718,1652],{"class":505},[329,3720,3721],{"class":509},"img",[329,3723,3724],{"class":1172}," src",[329,3726,1511],{"class":505},[329,3728,3729],{"class":516},"\"hero.jpg\"",[329,3731,3732],{"class":1172}," width",[329,3734,1511],{"class":505},[329,3736,3737],{"class":516},"\"1200\"",[329,3739,3740],{"class":1172}," height",[329,3742,1511],{"class":505},[329,3744,3745],{"class":516},"\"630\"",[329,3747,3748],{"class":1172}," alt",[329,3750,1511],{"class":505},[329,3752,3753],{"class":516},"\"...\"",[329,3755,3756],{"class":1172}," loading",[329,3758,1511],{"class":505},[329,3760,3761],{"class":516},"\"lazy\"",[329,3763,1666],{"class":505},[20,3765,3766],{},"Embeds with unknown dimensions: ads, iframes, social embeds. Fix: define explicit container dimensions before the embed loads.",[20,3768,3769,3770,3773,3774,1486,3777,3780,3781,3784],{},"Late-loading fonts causing FOUT/FOIT. Text rendering first in a fallback font, then shifting position when the web font loads. Fix: ",[108,3771,3772],{},"font-display: swap"," to prevent invisible text, and matching fallback font metrics using ",[108,3775,3776],{},"ascent-override",[108,3778,3779],{},"descent-override",", and ",[108,3782,3783],{},"line-gap-override"," to reduce the metric difference between fonts.",[20,3786,3787],{},"Dynamic content injected above existing content. If you inject a cookie banner, notification bar, or dynamic header above page content after load, all the content below it shifts. Fix: reserve the space in the layout before the dynamic content loads.",[30,3789],{},[15,3791,3793],{"id":3792},"measuring-and-monitoring","Measuring and Monitoring",[20,3795,3796],{},[42,3797,3798],{},"Lab tools:",[95,3800,3801,3804,3807],{},[98,3802,3803],{},"Lighthouse (in Chrome DevTools) — synthetic measurement on demand",[98,3805,3806],{},"PageSpeed Insights (pagespeed.web.dev) — Lighthouse plus real-world data from CrUX",[98,3808,3809],{},"WebPageTest — detailed waterfall and multi-step testing",[20,3811,3812],{},[42,3813,3814],{},"Field data:",[95,3816,3817,3820,3823],{},[98,3818,3819],{},"Chrome User Experience Report (CrUX) — real user measurements from Chrome, publicly available",[98,3821,3822],{},"Google Search Console — Core Web Vitals tab shows field data for your URLs",[98,3824,3825],{},"web-vitals JavaScript library — collect CWV from real users in your own analytics",[321,3827,3829],{"className":3564,"code":3828,"language":3566,"meta":263,"style":263},"import { onLCP, onINP, onCLS } from 'web-vitals'\n\nOnLCP(metric => sendToAnalytics('LCP', metric.value))\nonINP(metric => sendToAnalytics('INP', metric.value))\nonCLS(metric => sendToAnalytics('CLS', metric.value))\n",[108,3830,3831,3843,3847,3871,3891],{"__ignoreMap":263},[329,3832,3833,3835,3838,3840],{"class":331,"line":332},[329,3834,1399],{"class":1182},[329,3836,3837],{"class":505}," { onLCP, onINP, onCLS } ",[329,3839,1405],{"class":1182},[329,3841,3842],{"class":516}," 'web-vitals'\n",[329,3844,3845],{"class":331,"line":267},[329,3846,340],{"emptyLinePlaceholder":284},[329,3848,3849,3852,3854,3857,3860,3863,3865,3868],{"class":331,"line":264},[329,3850,3851],{"class":1172},"OnLCP",[329,3853,1437],{"class":505},[329,3855,3856],{"class":1482},"metric",[329,3858,3859],{"class":1182}," =>",[329,3861,3862],{"class":1172}," sendToAnalytics",[329,3864,1437],{"class":505},[329,3866,3867],{"class":516},"'LCP'",[329,3869,3870],{"class":505},", metric.value))\n",[329,3872,3873,3876,3878,3880,3882,3884,3886,3889],{"class":331,"line":348},[329,3874,3875],{"class":1172},"onINP",[329,3877,1437],{"class":505},[329,3879,3856],{"class":1482},[329,3881,3859],{"class":1182},[329,3883,3862],{"class":1172},[329,3885,1437],{"class":505},[329,3887,3888],{"class":516},"'INP'",[329,3890,3870],{"class":505},[329,3892,3893,3896,3898,3900,3902,3904,3906,3909],{"class":331,"line":354},[329,3894,3895],{"class":1172},"onCLS",[329,3897,1437],{"class":505},[329,3899,3856],{"class":1482},[329,3901,3859],{"class":1182},[329,3903,3862],{"class":1172},[329,3905,1437],{"class":505},[329,3907,3908],{"class":516},"'CLS'",[329,3910,3870],{"class":505},[20,3912,3913],{},"Field data is more important than lab data. A page that scores well in Lighthouse but poorly in real user measurements has a real-world problem. Field data captures network conditions, device diversity, and browser extensions that lab tools can't reproduce.",[30,3915],{},[15,3917,3919],{"id":3918},"a-practical-optimization-priority-order","A Practical Optimization Priority Order",[1796,3921,3922,3925,3928,3931,3934,3937,3943],{},[98,3923,3924],{},"Fix TTFB first (server response time). You can't fix LCP without a fast server response.",[98,3926,3927],{},"Eliminate render-blocking resources. Inline critical CSS, defer everything else.",[98,3929,3930],{},"Preload the LCP image. One line of HTML, potentially significant LCP improvement.",[98,3932,3933],{},"Add explicit dimensions to all images and embeds. Eliminates most CLS.",[98,3935,3936],{},"Profile and break up long tasks. Identify the biggest INP offenders and yield between chunks.",[98,3938,3939,3940,3942],{},"Optimize fonts with ",[108,3941,3772],{}," and fallback metric matching.",[98,3944,3945],{},"Measure with field data. Repeat.",[30,3947],{},[20,3949,3950,3951,229],{},"Core Web Vitals optimization is part measurement, part diagnosis, and part implementation. If you're working on a site with poor Core Web Vitals scores and want help identifying the specific root causes, book a call at ",[223,3952,228],{"href":225,"rel":3953},[227],[30,3955],{},[15,3957,235],{"id":234},[95,3959,3960,3966,3972,3978],{},[98,3961,3962],{},[223,3963,3965],{"href":3964},"/blog/font-loading-optimization","Font Loading Optimization: Eliminating Layout Shift and Invisible Text",[98,3967,3968],{},[223,3969,3971],{"href":3970},"/blog/image-optimization-web","Image Optimization for the Web: Formats, Compression, and Lazy Loading",[98,3973,3974],{},[223,3975,3977],{"href":3976},"/blog/nodejs-performance-optimization","Node.js Performance Optimization: The Practical Guide",[98,3979,3980],{},[223,3981,3983],{"href":3982},"/blog/frontend-performance-guide","Frontend Performance: The Metrics That Matter and How to Hit Them",[1301,3985,3986],{},"html pre.shiki code .snl16, html code.shiki .snl16{--shiki-default:#F97583}html pre.shiki code .svObZ, html code.shiki .svObZ{--shiki-default:#B392F0}html pre.shiki code .s95oV, html code.shiki .s95oV{--shiki-default:#E1E4E8}html pre.shiki code .s9osk, html code.shiki .s9osk{--shiki-default:#FFAB70}html pre.shiki code .sDLfK, html code.shiki .sDLfK{--shiki-default:#79B8FF}html pre.shiki code .sAwPA, html code.shiki .sAwPA{--shiki-default:#6A737D}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html pre.shiki code .s4JwU, html code.shiki .s4JwU{--shiki-default:#85E89D}html pre.shiki code .sU2Wk, html code.shiki .sU2Wk{--shiki-default:#9ECBFF}",{"title":263,"searchDepth":264,"depth":264,"links":3988},[3989,3990,3991,3992,3993,3994,3995],{"id":3440,"depth":267,"text":3441},{"id":3452,"depth":267,"text":3453},{"id":3521,"depth":267,"text":3522},{"id":3668,"depth":267,"text":3669},{"id":3792,"depth":267,"text":3793},{"id":3918,"depth":267,"text":3919},{"id":234,"depth":267,"text":235},"Core Web Vitals directly affect your search rankings and user experience. Here's the developer's guide to measuring, diagnosing, and fixing LCP, INP, and CLS.",[3998,3999],"Core Web Vitals optimization","LCP FID CLS",{},"/blog/core-web-vitals-optimization",{"title":3434,"description":3996},"blog/core-web-vitals-optimization",[4005,4006,4007],"Core Web Vitals","Performance","SEO","6j0pT9rEORRhSpWOcnCy7uAbamST87CjVnOWi_4__uQ",{"id":4010,"title":4011,"author":4012,"body":4013,"category":4293,"date":275,"description":4294,"extension":277,"featured":278,"image":279,"keywords":4295,"meta":4301,"navigation":284,"path":4302,"readTime":381,"seo":4303,"stem":4304,"tags":4305,"__hash__":4310},"blog/blog/cqrs-event-sourcing-explained.md","CQRS and Event Sourcing: A Practitioner's Honest Take",{"name":9,"bio":10},{"type":12,"value":4014,"toc":4279},[4015,4019,4022,4025,4028,4030,4034,4037,4040,4043,4048,4051,4054,4057,4060,4063,4067,4070,4084,4087,4089,4093,4096,4110,4116,4119,4123,4129,4135,4141,4147,4151,4157,4163,4169,4179,4185,4187,4191,4194,4208,4211,4222,4225,4227,4231,4234,4237,4240,4242,4249,4251,4253],[15,4016,4018],{"id":4017},"starting-with-honest-expectations","Starting With Honest Expectations",[20,4020,4021],{},"CQRS and Event Sourcing (ES) are among the most discussed and most misapplied patterns in modern software architecture. They appear frequently in blog posts, conference talks, and job postings as markers of architectural sophistication. They appear less frequently in production systems that are actually better for having them.",[20,4023,4024],{},"That's not a knock on the patterns. Both CQRS and event sourcing solve real problems elegantly. The issue is that the problems they solve are specific and the complexity they introduce is significant. Applying them to systems that don't have those specific problems is expensive and counterproductive.",[20,4026,4027],{},"This post explains what CQRS and event sourcing actually do, the implementation complexity involved, and the conditions under which that complexity is justified.",[30,4029],{},[15,4031,4033],{"id":4032},"cqrs-the-core-idea","CQRS: The Core Idea",[20,4035,4036],{},"Command Query Responsibility Segregation (CQRS) is the principle that reading data and writing data should use different models and potentially different paths through your system.",[20,4038,4039],{},"The term comes from Bertrand Meyer's Command Query Separation (CQS) principle: methods should either perform an action (command) or return data (query), but not both. CQRS applies this at the architectural level: your system has a command side (write operations that change state) and a query side (read operations that return data).",[20,4041,4042],{},"In a simple implementation, this might just mean using different service classes for reads and writes. In a more complete implementation, it means completely separate models: a write model optimized for expressing and validating domain operations, and one or more read models optimized for the specific data access patterns of your UI or API consumers.",[4044,4045,4047],"h3",{"id":4046},"why-would-you-want-separate-models","Why Would You Want Separate Models?",[20,4049,4050],{},"The value becomes clear when read and write requirements diverge significantly.",[20,4052,4053],{},"Consider an e-commerce order system. Writing an order is a complex domain operation: validate inventory, calculate pricing with promotions, apply tax rules, verify payment method, update inventory reservations. The write model needs to express this business logic clearly and enforce invariants.",[20,4055,4056],{},"Reading orders, however, serves many different purposes. An order history page needs a flat, paginated list of orders with basic status. An analytics dashboard needs aggregated order metrics by date, product category, and geography. A warehouse pick list needs orders grouped by fulfillment center with specific item attributes. A customer service view needs orders with full audit history and communication log.",[20,4058,4059],{},"If you try to serve all of these read patterns from the same model as your write operations, you end up with either a complex model that serves all purposes poorly, or N-to-1 queries that join data in ways your write model doesn't efficiently support.",[20,4061,4062],{},"CQRS acknowledges this divergence explicitly. The write model is optimized for writes. The read models (there can be multiple) are optimized for their specific consumers.",[4044,4064,4066],{"id":4065},"cqrs-without-event-sourcing","CQRS Without Event Sourcing",[20,4068,4069],{},"CQRS and event sourcing are often mentioned together, but they're independent patterns. You can implement CQRS without event sourcing:",[1796,4071,4072,4075,4078,4081],{},[98,4073,4074],{},"Commands go through a command handler that executes domain logic and writes to the write store (typically a relational database)",[98,4076,4077],{},"An event or trigger publishes the state change",[98,4079,4080],{},"Read model projections update one or more read stores (denormalized tables, Elasticsearch indexes, a separate database) based on the change",[98,4082,4083],{},"Queries read from the read store directly",[20,4085,4086],{},"This is a significant but tractable implementation. The read stores are eventually consistent with the write store — updates propagate asynchronously.",[30,4088],{},[15,4090,4092],{"id":4091},"event-sourcing-storing-events-instead-of-state","Event Sourcing: Storing Events Instead of State",[20,4094,4095],{},"Event sourcing takes a fundamentally different approach to persistence. Instead of storing the current state of an entity, you store the sequence of events that produced that state.",[20,4097,4098,4099,4102,4103,3701,4106,4109],{},"An ",[108,4100,4101],{},"Order"," entity is not stored as a record with fields like ",[108,4104,4105],{},"status: \"shipped\"",[108,4107,4108],{},"total: 149.99",". Instead, you store a sequence of events:",[321,4111,4114],{"className":4112,"code":4113,"language":1775},[1773],"OrderCreated { id, customerId, items }\nPaymentCaptured { orderId, amount, paymentMethod }\nOrderConfirmed { orderId }\nItemShipped { orderId, itemId, trackingNumber }\n",[108,4115,4113],{"__ignoreMap":263},[20,4117,4118],{},"The current state of the order is derived by replaying these events in sequence — a process called projection or reconstitution. Every state the entity has ever been in is recoverable by replaying the event log up to a given point.",[4044,4120,4122],{"id":4121},"what-event-sourcing-actually-provides","What Event Sourcing Actually Provides",[20,4124,4125,4128],{},[42,4126,4127],{},"Complete audit history."," Every change to every entity is preserved. This is genuinely valuable in domains where you need to answer \"what was the state of this account on March 1st at 3pm?\" Financial systems, healthcare systems, and compliance-heavy domains benefit from this.",[20,4130,4131,4134],{},[42,4132,4133],{},"Temporal queries."," Query the state of any entity at any point in its history without maintaining separate audit tables.",[20,4136,4137,4140],{},[42,4138,4139],{},"Event-driven integration."," Your event log is a natural source of events for other systems. The events that drive state changes also drive integration.",[20,4142,4143,4146],{},[42,4144,4145],{},"Debugging and analysis."," When something goes wrong, the full event history shows exactly what happened. No need to reconstruct state from current data and logs.",[4044,4148,4150],{"id":4149},"what-event-sourcing-actually-costs","What Event Sourcing Actually Costs",[20,4152,4153,4156],{},[42,4154,4155],{},"Eventual consistency is unavoidable."," Projections (the read models derived from the event stream) update asynchronously. If you write an event and immediately query for the current state, you might read stale data. This is inherently part of the model.",[20,4158,4159,4162],{},[42,4160,4161],{},"Schema evolution is genuinely hard."," Events are immutable records of history. When your domain evolves and event schemas change, you need strategies for handling both old and new event formats. Upcasting (transforming old events to new schemas during replay) adds significant complexity.",[20,4164,4165,4168],{},[42,4166,4167],{},"Projection management."," Every read model is a projection from the event log. When you add a new query requirement, you add a new projection — and potentially need to rebuild it from the full event history. If the event log is years old and has millions of events, this can be a significant operational task.",[20,4170,4171,4174,4175,4178],{},[42,4172,4173],{},"No simple queries against the write side."," You can't simply ",[108,4176,4177],{},"SELECT * FROM orders WHERE status = 'pending'",". Current state exists only in projections, not in the event store directly.",[20,4180,4181,4184],{},[42,4182,4183],{},"Snapshots."," For entities with long event histories, replaying thousands of events to reconstitute state is slow. Snapshots — periodic captures of an entity's current state — address this but add another layer of operational complexity.",[30,4186],{},[15,4188,4190],{"id":4189},"when-the-complexity-is-justified","When the Complexity Is Justified",[20,4192,4193],{},"CQRS is justified when your system has:",[95,4195,4196,4199,4202,4205],{},[98,4197,4198],{},"Significantly asymmetric read and write complexity",[98,4200,4201],{},"Multiple read patterns that are hard to serve from a single model",[98,4203,4204],{},"Performance requirements that benefit from optimized, denormalized read stores",[98,4206,4207],{},"Team capacity to manage the eventual consistency and projection complexity",[20,4209,4210],{},"Event sourcing is justified when your system has:",[95,4212,4213,4216,4219],{},[98,4214,4215],{},"Genuine audit and history requirements — not \"nice to have,\" but critical for compliance, financial accuracy, or regulatory reporting",[98,4217,4218],{},"Domains where time-based queries (state at a point in history) are a real requirement",[98,4220,4221],{},"Event-driven integration where the event log is the natural source of truth for downstream systems",[20,4223,4224],{},"Neither pattern is justified when applied speculatively — \"we might need this someday\" — or as a marker of architectural sophistication. The complexity is real and ongoing. The benefits are real only when the problem demands them.",[30,4226],{},[15,4228,4230],{"id":4229},"a-simpler-alternative-for-most-cases","A Simpler Alternative for Most Cases",[20,4232,4233],{},"For systems that need some degree of write/read separation without full CQRS and event sourcing: maintain a separate reporting schema in the same database with denormalized tables maintained by triggers or application-level updates. This achieves much of the query performance benefit with a fraction of the complexity.",[20,4235,4236],{},"For audit requirements without event sourcing: temporal tables (supported in SQL Server and some other databases) maintain a complete history of record changes automatically. Much simpler than event sourcing for most audit needs.",[20,4238,4239],{},"Reach for CQRS and event sourcing when the domain genuinely demands them. For most business applications, a well-designed relational schema with appropriate indexing and clear separation of read and write service logic is sufficient.",[30,4241],{},[20,4243,4244,4245],{},"If you're evaluating whether CQRS or event sourcing is appropriate for your domain, or working through the implementation complexity, ",[223,4246,4248],{"href":225,"rel":4247},[227],"I'm happy to dig into the specifics with you.",[30,4250],{},[15,4252,235],{"id":234},[95,4254,4255,4261,4267,4273],{},[98,4256,4257],{},[223,4258,4260],{"href":4259},"/blog/event-driven-architecture-guide","Event-Driven Architecture: When It's the Right Call",[98,4262,4263],{},[223,4264,4266],{"href":4265},"/blog/how-to-become-a-software-architect","How to Become a Software Architect (A Practitioner's Path)",[98,4268,4269],{},[223,4270,4272],{"href":4271},"/blog/domain-driven-design-guide","Domain-Driven Design in Practice (Without the Theory Overload)",[98,4274,4275],{},[223,4276,4278],{"href":4277},"/blog/microservices-vs-monolith","Microservices vs Monolith: The Honest Trade-off Analysis",{"title":263,"searchDepth":264,"depth":264,"links":4280},[4281,4282,4286,4290,4291,4292],{"id":4017,"depth":267,"text":4018},{"id":4032,"depth":267,"text":4033,"children":4283},[4284,4285],{"id":4046,"depth":264,"text":4047},{"id":4065,"depth":264,"text":4066},{"id":4091,"depth":267,"text":4092,"children":4287},[4288,4289],{"id":4121,"depth":264,"text":4122},{"id":4149,"depth":264,"text":4150},{"id":4189,"depth":267,"text":4190},{"id":4229,"depth":267,"text":4230},{"id":234,"depth":267,"text":235},"Architecture","CQRS and event sourcing solve real problems — but they come with significant complexity that teams routinely underestimate. Here's an honest look at what they do, what they cost, and when to use them.",[4296,4297,4298,4299,4300],"CQRS event sourcing","command query responsibility segregation","event sourcing pattern","CQRS implementation","when to use CQRS",{},"/blog/cqrs-event-sourcing-explained",{"title":4011,"description":4294},"blog/cqrs-event-sourcing-explained",[4306,4307,4308,4309],"CQRS","Event Sourcing","Software Architecture","Domain-Driven Design","NgAeeSEhki1cwmmR-pHQf2aFJf1yOio1O8DrraxW1_U",{"id":4312,"title":2525,"author":4313,"body":4314,"category":1329,"date":275,"description":5522,"extension":277,"featured":278,"image":279,"keywords":5523,"meta":5526,"navigation":284,"path":2524,"readTime":360,"seo":5527,"stem":5528,"tags":5529,"__hash__":5533},"blog/blog/csrf-protection-guide.md",{"name":9,"bio":10},{"type":12,"value":4315,"toc":5513},[4316,4319,4322,4333,4353,4363,4366,4370,4377,4380,4384,4391,4448,4460,4470,4479,4488,4492,4495,4498,4515,4795,4809,4813,4816,4952,4958,4969,5176,5180,5191,5198,5254,5257,5452,5458,5462,5473,5476,5478,5484,5486,5488,5510],[301,4317,2525],{"id":4318},"csrf-protection-understanding-cross-site-request-forgery-and-stopping-it",[20,4320,4321],{},"Cross-site request forgery is one of the better-named vulnerabilities in web security. The name describes exactly what happens: a malicious site makes a request to your application using the victim's credentials, forged to appear as if it came from the victim voluntarily.",[20,4323,4324,4325,4328,4329,4332],{},"Here is the scenario. You are logged into your bank at ",[108,4326,4327],{},"bank.com",". The session cookie is stored in your browser. You visit a malicious website, ",[108,4330,4331],{},"evil.com",". That page contains:",[321,4334,4336],{"className":1638,"code":4335,"language":1640,"meta":263,"style":263},"\u003Cimg src=\"https://bank.com/transfer?to=attacker&amount=5000\">\n",[108,4337,4338],{"__ignoreMap":263},[329,4339,4340,4342,4344,4346,4348,4351],{"class":331,"line":332},[329,4341,1652],{"class":505},[329,4343,3721],{"class":509},[329,4345,3724],{"class":1172},[329,4347,1511],{"class":505},[329,4349,4350],{"class":516},"\"https://bank.com/transfer?to=attacker&amount=5000\"",[329,4352,1666],{"class":505},[20,4354,4355,4356,4359,4360,4362],{},"Your browser tries to load that \"image.\" It sends a GET request to ",[108,4357,4358],{},"bank.com/transfer",", including your session cookie, because the browser always includes cookies for the target domain. If ",[108,4361,4327],{}," processes that transfer on a GET request, the attacker just transferred your money without you doing anything intentional.",[20,4364,4365],{},"Even with POST requests, CSRF is viable using hidden forms that auto-submit via JavaScript.",[15,4367,4369],{"id":4368},"why-csrf-works","Why CSRF Works",[20,4371,4372,4373,4376],{},"The attack exploits That browsers automatically include cookies in requests to a domain, regardless of which page initiated the request. Your bank does not know whether the request for ",[108,4374,4375],{},"/transfer"," came from your banking dashboard or from a malicious page on another domain — both will include your session cookie.",[20,4378,4379],{},"The bank's server authenticates the request (the cookie is valid), sees what looks like a legitimate transfer request, and processes it. There is no way to distinguish a CSRF attack from a legitimate user action at the cookie level alone.",[15,4381,4383],{"id":4382},"the-modern-defense-samesite-cookies","The Modern Defense: SameSite Cookies",[20,4385,4386,4387,4390],{},"Modern browsers support the ",[108,4388,4389],{},"SameSite"," cookie attribute, which controls when browsers include cookies in cross-site requests.",[321,4392,4394],{"className":1390,"code":4393,"language":1392,"meta":263,"style":263},"res.cookie(\"session\", sessionToken, {\n httpOnly: true,\n secure: true,\n sameSite: \"strict\", // or \"lax\"\n});\n",[108,4395,4396,4412,4422,4431,4444],{"__ignoreMap":263},[329,4397,4398,4401,4404,4406,4409],{"class":331,"line":332},[329,4399,4400],{"class":505},"res.",[329,4402,4403],{"class":1172},"cookie",[329,4405,1437],{"class":505},[329,4407,4408],{"class":516},"\"session\"",[329,4410,4411],{"class":505},", sessionToken, {\n",[329,4413,4414,4417,4420],{"class":331,"line":267},[329,4415,4416],{"class":505}," httpOnly: ",[329,4418,4419],{"class":585},"true",[329,4421,1540],{"class":505},[329,4423,4424,4427,4429],{"class":331,"line":264},[329,4425,4426],{"class":505}," secure: ",[329,4428,4419],{"class":585},[329,4430,1540],{"class":505},[329,4432,4433,4436,4439,4441],{"class":331,"line":348},[329,4434,4435],{"class":505}," sameSite: ",[329,4437,4438],{"class":516},"\"strict\"",[329,4440,1486],{"class":505},[329,4442,4443],{"class":620},"// or \"lax\"\n",[329,4445,4446],{"class":331,"line":354},[329,4447,1632],{"class":505},[20,4449,4450,4453,4454,4456,4457,4459],{},[108,4451,4452],{},"sameSite: \"strict\""," — the cookie is never sent in cross-site requests. A request from ",[108,4455,4331],{}," to ",[108,4458,4327],{}," does not include the cookie. This completely prevents CSRF.",[20,4461,4462,4465,4466,4469],{},[108,4463,4464],{},"sameSite: \"lax\""," — the cookie is not sent in cross-site POST requests, form submissions, or requests initiated by page loading (like the ",[108,4467,4468],{},"\u003Cimg>"," example above). It is sent in cross-site GET requests that result from user navigation (clicking a link). This prevents most CSRF while allowing links from other sites to work with the session.",[20,4471,4472,4473,4475,4476,4478],{},"For most applications, ",[108,4474,4464],{}," is the correct default. It prevents CSRF attacks while allowing normal navigation from external sites. ",[108,4477,4452],{}," is appropriate for high-security applications where breaking external links is acceptable.",[20,4480,4481,4484,4485,4487],{},[42,4482,4483],{},"The caveat:"," SameSite cookies depend on browser support and are not universally reliable in all environments. Older browsers do not support ",[108,4486,4389],{},". Some browser extensions, proxy software, and development tools can interfere with SameSite behavior. For applications handling sensitive operations (financial transactions, account changes), SameSite cookies alone are not sufficient — combine them with CSRF tokens.",[15,4489,4491],{"id":4490},"csrf-tokens-the-belt-with-the-suspenders","CSRF Tokens: The Belt with the Suspenders",[20,4493,4494],{},"The CSRF token pattern adds a secret value to every state-changing request that the server generates and validates. An attacker making a cross-site request cannot include the correct CSRF token because they cannot read it from your site (same-origin policy prevents cross-origin JavaScript from reading your cookies or page content).",[20,4496,4497],{},"The flow:",[1796,4499,4500,4503,4506,4509,4512],{},[98,4501,4502],{},"Server generates a unique, cryptographically random token for the session",[98,4504,4505],{},"Token is embedded in every HTML form and provided via a cookie or API endpoint to SPAs",[98,4507,4508],{},"Every POST/PUT/PATCH/DELETE request must include the token in the request body or a header",[98,4510,4511],{},"Server validates the token matches what it issued for this session",[98,4513,4514],{},"If the token is missing or incorrect, the request is rejected",[321,4516,4518],{"className":1390,"code":4517,"language":1392,"meta":263,"style":263},"import { randomBytes, timingSafeEqual } from \"crypto\";\n\n// Generate a CSRF token\nfunction generateCsrfToken(): string {\n return randomBytes(32).toString(\"hex\");\n}\n\n// Store in session\nreq.session.csrfToken = generateCsrfToken();\n\n// Validate incoming token\nfunction validateCsrfToken(req: Request): boolean {\n const sessionToken = req.session.csrfToken;\n const requestToken = req.body._csrf ?? req.headers[\"x-csrf-token\"];\n\n if (!sessionToken || !requestToken) return false;\n\n // Use timing-safe comparison to prevent timing attacks\n const sessionBytes = Buffer.from(sessionToken);\n const requestBytes = Buffer.from(requestToken);\n\n if (sessionBytes.length !== requestBytes.length) return false;\n\n return timingSafeEqual(sessionBytes, requestBytes);\n}\n",[108,4519,4520,4533,4537,4542,4559,4581,4585,4589,4594,4605,4609,4614,4640,4652,4675,4679,4706,4710,4715,4731,4747,4751,4777,4781,4791],{"__ignoreMap":263},[329,4521,4522,4524,4527,4529,4531],{"class":331,"line":332},[329,4523,1399],{"class":1182},[329,4525,4526],{"class":505}," { randomBytes, timingSafeEqual } ",[329,4528,1405],{"class":1182},[329,4530,1408],{"class":516},[329,4532,1411],{"class":505},[329,4534,4535],{"class":331,"line":267},[329,4536,340],{"emptyLinePlaceholder":284},[329,4538,4539],{"class":331,"line":264},[329,4540,4541],{"class":620},"// Generate a CSRF token\n",[329,4543,4544,4546,4549,4552,4554,4557],{"class":331,"line":348},[329,4545,2088],{"class":1182},[329,4547,4548],{"class":1172}," generateCsrfToken",[329,4550,4551],{"class":505},"()",[329,4553,2099],{"class":1182},[329,4555,4556],{"class":585}," string",[329,4558,1503],{"class":505},[329,4560,4561,4563,4565,4567,4570,4572,4574,4576,4579],{"class":331,"line":354},[329,4562,1431],{"class":1182},[329,4564,1434],{"class":1172},[329,4566,1437],{"class":505},[329,4568,4569],{"class":585},"32",[329,4571,1443],{"class":505},[329,4573,1446],{"class":1172},[329,4575,1437],{"class":505},[329,4577,4578],{"class":516},"\"hex\"",[329,4580,1454],{"class":505},[329,4582,4583],{"class":331,"line":360},[329,4584,1459],{"class":505},[329,4586,4587],{"class":331,"line":286},[329,4588,340],{"emptyLinePlaceholder":284},[329,4590,4591],{"class":331,"line":370},[329,4592,4593],{"class":620},"// Store in session\n",[329,4595,4596,4599,4601,4603],{"class":331,"line":375},[329,4597,4598],{"class":505},"req.session.csrfToken ",[329,4600,1511],{"class":1182},[329,4602,4548],{"class":1172},[329,4604,1517],{"class":505},[329,4606,4607],{"class":331,"line":381},[329,4608,340],{"emptyLinePlaceholder":284},[329,4610,4611],{"class":331,"line":387},[329,4612,4613],{"class":620},"// Validate incoming token\n",[329,4615,4616,4618,4621,4623,4625,4627,4630,4633,4635,4638],{"class":331,"line":392},[329,4617,2088],{"class":1182},[329,4619,4620],{"class":1172}," validateCsrfToken",[329,4622,1437],{"class":505},[329,4624,1483],{"class":1482},[329,4626,2099],{"class":1182},[329,4628,4629],{"class":1172}," Request",[329,4631,4632],{"class":505},")",[329,4634,2099],{"class":1182},[329,4636,4637],{"class":585}," boolean",[329,4639,1503],{"class":505},[329,4641,4642,4644,4647,4649],{"class":331,"line":398},[329,4643,1910],{"class":1182},[329,4645,4646],{"class":585}," sessionToken",[329,4648,1916],{"class":1182},[329,4650,4651],{"class":505}," req.session.csrfToken;\n",[329,4653,4654,4656,4659,4661,4664,4667,4670,4673],{"class":331,"line":403},[329,4655,1910],{"class":1182},[329,4657,4658],{"class":585}," requestToken",[329,4660,1916],{"class":1182},[329,4662,4663],{"class":505}," req.body._csrf ",[329,4665,4666],{"class":1182},"??",[329,4668,4669],{"class":505}," req.headers[",[329,4671,4672],{"class":516},"\"x-csrf-token\"",[329,4674,1925],{"class":505},[329,4676,4677],{"class":331,"line":409},[329,4678,340],{"emptyLinePlaceholder":284},[329,4680,4681,4683,4685,4687,4690,4693,4695,4698,4701,4704],{"class":331,"line":415},[329,4682,2425],{"class":1182},[329,4684,2428],{"class":505},[329,4686,2431],{"class":1182},[329,4688,4689],{"class":505},"sessionToken ",[329,4691,4692],{"class":1182},"||",[329,4694,2440],{"class":1182},[329,4696,4697],{"class":505},"requestToken) ",[329,4699,4700],{"class":1182},"return",[329,4702,4703],{"class":585}," false",[329,4705,1411],{"class":505},[329,4707,4708],{"class":331,"line":420},[329,4709,340],{"emptyLinePlaceholder":284},[329,4711,4712],{"class":331,"line":426},[329,4713,4714],{"class":620}," // Use timing-safe comparison to prevent timing attacks\n",[329,4716,4717,4719,4722,4724,4726,4728],{"class":331,"line":887},[329,4718,1910],{"class":1182},[329,4720,4721],{"class":585}," sessionBytes",[329,4723,1916],{"class":1182},[329,4725,2116],{"class":505},[329,4727,1405],{"class":1172},[329,4729,4730],{"class":505},"(sessionToken);\n",[329,4732,4733,4735,4738,4740,4742,4744],{"class":331,"line":895},[329,4734,1910],{"class":1182},[329,4736,4737],{"class":585}," requestBytes",[329,4739,1916],{"class":1182},[329,4741,2116],{"class":505},[329,4743,1405],{"class":1172},[329,4745,4746],{"class":505},"(requestToken);\n",[329,4748,4749],{"class":331,"line":906},[329,4750,340],{"emptyLinePlaceholder":284},[329,4752,4753,4755,4758,4761,4764,4767,4769,4771,4773,4775],{"class":331,"line":914},[329,4754,2425],{"class":1182},[329,4756,4757],{"class":505}," (sessionBytes.",[329,4759,4760],{"class":585},"length",[329,4762,4763],{"class":1182}," !==",[329,4765,4766],{"class":505}," requestBytes.",[329,4768,4760],{"class":585},[329,4770,1497],{"class":505},[329,4772,4700],{"class":1182},[329,4774,4703],{"class":585},[329,4776,1411],{"class":505},[329,4778,4779],{"class":331,"line":923},[329,4780,340],{"emptyLinePlaceholder":284},[329,4782,4783,4785,4788],{"class":331,"line":2282},[329,4784,1431],{"class":1182},[329,4786,4787],{"class":1172}," timingSafeEqual",[329,4789,4790],{"class":505},"(sessionBytes, requestBytes);\n",[329,4792,4793],{"class":331,"line":2290},[329,4794,1459],{"class":505},[20,4796,4797,4798,4801,4802,4805,4806,4808],{},"Using ",[108,4799,4800],{},"timingSafeEqual"," for token comparison is important. A regular ",[108,4803,4804],{},"==="," comparison short-circuits on the first different character, which can reveal information about the token through timing differences. ",[108,4807,4800],{}," always takes the same amount of time regardless of where the comparison fails.",[15,4810,4812],{"id":4811},"server-side-rendered-applications","Server-Side Rendered Applications",[20,4814,4815],{},"For traditional SSR applications that render HTML forms, embed the CSRF token in a hidden form field:",[321,4817,4819],{"className":1638,"code":4818,"language":1640,"meta":263,"style":263},"\u003Cform method=\"POST\" action=\"/transfer\">\n \u003Cinput type=\"hidden\" name=\"_csrf\" value=\"{{ csrfToken }}\">\n \u003Cinput type=\"text\" name=\"recipient\">\n \u003Cinput type=\"number\" name=\"amount\">\n \u003Cbutton type=\"submit\">Transfer\u003C/button>\n\u003C/form>\n",[108,4820,4821,4846,4879,4901,4923,4944],{"__ignoreMap":263},[329,4822,4823,4825,4828,4831,4833,4836,4839,4841,4844],{"class":331,"line":332},[329,4824,1652],{"class":505},[329,4826,4827],{"class":509},"form",[329,4829,4830],{"class":1172}," method",[329,4832,1511],{"class":505},[329,4834,4835],{"class":516},"\"POST\"",[329,4837,4838],{"class":1172}," action",[329,4840,1511],{"class":505},[329,4842,4843],{"class":516},"\"/transfer\"",[329,4845,1666],{"class":505},[329,4847,4848,4851,4854,4856,4858,4861,4864,4866,4869,4872,4874,4877],{"class":331,"line":267},[329,4849,4850],{"class":505}," \u003C",[329,4852,4853],{"class":509},"input",[329,4855,2055],{"class":1172},[329,4857,1511],{"class":505},[329,4859,4860],{"class":516},"\"hidden\"",[329,4862,4863],{"class":1172}," name",[329,4865,1511],{"class":505},[329,4867,4868],{"class":516},"\"_csrf\"",[329,4870,4871],{"class":1172}," value",[329,4873,1511],{"class":505},[329,4875,4876],{"class":516},"\"{{ csrfToken }}\"",[329,4878,1666],{"class":505},[329,4880,4881,4883,4885,4887,4889,4892,4894,4896,4899],{"class":331,"line":264},[329,4882,4850],{"class":505},[329,4884,4853],{"class":509},[329,4886,2055],{"class":1172},[329,4888,1511],{"class":505},[329,4890,4891],{"class":516},"\"text\"",[329,4893,4863],{"class":1172},[329,4895,1511],{"class":505},[329,4897,4898],{"class":516},"\"recipient\"",[329,4900,1666],{"class":505},[329,4902,4903,4905,4907,4909,4911,4914,4916,4918,4921],{"class":331,"line":348},[329,4904,4850],{"class":505},[329,4906,4853],{"class":509},[329,4908,2055],{"class":1172},[329,4910,1511],{"class":505},[329,4912,4913],{"class":516},"\"number\"",[329,4915,4863],{"class":1172},[329,4917,1511],{"class":505},[329,4919,4920],{"class":516},"\"amount\"",[329,4922,1666],{"class":505},[329,4924,4925,4927,4930,4932,4934,4937,4940,4942],{"class":331,"line":354},[329,4926,4850],{"class":505},[329,4928,4929],{"class":509},"button",[329,4931,2055],{"class":1172},[329,4933,1511],{"class":505},[329,4935,4936],{"class":516},"\"submit\"",[329,4938,4939],{"class":505},">Transfer\u003C/",[329,4941,4929],{"class":509},[329,4943,1666],{"class":505},[329,4945,4946,4948,4950],{"class":331,"line":360},[329,4947,1697],{"class":505},[329,4949,4827],{"class":509},[329,4951,1666],{"class":505},[20,4953,4954,4955,4957],{},"The CSRF token in the hidden field is served by your server. ",[108,4956,4331],{}," cannot read this value because cross-origin JavaScript cannot read the DOM of your pages. When the form is submitted, the token is included in the POST body and validated server-side.",[20,4959,4960,4961,4964,4965,4968],{},"Express has ",[108,4962,4963],{},"csurf"," middleware (deprecated) and its successor ",[108,4966,4967],{},"csrf-csrf"," for this:",[321,4970,4972],{"className":1390,"code":4971,"language":1392,"meta":263,"style":263},"import { doubleCsrf } from \"csrf-csrf\";\n\nConst { generateToken, doubleCsrfProtection } = doubleCsrf({\n getSecret: () => process.env.CSRF_SECRET!,\n cookieName: \"__Host-psifi.x-csrf-token\",\n cookieOptions: { secure: true, httpOnly: true },\n});\n\nApp.get(\"/form\", (req, res) => {\n const csrfToken = generateToken(req, res);\n res.render(\"form\", { csrfToken });\n});\n\nApp.use(doubleCsrfProtection);\n\nApp.post(\"/transfer\", (req, res) => {\n // CSRF validated by middleware\n processTransfer(req.body);\n});\n",[108,4973,4974,4988,4992,5004,5024,5034,5049,5053,5057,5084,5099,5114,5118,5122,5131,5135,5159,5164,5172],{"__ignoreMap":263},[329,4975,4976,4978,4981,4983,4986],{"class":331,"line":332},[329,4977,1399],{"class":1182},[329,4979,4980],{"class":505}," { doubleCsrf } ",[329,4982,1405],{"class":1182},[329,4984,4985],{"class":516}," \"csrf-csrf\"",[329,4987,1411],{"class":505},[329,4989,4990],{"class":331,"line":267},[329,4991,340],{"emptyLinePlaceholder":284},[329,4993,4994,4997,4999,5002],{"class":331,"line":264},[329,4995,4996],{"class":505},"Const { generateToken, doubleCsrfProtection } ",[329,4998,1511],{"class":1182},[329,5000,5001],{"class":1172}," doubleCsrf",[329,5003,2222],{"class":505},[329,5005,5006,5009,5012,5014,5017,5020,5022],{"class":331,"line":348},[329,5007,5008],{"class":1172}," getSecret",[329,5010,5011],{"class":505},": () ",[329,5013,1500],{"class":1182},[329,5015,5016],{"class":505}," process.env.",[329,5018,5019],{"class":585},"CSRF_SECRET",[329,5021,2431],{"class":1182},[329,5023,1540],{"class":505},[329,5025,5026,5029,5032],{"class":331,"line":354},[329,5027,5028],{"class":505}," cookieName: ",[329,5030,5031],{"class":516},"\"__Host-psifi.x-csrf-token\"",[329,5033,1540],{"class":505},[329,5035,5036,5039,5041,5044,5046],{"class":331,"line":360},[329,5037,5038],{"class":505}," cookieOptions: { secure: ",[329,5040,4419],{"class":585},[329,5042,5043],{"class":505},", httpOnly: ",[329,5045,4419],{"class":585},[329,5047,5048],{"class":505}," },\n",[329,5050,5051],{"class":331,"line":286},[329,5052,1632],{"class":505},[329,5054,5055],{"class":331,"line":370},[329,5056,340],{"emptyLinePlaceholder":284},[329,5058,5059,5062,5065,5067,5070,5072,5074,5076,5078,5080,5082],{"class":331,"line":375},[329,5060,5061],{"class":505},"App.",[329,5063,5064],{"class":1172},"get",[329,5066,1437],{"class":505},[329,5068,5069],{"class":516},"\"/form\"",[329,5071,2329],{"class":505},[329,5073,1483],{"class":1482},[329,5075,1486],{"class":505},[329,5077,1489],{"class":1482},[329,5079,1497],{"class":505},[329,5081,1500],{"class":1182},[329,5083,1503],{"class":505},[329,5085,5086,5088,5091,5093,5096],{"class":331,"line":381},[329,5087,1910],{"class":1182},[329,5089,5090],{"class":585}," csrfToken",[329,5092,1916],{"class":1182},[329,5094,5095],{"class":1172}," generateToken",[329,5097,5098],{"class":505},"(req, res);\n",[329,5100,5101,5103,5106,5108,5111],{"class":331,"line":387},[329,5102,1526],{"class":505},[329,5104,5105],{"class":1172},"render",[329,5107,1437],{"class":505},[329,5109,5110],{"class":516},"\"form\"",[329,5112,5113],{"class":505},", { csrfToken });\n",[329,5115,5116],{"class":331,"line":392},[329,5117,1632],{"class":505},[329,5119,5120],{"class":331,"line":398},[329,5121,340],{"emptyLinePlaceholder":284},[329,5123,5124,5126,5128],{"class":331,"line":403},[329,5125,5061],{"class":505},[329,5127,1476],{"class":1172},[329,5129,5130],{"class":505},"(doubleCsrfProtection);\n",[329,5132,5133],{"class":331,"line":409},[329,5134,340],{"emptyLinePlaceholder":284},[329,5136,5137,5139,5141,5143,5145,5147,5149,5151,5153,5155,5157],{"class":331,"line":415},[329,5138,5061],{"class":505},[329,5140,1873],{"class":1172},[329,5142,1437],{"class":505},[329,5144,4843],{"class":516},[329,5146,2329],{"class":505},[329,5148,1483],{"class":1482},[329,5150,1486],{"class":505},[329,5152,1489],{"class":1482},[329,5154,1497],{"class":505},[329,5156,1500],{"class":1182},[329,5158,1503],{"class":505},[329,5160,5161],{"class":331,"line":420},[329,5162,5163],{"class":620}," // CSRF validated by middleware\n",[329,5165,5166,5169],{"class":331,"line":426},[329,5167,5168],{"class":1172}," processTransfer",[329,5170,5171],{"class":505},"(req.body);\n",[329,5173,5174],{"class":331,"line":887},[329,5175,1632],{"class":505},[15,5177,5179],{"id":5178},"single-page-applications","Single-Page Applications",[20,5181,5182,5183,5186,5187,5190],{},"For SPAs using JWT or session-based authentication without cookies, CSRF is typically not applicable — the authentication credential is not automatically included in cross-site requests by the browser. JWTs stored in ",[108,5184,5185],{},"localStorage"," and submitted via ",[108,5188,5189],{},"Authorization"," header are not sent automatically by the browser in cross-origin requests.",[20,5192,5193,5194,5197],{},"If your SPA uses cookie-based authentication, add CSRF protection. The pattern for SPAs: the server provides the CSRF token via a separate cookie (readable by JavaScript, not ",[108,5195,5196],{},"httpOnly","):",[321,5199,5201],{"className":1390,"code":5200,"language":1392,"meta":263,"style":263},"// Server sets a readable CSRF cookie\nres.cookie(\"csrf-token\", csrfToken, {\n httpOnly: false, // Must be readable by JavaScript\n secure: true,\n sameSite: \"strict\",\n});\n",[108,5202,5203,5208,5222,5234,5242,5250],{"__ignoreMap":263},[329,5204,5205],{"class":331,"line":332},[329,5206,5207],{"class":620},"// Server sets a readable CSRF cookie\n",[329,5209,5210,5212,5214,5216,5219],{"class":331,"line":267},[329,5211,4400],{"class":505},[329,5213,4403],{"class":1172},[329,5215,1437],{"class":505},[329,5217,5218],{"class":516},"\"csrf-token\"",[329,5220,5221],{"class":505},", csrfToken, {\n",[329,5223,5224,5226,5229,5231],{"class":331,"line":264},[329,5225,4416],{"class":505},[329,5227,5228],{"class":585},"false",[329,5230,1486],{"class":505},[329,5232,5233],{"class":620},"// Must be readable by JavaScript\n",[329,5235,5236,5238,5240],{"class":331,"line":348},[329,5237,4426],{"class":505},[329,5239,4419],{"class":585},[329,5241,1540],{"class":505},[329,5243,5244,5246,5248],{"class":331,"line":354},[329,5245,4435],{"class":505},[329,5247,4438],{"class":516},[329,5249,1540],{"class":505},[329,5251,5252],{"class":331,"line":360},[329,5253,1632],{"class":505},[20,5255,5256],{},"The SPA reads the token from the cookie and includes it in a custom header:",[321,5258,5260],{"className":1390,"code":5259,"language":1392,"meta":263,"style":263},"// Client reads and sends the CSRF token\nfunction getCsrfToken(): string {\n return document.cookie\n .split(\"; \")\n .find((c) => c.startsWith(\"csrf-token=\"))\n ?.split(\"=\")[1] ?? \"\";\n}\n\nFetch(\"/api/transfer\", {\n method: \"POST\",\n headers: {\n \"Content-Type\": \"application/json\",\n \"X-CSRF-Token\": getCsrfToken(),\n },\n credentials: \"include\",\n body: JSON.stringify({ recipient, amount }),\n});\n",[108,5261,5262,5267,5282,5289,5303,5332,5359,5363,5367,5380,5389,5394,5406,5419,5423,5433,5448],{"__ignoreMap":263},[329,5263,5264],{"class":331,"line":332},[329,5265,5266],{"class":620},"// Client reads and sends the CSRF token\n",[329,5268,5269,5271,5274,5276,5278,5280],{"class":331,"line":267},[329,5270,2088],{"class":1182},[329,5272,5273],{"class":1172}," getCsrfToken",[329,5275,4551],{"class":505},[329,5277,2099],{"class":1182},[329,5279,4556],{"class":585},[329,5281,1503],{"class":505},[329,5283,5284,5286],{"class":331,"line":264},[329,5285,1431],{"class":1182},[329,5287,5288],{"class":505}," document.cookie\n",[329,5290,5291,5294,5297,5299,5301],{"class":331,"line":348},[329,5292,5293],{"class":505}," .",[329,5295,5296],{"class":1172},"split",[329,5298,1437],{"class":505},[329,5300,1608],{"class":516},[329,5302,1611],{"class":505},[329,5304,5305,5307,5310,5312,5315,5317,5319,5322,5324,5326,5329],{"class":331,"line":354},[329,5306,5293],{"class":505},[329,5308,5309],{"class":1172},"find",[329,5311,1479],{"class":505},[329,5313,5314],{"class":1482},"c",[329,5316,1497],{"class":505},[329,5318,1500],{"class":1182},[329,5320,5321],{"class":505}," c.",[329,5323,2385],{"class":1172},[329,5325,1437],{"class":505},[329,5327,5328],{"class":516},"\"csrf-token=\"",[329,5330,5331],{"class":505},"))\n",[329,5333,5334,5337,5339,5341,5344,5347,5349,5352,5354,5357],{"class":331,"line":360},[329,5335,5336],{"class":505}," ?.",[329,5338,5296],{"class":1172},[329,5340,1437],{"class":505},[329,5342,5343],{"class":516},"\"=\"",[329,5345,5346],{"class":505},")[",[329,5348,2950],{"class":585},[329,5350,5351],{"class":505},"] ",[329,5353,4666],{"class":1182},[329,5355,5356],{"class":516}," \"\"",[329,5358,1411],{"class":505},[329,5360,5361],{"class":331,"line":286},[329,5362,1459],{"class":505},[329,5364,5365],{"class":331,"line":370},[329,5366,340],{"emptyLinePlaceholder":284},[329,5368,5369,5372,5374,5377],{"class":331,"line":375},[329,5370,5371],{"class":1172},"Fetch",[329,5373,1437],{"class":505},[329,5375,5376],{"class":516},"\"/api/transfer\"",[329,5378,5379],{"class":505},", {\n",[329,5381,5382,5385,5387],{"class":331,"line":381},[329,5383,5384],{"class":505}," method: ",[329,5386,4835],{"class":516},[329,5388,1540],{"class":505},[329,5390,5391],{"class":331,"line":387},[329,5392,5393],{"class":505}," headers: {\n",[329,5395,5396,5399,5401,5404],{"class":331,"line":392},[329,5397,5398],{"class":516}," \"Content-Type\"",[329,5400,513],{"class":505},[329,5402,5403],{"class":516},"\"application/json\"",[329,5405,1540],{"class":505},[329,5407,5408,5411,5413,5416],{"class":331,"line":398},[329,5409,5410],{"class":516}," \"X-CSRF-Token\"",[329,5412,513],{"class":505},[329,5414,5415],{"class":1172},"getCsrfToken",[329,5417,5418],{"class":505},"(),\n",[329,5420,5421],{"class":331,"line":403},[329,5422,5048],{"class":505},[329,5424,5425,5428,5431],{"class":331,"line":409},[329,5426,5427],{"class":505}," credentials: ",[329,5429,5430],{"class":516},"\"include\"",[329,5432,1540],{"class":505},[329,5434,5435,5438,5441,5443,5445],{"class":331,"line":415},[329,5436,5437],{"class":505}," body: ",[329,5439,5440],{"class":585},"JSON",[329,5442,229],{"class":505},[329,5444,1684],{"class":1172},[329,5446,5447],{"class":505},"({ recipient, amount }),\n",[329,5449,5450],{"class":331,"line":420},[329,5451,1632],{"class":505},[20,5453,5454,5455,5457],{},"A cross-site request from ",[108,5456,4331],{}," cannot read the CSRF cookie (same-origin policy for cookie access) and therefore cannot include the correct token in the request header.",[15,5459,5461],{"id":5460},"what-does-not-protect-against-csrf","What Does Not Protect Against CSRF",[20,5463,5464,5465,5468,5469,5472],{},"SameSite cookies on their own (legacy browser support gap), checking the ",[108,5466,5467],{},"Content-Type"," header (can be spoofed with some techniques), checking the ",[108,5470,5471],{},"Referer"," header (can be absent, can be spoofed, some privacy tools strip it), and basic authentication (browser auto-includes credentials).",[20,5474,5475],{},"The correct protection is SameSite cookies combined with CSRF tokens for any application handling sensitive operations. Belt and suspenders.",[30,5477],{},[20,5479,5480,5481,229],{},"If you want help implementing CSRF protection in your application or want to audit your existing protection for gaps, book a session at ",[223,5482,225],{"href":225,"rel":5483},[227],[30,5485],{},[15,5487,235],{"id":234},[95,5489,5490,5496,5502,5506],{},[98,5491,5492],{},[223,5493,5495],{"href":5494},"/blog/xss-prevention-guide","XSS Prevention: Cross-Site Scripting Still Kills and Here's What to Do About It",[98,5497,5498],{},[223,5499,5501],{"href":5500},"/blog/owasp-top-10-explained","OWASP Top 10 Explained: What Developers Actually Need to Understand",[98,5503,5504],{},[223,5505,1333],{"href":2550},[98,5507,5508],{},[223,5509,2513],{"href":2512},[1301,5511,5512],{},"html pre.shiki code .s95oV, html code.shiki .s95oV{--shiki-default:#E1E4E8}html pre.shiki code .s4JwU, html code.shiki .s4JwU{--shiki-default:#85E89D}html pre.shiki code .svObZ, html code.shiki .svObZ{--shiki-default:#B392F0}html pre.shiki code .sU2Wk, html code.shiki .sU2Wk{--shiki-default:#9ECBFF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html pre.shiki code .sDLfK, html code.shiki .sDLfK{--shiki-default:#79B8FF}html pre.shiki code .sAwPA, html code.shiki .sAwPA{--shiki-default:#6A737D}html pre.shiki code .snl16, html code.shiki .snl16{--shiki-default:#F97583}html pre.shiki code .s9osk, html code.shiki .s9osk{--shiki-default:#FFAB70}",{"title":263,"searchDepth":264,"depth":264,"links":5514},[5515,5516,5517,5518,5519,5520,5521],{"id":4368,"depth":267,"text":4369},{"id":4382,"depth":267,"text":4383},{"id":4490,"depth":267,"text":4491},{"id":4811,"depth":267,"text":4812},{"id":5178,"depth":267,"text":5179},{"id":5460,"depth":267,"text":5461},{"id":234,"depth":267,"text":235},"How CSRF attacks work, why SameSite cookies are not always sufficient, and the correct implementation of CSRF tokens for forms and single-page applications.",[5524,5525],"CSRF protection","web security",{},{"title":2525,"description":5522},"blog/csrf-protection-guide",[5530,5531,1329,5532],"CSRF","Web Security","Forms","UJiDUfgozmAftmmrSjyC1ywyTmFMNBAxYh57BuQLpIQ",{"id":5535,"title":5536,"author":5537,"body":5538,"category":274,"date":275,"description":5778,"extension":277,"featured":278,"image":279,"keywords":5779,"meta":5782,"navigation":284,"path":5783,"readTime":381,"seo":5784,"stem":5785,"tags":5786,"__hash__":5791},"blog/blog/custom-crm-development.md","Custom CRM Development: When Building Beats Buying Salesforce",{"name":9,"bio":10},{"type":12,"value":5539,"toc":5768},[5540,5544,5547,5550,5553,5557,5563,5569,5575,5581,5587,5591,5594,5597,5603,5609,5615,5621,5627,5633,5639,5643,5646,5652,5658,5664,5670,5674,5677,5683,5689,5695,5701,5707,5711,5714,5717,5720,5723,5727,5730,5738,5740,5742],[15,5541,5543],{"id":5542},"the-salesforce-question","The Salesforce Question",[20,5545,5546],{},"Almost every mid-market business I talk to is either on Salesforce, considering Salesforce, or recently left Salesforce. The platform dominates the CRM market for understandable reasons: it's comprehensive, it has a massive ecosystem, and it can do almost anything if you're willing to pay for the customization.",[20,5548,5549],{},"That last clause is the problem. \"Can do almost anything\" in Salesforce means Apex code, custom objects, complex SOQL queries, and a Salesforce-certified developer who costs $150-200/hour and bills in 10-hour increments. By the time you've customized Salesforce to match your actual sales process, you've spent enough to build something custom — and you're locked into Salesforce's licensing, Salesforce's infrastructure, and Salesforce's annual price increases.",[20,5551,5552],{},"Custom CRM development is not the right answer for every business. But for a specific set of situations, it delivers dramatically better results at dramatically lower total cost. Here's how to identify whether you're in that group.",[15,5554,5556],{"id":5555},"when-custom-crm-development-makes-sense","When Custom CRM Development Makes Sense",[20,5558,5559,5562],{},[42,5560,5561],{},"Your sales process is genuinely differentiated."," Most CRMs are designed around a relatively standard B2B sales workflow: lead, opportunity, quote, close. If your process fits this model — even if your terminology differs — you can almost certainly configure an off-the-shelf CRM. If your sales process is fundamentally different — complex multi-party approvals, project-scoped engagements, product configurations with deeply interdependent constraints — off-the-shelf platforms force you to either customize heavily or live with a broken workflow.",[20,5564,5565,5568],{},[42,5566,5567],{},"You need deep integration with proprietary systems."," If your CRM needs to integrate with a custom ERP, a proprietary pricing engine, a unique scheduling system, or any system where the integration is complex enough that it requires custom development regardless — you're already in custom territory. At that point, the question is whether you want your custom development to be constrained by Salesforce's architecture or free to be designed correctly.",[20,5570,5571,5574],{},[42,5572,5573],{},"User count economics favor custom."," Salesforce Enterprise runs $150-300 per user per month. For a 30-person sales team, that's $54K-$108K per year, before customization, before integration, before admin overhead. A custom CRM built for that team might cost $80K-$150K to build and $15K-$25K/year to maintain. By year three, the custom build is often cheaper, and it doesn't get repriced annually.",[20,5576,5577,5580],{},[42,5578,5579],{},"You have data ownership or compliance requirements."," Healthcare companies handling patient relationship data. Financial advisors with strict SEC recordkeeping requirements. Government contractors with data sovereignty requirements. In these cases, self-hosting a custom CRM gives you control that SaaS platforms can't match.",[20,5582,5583,5586],{},[42,5584,5585],{},"Your team tried off-the-shelf and couldn't make it work."," If you've been through one or two CRM implementations that failed — not for technical reasons, but because the system didn't match the way your team actually works — that's diagnostic. The issue isn't that your team won't use a CRM; it's that the systems you tried imposed a workflow that wasn't yours.",[15,5588,5590],{"id":5589},"what-a-custom-crm-actually-needs-to-include","What a Custom CRM Actually Needs to Include",[20,5592,5593],{},"One mistake businesses make when considering a custom CRM is underscoping it. \"We just need a place to track our contacts and deals\" sounds simple and leads to building something that misses half the value.",[20,5595,5596],{},"A functional CRM needs:",[20,5598,5599,5602],{},[42,5600,5601],{},"Contact and account management."," This is the obvious one. But think carefully about the data model. What defines a \"contact\" for your business? What's the relationship between contacts and accounts? For B2B, there's usually a company (account) with multiple contacts. For B2B2C, you might have accounts, contacts, and end-user relationships. Get the data model right; it's hard to change later.",[20,5604,5605,5608],{},[42,5606,5607],{},"Deal and pipeline tracking."," Every deal should have a stage, an owner, expected value, expected close date, and a history of activity. The pipeline view — seeing all deals by stage — is one of the highest-value features in any CRM and surprisingly hard to display well.",[20,5610,5611,5614],{},[42,5612,5613],{},"Activity logging."," Calls, emails, meetings, notes — all associated with the relevant contact and deal. The history should be easy to log (friction-free logging means it actually gets used) and easy to review (the full picture of a relationship in one place).",[20,5616,5617,5620],{},[42,5618,5619],{},"Task and reminder management."," What needs to happen next for each deal? When is the follow-up scheduled? Who is responsible? Without task management, deals stall because nobody is accountable for the next action.",[20,5622,5623,5626],{},[42,5624,5625],{},"Email integration."," This is table stakes. If reps have to manually log every email, they won't log emails. Bi-directional email sync — where emails sent from and received by contacts are automatically attached to the contact record — is worth the engineering investment.",[20,5628,5629,5632],{},[42,5630,5631],{},"Reporting and dashboards."," Pipeline by stage, deals by rep, conversion rates, average deal size, close rates by source — these metrics are why you have a CRM. Build the reporting layer thoughtfully; it's one of the most-used features.",[20,5634,5635,5638],{},[42,5636,5637],{},"Search."," Global search across contacts, accounts, and deals. Simple requirement, critical to daily use, often underinvested in custom builds.",[15,5640,5642],{"id":5641},"the-features-that-separate-good-from-great-custom-crms","The Features That Separate Good From Great Custom CRMs",[20,5644,5645],{},"These features aren't always in scope for a first version but are worth planning for from the start:",[20,5647,5648,5651],{},[42,5649,5650],{},"Workflow automation."," When a deal moves to \"Proposal Sent,\" automatically create a follow-up task for seven days later. When a deal goes past expected close date, send a Slack notification to the manager. Workflow automation doesn't require a Zapier integration if you design it into the CRM.",[20,5653,5654,5657],{},[42,5655,5656],{},"Role-based visibility."," Reps see their deals. Managers see their team's deals. Executives see everything. This isn't just a feature — it drives adoption because reps feel their pipeline is private, not under surveillance.",[20,5659,5660,5663],{},[42,5661,5662],{},"Mobile experience."," If your sales team is in the field, a mobile-optimized CRM that works well on a phone is not optional. Custom builds often neglect mobile; plan for it from the beginning or it won't get done.",[20,5665,5666,5669],{},[42,5667,5668],{},"Activity analytics."," How many calls did each rep make this week? How many emails? What's the ratio of activity to deals closed? Behavioral metrics separate activity from outcomes and help managers coach.",[15,5671,5673],{"id":5672},"the-technical-approach","The Technical Approach",[20,5675,5676],{},"A custom CRM is a relatively well-understood problem from an engineering standpoint. Here's the stack I'd choose today for a business in the 20-100 user range:",[20,5678,5679,5682],{},[42,5680,5681],{},"Backend:"," Node.js with a framework like Hono or Express, backed by PostgreSQL. The data model is relational by nature — contacts, accounts, deals, activities all have clear relationships. PostgreSQL's full-text search capabilities cover the search requirement without a separate search infrastructure. Prisma or similar ORM for type-safe database access.",[20,5684,5685,5688],{},[42,5686,5687],{},"API:"," REST for the main application, WebSockets for real-time features like dashboard updates and notifications.",[20,5690,5691,5694],{},[42,5692,5693],{},"Frontend:"," A modern JavaScript framework with a component library. The UI patterns for a CRM are well-established — data tables, kanban boards, form panels, timeline views. Choose a component library that covers these patterns rather than building from scratch.",[20,5696,5697,5700],{},[42,5698,5699],{},"Email integration:"," Microsoft Graph API for Office 365 users, Gmail API for Google Workspace. Both have good documentation and allow reading and sending emails from the CRM with the user's credentials.",[20,5702,5703,5706],{},[42,5704,5705],{},"Search:"," PostgreSQL full-text search handles most CRM search requirements without additional infrastructure. If you need more sophisticated search (fuzzy matching, relevance ranking), add Meilisearch or a similar embedded search engine.",[15,5708,5710],{"id":5709},"timeline-and-budget-expectations","Timeline and Budget Expectations",[20,5712,5713],{},"A functional custom CRM for a 20-50 person sales team, including the features described above, typically takes 3-4 months to build with an experienced team and costs in the $80K-$150K range depending on complexity.",[20,5715,5716],{},"That's the real number, not a low estimate designed to win a proposal. Underestimating scope is the most common problem in custom software projects, and it's especially acute for CRM builds because the feature surface area expands when you get into the details.",[20,5718,5719],{},"Phase the delivery. A first version with contact management, pipeline, activity logging, and basic reporting gets you most of the value and ships in 6-8 weeks. Workflow automation, advanced reporting, and mobile optimization come in subsequent phases.",[20,5721,5722],{},"Budget for ongoing maintenance and enhancement. A custom system needs someone to maintain it — fixing issues, updating dependencies, adding features as the business evolves. Plan for $2K-$5K/month in ongoing support once the initial build is complete.",[15,5724,5726],{"id":5725},"the-right-question-to-ask","The Right Question to Ask",[20,5728,5729],{},"Before deciding on custom vs. Off-the-shelf, define what your CRM needs to do that off-the-shelf can't. If the list is short, configure what's available. If the list is long — or if the core workflow is fundamentally different from what standard platforms support — you have a strong case for building.",[20,5731,5732,5733,5737],{},"If you want to walk through your specific requirements and get an honest assessment of whether custom or off-the-shelf makes more sense for your business, ",[223,5734,5736],{"href":225,"rel":5735},[227],"schedule a call at calendly.com/jamesrossjr",". I'll give you a straight answer.",[30,5739],{},[15,5741,235],{"id":234},[95,5743,5744,5750,5756,5762],{},[98,5745,5746],{},[223,5747,5749],{"href":5748},"/blog/low-code-vs-custom-development","Low-Code vs Custom Development: When Each Actually Makes Sense",[98,5751,5752],{},[223,5753,5755],{"href":5754},"/blog/custom-erp-development-guide","Custom ERP Development: What It Actually Takes",[98,5757,5758],{},[223,5759,5761],{"href":5760},"/blog/build-vs-buy-enterprise-software","Build vs Buy Enterprise Software: A Framework for the Decision",[98,5763,5764],{},[223,5765,5767],{"href":5766},"/blog/api-first-architecture","API-First Architecture: Building Software That Integrates by Default",{"title":263,"searchDepth":264,"depth":264,"links":5769},[5770,5771,5772,5773,5774,5775,5776,5777],{"id":5542,"depth":267,"text":5543},{"id":5555,"depth":267,"text":5556},{"id":5589,"depth":267,"text":5590},{"id":5641,"depth":267,"text":5642},{"id":5672,"depth":267,"text":5673},{"id":5709,"depth":267,"text":5710},{"id":5725,"depth":267,"text":5726},{"id":234,"depth":267,"text":235},"Salesforce and HubSpot are powerful, but they're not right for every business. Here's when custom CRM development delivers better ROI and how to approach building one.",[5780,5781],"custom CRM development","custom enterprise software",{},"/blog/custom-crm-development",{"title":5536,"description":5778},"blog/custom-crm-development",[5787,5788,5789,5790,4293],"CRM","Custom Development","Enterprise Software","Sales Technology","ll8eGFRzw1LLE72nrpWYN4uMA9kPBvAPhVZ9UU5MvUs",{"id":5793,"title":5755,"author":5794,"body":5795,"category":274,"date":275,"description":6146,"extension":277,"featured":278,"image":279,"keywords":6147,"meta":6153,"navigation":284,"path":5754,"readTime":387,"seo":6154,"stem":6155,"tags":6156,"__hash__":6160},"blog/blog/custom-erp-development-guide.md",{"name":9,"bio":10},{"type":12,"value":5796,"toc":6129},[5797,5801,5804,5807,5810,5812,5816,5819,5822,5830,5832,5836,5839,5845,5851,5857,5863,5865,5869,5872,5886,5893,5904,5906,5910,5913,5917,5920,5923,5926,5930,5933,5936,5962,5966,5969,5972,5978,5984,5990,5994,5997,6000,6003,6007,6010,6013,6015,6019,6022,6048,6051,6053,6057,6060,6066,6072,6078,6084,6086,6090,6093,6096,6103,6105,6107],[15,5798,5800],{"id":5799},"the-problem-with-just-use-sap","The Problem With \"Just Use SAP\"",[20,5802,5803],{},"Every conversation about ERP software eventually lands on the same suggestion: use NetSuite, use SAP, use Dynamics. These are mature products with decades of development behind them. For many businesses, they're the right answer.",[20,5805,5806],{},"But not for all businesses. And the ones where they're wrong tend to find out in the most painful ways: after the implementation, after the integrations are built, after the org chart has been reorganized around the software's workflow — and the system still doesn't match how the business actually operates.",[20,5808,5809],{},"Custom ERP development is not for everyone. But when it's the right call, it's transformative. This is an honest look at when to build, when to buy, and what building actually involves.",[30,5811],{},[15,5813,5815],{"id":5814},"what-is-erp-actually","What Is ERP, Actually?",[20,5817,5818],{},"Enterprise Resource Planning (ERP) is a category of software that integrates core business functions — inventory, procurement, financials, HR, production, customer management — into a single system with a shared data model. The defining characteristic is integration: instead of five separate databases with five separate export/import jobs keeping them approximately in sync, an ERP gives you one source of truth.",[20,5820,5821],{},"The value proposition is straightforward: when your sales team books an order, inventory is automatically allocated, production is scheduled, financials are updated, and fulfillment is triggered — without anyone copying data between systems or manually triggering downstream processes. The business operates as a system rather than a collection of departments.",[20,5823,5824,5825,5829],{},"The problem is that implementing this integration in a way that reflects how a ",[5826,5827,5828],"em",{},"specific business"," actually operates is genuinely hard. And off-the-shelf ERP systems were built around a generalized model of how businesses work — which is close to how your business works, but not identical. The delta between \"how the software thinks businesses work\" and \"how our business actually works\" is where implementation projects go to die.",[30,5831],{},[15,5833,5835],{"id":5834},"when-off-the-shelf-erp-fails","When Off-the-Shelf ERP Fails",[20,5837,5838],{},"The failure modes I see most often:",[20,5840,5841,5844],{},[42,5842,5843],{},"Your process is your competitive advantage."," If the way you manage inventory, schedule production, or handle customer relationships is the thing that makes you better than competitors, you don't want software that flattens that into a generic workflow. Off-the-shelf ERP is optimized for the median business in your industry. If you're the median, it fits. If your differentiation comes from process, you're either going to fight the software every day or change your process to match the software — which means changing the thing that makes you competitive.",[20,5846,5847,5850],{},[42,5848,5849],{},"Your industry has requirements the general ERP doesn't understand."," Specialty manufacturing, regulated industries, multi-entity operations with complex intercompany transactions, businesses that operate in markets the major ERP vendors don't focus on. When the software doesn't understand your industry, you end up customizing it so heavily that you're effectively maintaining a fork of a commercial product — which combines the cost of custom development with the cost of keeping up with vendor updates.",[20,5852,5853,5856],{},[42,5854,5855],{},"The integration complexity is the problem."," Sometimes businesses have legacy systems, partner APIs, or proprietary hardware that needs to talk to the ERP. When the integration surface is large and complex, the \"buy a standard product and integrate it\" approach can be more expensive than building something that was designed for your integrations from the start.",[20,5858,5859,5862],{},[42,5860,5861],{},"The user count and usage pattern don't justify the license cost."," Enterprise ERP licenses are priced for enterprises. If you're a 30-person company that genuinely needs ERP-level integration, a custom-built system at $200K is often cheaper over a five-year horizon than a commercial license that costs $80K/year.",[30,5864],{},[15,5866,5868],{"id":5867},"when-custom-erp-development-is-the-right-call","When Custom ERP Development Is the Right Call",[20,5870,5871],{},"Custom ERP development makes sense when at least two of these are true:",[1796,5873,5874,5877,5880,5883],{},[98,5875,5876],{},"Your business processes are genuinely differentiated from industry norms",[98,5878,5879],{},"The five-year total cost of custom development is lower than the five-year total cost of the best commercial alternative (including implementation, licensing, and ongoing customization)",[98,5881,5882],{},"You have a technical partner who can build and maintain the system reliably",[98,5884,5885],{},"The commercial alternatives require significant process change that would damage your competitive position",[20,5887,5888,5889,5892],{},"It does ",[5826,5890,5891],{},"not"," make sense when:",[95,5894,5895,5898,5901],{},[98,5896,5897],{},"Your business is new and you haven't yet validated what your processes should be",[98,5899,5900],{},"You don't have a reliable technical partner and are planning to hire one team to build and another to maintain",[98,5902,5903],{},"Your differentiation is in your product or sales, not your operations — in which case a well-implemented standard ERP is fine and custom ERP is a distraction",[30,5905],{},[15,5907,5909],{"id":5908},"what-custom-erp-development-actually-involves","What Custom ERP Development Actually Involves",[20,5911,5912],{},"If you decide to build, here's what the process looks like when done well.",[4044,5914,5916],{"id":5915},"phase-1-requirements-and-domain-modeling","Phase 1: Requirements and Domain Modeling",[20,5918,5919],{},"The most important phase, the most commonly rushed. This is where you map the actual business processes — not as they're documented in the procedure manual, but as they actually occur. Who does what, when, in response to what trigger, producing what output that flows into the next process.",[20,5921,5922],{},"The output is a domain model: the core entities (Order, Product, Customer, Supplier, WorkOrder, Invoice, etc.), their relationships, their lifecycle states, and the events that transition them from state to state. This model is the foundation everything else builds on. Getting it wrong in Phase 1 means rework in every subsequent phase.",[20,5924,5925],{},"A good domain modeling process involves the people who actually do the work, not just the people who manage them. The accountant who manually reconciles two reports every Friday knows something your CFO doesn't.",[4044,5927,5929],{"id":5928},"phase-2-data-architecture","Phase 2: Data Architecture",[20,5931,5932],{},"With the domain model in hand, you design the data layer. For ERP, this almost always means a relational database — the integrity constraints and transaction support that PostgreSQL or SQL Server provide are not optional when financial data is involved.",[20,5934,5935],{},"The data architecture decisions that matter most in ERP:",[95,5937,5938,5944,5950,5956],{},[98,5939,5940,5943],{},[42,5941,5942],{},"Multi-tenancy",": If you're building for multiple entities or divisions, tenant isolation at the data layer prevents the class of bugs where one entity's data contaminates another's.",[98,5945,5946,5949],{},[42,5947,5948],{},"Audit logging",": Every state change in an ERP should be logged immutably — who changed what, from what value, to what value, at what time, with what authorization.",[98,5951,5952,5955],{},[42,5953,5954],{},"Soft deletes",": In ERP, you almost never actually delete records. Orders are cancelled, not deleted. Invoices are credited, not removed. Deleting data that has financial implications creates compliance risk.",[98,5957,5958,5961],{},[42,5959,5960],{},"Reference data management",": Products, suppliers, GL accounts, cost centers — these are shared across modules and need to be managed as a first-class concern.",[4044,5963,5965],{"id":5964},"phase-3-api-and-business-logic-layer","Phase 3: API and Business Logic Layer",[20,5967,5968],{},"This is where the ERP's rules live. Business logic in ERP is particularly complex because it's stateful, has intricate dependencies, and has to handle failure gracefully.",[20,5970,5971],{},"Some of the patterns that matter here:",[20,5973,5974,5977],{},[42,5975,5976],{},"Domain events."," When an order is confirmed, what happens? Inventory is reserved. A purchase order may be triggered if supply is short. A production job may be created. The fulfillment queue is updated. These are not UI side effects — they're business consequences that need to be executed reliably even if the user's browser crashes mid-operation. Domain events, with a reliable queue behind them, give you this.",[20,5979,5980,5983],{},[42,5981,5982],{},"Saga pattern for long-running transactions."," Some ERP operations span multiple steps across time — a production order that moves through multiple stages over days or weeks. A saga coordinates these steps, handles failures at each step, and provides compensating transactions when something goes wrong mid-process.",[20,5985,5986,5989],{},[42,5987,5988],{},"Strict validation at the boundary."," The API layer enforces business rules before data gets to the database. Negative inventory quantities don't make it to the database. GL entries without a balancing debit don't make it to the database. This validation is where the business rules live as code, and it's the layer that prevents the database from becoming corrupted by edge-case inputs.",[4044,5991,5993],{"id":5992},"phase-4-frontend-and-workflow-ui","Phase 4: Frontend and Workflow UI",[20,5995,5996],{},"ERP frontends have a bad reputation for good reason — they're usually built by backend engineers who are optimizing for data completeness rather than workflow efficiency. The result is forms with fifty fields, tabs that require three clicks to navigate, and search interfaces that return 10,000 results when you type a partial product name.",[20,5998,5999],{},"Good ERP UI design starts from workflows, not from data models. What is the user trying to accomplish in this session? What is the context they need to do it? What are the three most common next actions from here? The UI should surface the answers to those questions, not expose the underlying data structure.",[20,6001,6002],{},"For warehouse staff, this might mean a mobile-optimized interface where the entire pick-and-pack process is three button presses. For finance, it might mean a dashboard that shows exactly the reconciliation items that need attention today, with one-click drill-down to the supporting transactions.",[4044,6004,6006],{"id":6005},"phase-5-integrations","Phase 5: Integrations",[20,6008,6009],{},"ERP systems never live in isolation. Bank feeds, shipping carriers, e-commerce platforms, payment processors, EDI partners, tax authorities — the integration list grows with the business.",[20,6011,6012],{},"The architectural choice that matters here: build your ERP with a well-defined integration layer from the start. Integrations that directly read from and write to the core database become impossible to maintain as the schema evolves. An integration layer with a stable API contract between the ERP core and the external world means that adding a new integration doesn't require understanding the entire data model.",[30,6014],{},[15,6016,6018],{"id":6017},"the-realistic-cost-and-timeline","The Realistic Cost and Timeline",[20,6020,6021],{},"Custom ERP development for a mid-size business (50–500 employees, 3–8 core modules) typically looks like:",[95,6023,6024,6030,6036,6042],{},[98,6025,6026,6029],{},[42,6027,6028],{},"Scope",": 9–18 months from requirements to initial production rollout",[98,6031,6032,6035],{},[42,6033,6034],{},"Cost",": $150K–$500K for initial build, depending on complexity and scope",[98,6037,6038,6041],{},[42,6039,6040],{},"Team",": 2–4 engineers, a product owner who can represent business requirements, and an architect driving structural decisions",[98,6043,6044,6047],{},[42,6045,6046],{},"Ongoing",": Budget for 1–2 engineers at ~20 hours/week for maintenance and evolution",[20,6049,6050],{},"The companies that blow these numbers are almost always the ones that underfunded Phase 1. Vague requirements lead to rework. Rework doubles timelines and triples costs.",[30,6052],{},[15,6054,6056],{"id":6055},"choosing-a-custom-erp-development-company","Choosing a Custom ERP Development Company",[20,6058,6059],{},"When evaluating partners for custom ERP development, the questions that matter:",[20,6061,6062,6065],{},[42,6063,6064],{},"Do they have ERP-specific experience?"," Building an ERP is different from building a web app. Financial data integrity, audit trails, complex business rules, high-stakes data migrations — these require specific experience. Ask for examples of ERP systems they've built and get on calls with reference clients.",[20,6067,6068,6071],{},[42,6069,6070],{},"Do they own the domain modeling process?"," The companies that produce good ERP systems treat requirements gathering and domain modeling as first-class work, not as a quick kickoff meeting before the developers start writing code. If their process doesn't include significant upfront modeling, the delivered system will reflect that.",[20,6073,6074,6077],{},[42,6075,6076],{},"What does maintenance look like?"," ERP is a long-term relationship. Businesses change. The software has to change with them. Understand how the partner handles post-launch development, and whether they're building in a way that makes future development tractable.",[20,6079,6080,6083],{},[42,6081,6082],{},"How do they handle data migration?"," If you're replacing existing systems, the migration of historical data into the new system is one of the highest-risk parts of the project. Partners who treat this as an afterthought are telling you something.",[30,6085],{},[15,6087,6089],{"id":6088},"the-bottom-line","The Bottom Line",[20,6091,6092],{},"Custom ERP development is the right investment when your business processes are genuinely different enough from the norm that standard products require you to either pay for extensive customization or change how your business operates. When the fit is right, a well-built custom ERP becomes a competitive moat — software that exactly reflects how you operate and can be evolved as your operations evolve.",[20,6094,6095],{},"When the fit isn't there — when your processes are standard, when your technical partner isn't reliable, when the timeline for custom development conflicts with an urgent business need — buy before you build.",[20,6097,6098,6099],{},"The decision deserves more than a cost comparison spreadsheet. If you're working through it, ",[223,6100,6102],{"href":225,"rel":6101},[227],"I'm happy to talk through the specifics of your situation.",[30,6104],{},[15,6106,235],{"id":234},[95,6108,6109,6113,6119,6123],{},[98,6110,6111],{},[223,6112,5749],{"href":5748},[98,6114,6115],{},[223,6116,6118],{"href":6117},"/blog/erp-implementation-guide","ERP Implementation Guide: What to Do Before You Go Live",[98,6120,6121],{},[223,6122,5536],{"href":5783},[98,6124,6125],{},[223,6126,6128],{"href":6127},"/blog/erp-vs-crm-differences","ERP vs CRM: What's the Difference and Which Do You Actually Need?",{"title":263,"searchDepth":264,"depth":264,"links":6130},[6131,6132,6133,6134,6135,6142,6143,6144,6145],{"id":5799,"depth":267,"text":5800},{"id":5814,"depth":267,"text":5815},{"id":5834,"depth":267,"text":5835},{"id":5867,"depth":267,"text":5868},{"id":5908,"depth":267,"text":5909,"children":6136},[6137,6138,6139,6140,6141],{"id":5915,"depth":264,"text":5916},{"id":5928,"depth":264,"text":5929},{"id":5964,"depth":264,"text":5965},{"id":5992,"depth":264,"text":5993},{"id":6005,"depth":264,"text":6006},{"id":6017,"depth":267,"text":6018},{"id":6055,"depth":267,"text":6056},{"id":6088,"depth":267,"text":6089},{"id":234,"depth":267,"text":235},"Off-the-shelf ERP systems promise everything and deliver compromises. Here's an honest look at custom ERP development — when it makes sense, what it costs, and how to do it without destroying your organization in the process.",[6148,6149,6150,6151,6152],"custom erp development","custom erp development company","custom erp development services","enterprise software development","custom enterprise software development",{},{"title":5755,"description":6146},"blog/custom-erp-development-guide",[6157,5789,5788,6158,6159],"ERP","Systems Architecture","Business Software","i1UXVutjsqjbvA0Kc0wuZ0NFm7X-7uf3OxfyvpCiSEg",{"id":6162,"title":6163,"author":6164,"body":6165,"category":274,"date":275,"description":6462,"extension":277,"featured":278,"image":279,"keywords":6463,"meta":6465,"navigation":284,"path":6466,"readTime":381,"seo":6467,"stem":6468,"tags":6469,"__hash__":6473},"blog/blog/custom-inventory-management-system.md","Custom Inventory Management Systems: What They Can Do That Off-the-Shelf Can't",{"name":9,"bio":10},{"type":12,"value":6166,"toc":6451},[6167,6171,6174,6177,6180,6184,6187,6190,6210,6213,6217,6220,6226,6232,6238,6244,6250,6256,6260,6263,6266,6272,6278,6284,6290,6296,6302,6306,6309,6314,6334,6339,6356,6361,6372,6376,6379,6382,6388,6394,6397,6401,6404,6407,6410,6414,6417,6420,6427,6429,6431],[15,6168,6170],{"id":6169},"the-inventory-software-gap","The Inventory Software Gap",[20,6172,6173],{},"There's a gap in the inventory software market that doesn't get discussed enough. On one end, you have simple tools like Fishbowl or inFlow — adequate for businesses with relatively standard inventory workflows. On the other end, you have Warehouse Management Systems built for large-scale distribution operations costing $200K+ to implement.",[20,6175,6176],{},"In the middle are thousands of businesses with inventory operations more complex than the simple tools can handle but not complex enough to justify (or afford) enterprise WMS implementations. They end up either over-paying for WMS software they use at 30% of capacity, or under-serving themselves with inventory tools that require constant manual workarounds.",[20,6178,6179],{},"This is where custom inventory management systems earn their place.",[15,6181,6183],{"id":6182},"what-off-the-shelf-systems-handle-well","What Off-the-Shelf Systems Handle Well",[20,6185,6186],{},"Before making the case for custom, it's worth being honest about what generic systems handle well — because if they meet your needs, they're the right choice.",[20,6188,6189],{},"Standard inventory software does well with:",[95,6191,6192,6195,6198,6201,6204,6207],{},[98,6193,6194],{},"Basic stock tracking across one or a few warehouse locations",[98,6196,6197],{},"Standard purchase order workflows (create PO, receive inventory, match to invoice)",[98,6199,6200],{},"Standard FIFO/LIFO cost accounting",[98,6202,6203],{},"Basic reorder point management",[98,6205,6206],{},"Simple product catalog management",[98,6208,6209],{},"Sales order fulfillment for standard pick-pack-ship operations",[20,6211,6212],{},"If your inventory workflow fits these patterns, start with off-the-shelf. QuickBooks with inventory modules, Cin7, or Fishbowl will serve you better than spending six months on a custom build.",[15,6214,6216],{"id":6215},"when-the-standard-model-breaks-down","When the Standard Model Breaks Down",[20,6218,6219],{},"Here's where the gaps appear. The businesses that need custom inventory systems typically have one or more of these characteristics.",[20,6221,6222,6225],{},[42,6223,6224],{},"Non-standard unit-of-measure complexity."," A lumber distributor that buys in board feet, stores in linear feet, and sells in custom cut dimensions. A food distributor that receives in cases, stores in units, and invoices in pounds. A chemical supplier with materials that are sold by volume but stored by weight with density tables for conversion. Standard inventory systems have UOM conversion — but they assume the conversion is fixed. When it's dynamic or multi-step, you're fighting the system constantly.",[20,6227,6228,6231],{},[42,6229,6230],{},"Lot and serial number tracking with downstream traceability."," Lot tracking is standard. But tracking a lot of raw material through a production process — through multiple manufacturing stages, mixed with other lots, consumed in sub-assemblies — is not. A food manufacturer that needs to trace a finished product back to the farm lot of every ingredient needs traceability that most off-the-shelf systems can't produce without custom development anyway. You might as well build it right.",[20,6233,6234,6237],{},[42,6235,6236],{},"Complex warehouse topology."," Multi-level locations (zone, aisle, rack, shelf, bin), temperature-controlled zones with different storage eligibility rules, quarantine locations, cross-docking bays, in-transit locations. Standard systems support locations. They don't support location-based business rules that govern which inventory can be stored where and what picking strategies apply.",[20,6239,6240,6243],{},[42,6241,6242],{},"Custom pricing and cost allocation."," Standard average cost or specific identification cost accounting works until your business model diverges from it. Construction companies that need to allocate inventory costs to specific projects. Manufacturers with complex overhead allocation across product lines. Service companies that consume inventory as part of projects and need to cost it per job. These scenarios require inventory cost accounting that mirrors your business model, not a generic accounting model.",[20,6245,6246,6249],{},[42,6247,6248],{},"Integration with non-standard upstream systems."," You use a proprietary estimating system, a custom order management platform, or industry-specific software that doesn't have inventory integrations. Off-the-shelf inventory systems have integrations with other popular off-the-shelf systems. They don't have integrations with your custom platform. Building a custom inventory system that integrates natively is often cheaper than building a complex integration layer between two disconnected systems.",[20,6251,6252,6255],{},[42,6253,6254],{},"Real-time operations."," High-velocity fulfillment where inventory decisions need millisecond response times — barcode scanning on a pick line, real-time inventory reservation on an e-commerce platform during a sales event. Standard cloud-hosted inventory systems have latency that affects user experience when operations are truly real-time. A custom system optimized for your specific workload can be significantly faster.",[15,6257,6259],{"id":6258},"the-core-data-model-for-inventory-systems","The Core Data Model for Inventory Systems",[20,6261,6262],{},"Good inventory system design starts with the data model. This is where most systems — custom and off-the-shelf — either get it right or create problems that cascade through everything else.",[20,6264,6265],{},"The key entities in a well-designed inventory system:",[20,6267,6268,6271],{},[42,6269,6270],{},"Product/Item master."," Everything you stock. This needs to be rich enough to capture all the attributes that matter for storage, picking, and costing — dimensions, weight, storage requirements, UOM, cost basis — without becoming unwieldy. Keep the item master clean; it's the foundation everything else references.",[20,6273,6274,6277],{},[42,6275,6276],{},"Location master."," Every place where inventory can be stored. Model your physical space accurately: warehouses, zones, aisles, racks, bins. Attach attributes that drive business rules: temperature zone, weight capacity, product type eligibility.",[20,6279,6280,6283],{},[42,6281,6282],{},"Inventory positions."," The intersection of product and location at a specific quantity, with lot/serial tracking as appropriate. This is the real-time stock record. It should be the single source of truth for \"how much of X do we have at Y location.\"",[20,6285,6286,6289],{},[42,6287,6288],{},"Transactions."," Every movement of inventory — receipts, transfers, picks, adjustments, returns — recorded as immutable transactions. You reconstruct the current position by summing transactions. This gives you a complete audit trail and the ability to investigate discrepancies.",[20,6291,6292,6295],{},[42,6293,6294],{},"Reservations."," Uncommitted inventory that's been reserved for an order but not yet picked. The distinction between on-hand, reserved, and available is critical for accurate availability calculation.",[20,6297,6298,6301],{},[42,6299,6300],{},"Lots/Serial numbers."," If your business requires tracking, these records link inventory positions to lot or serial number records with full history.",[15,6303,6305],{"id":6304},"features-worth-building-features-worth-skipping","Features Worth Building, Features Worth Skipping",[20,6307,6308],{},"When scoping a custom inventory system, the tendency is to build everything you wish your current system had. This creates a project that takes twice as long as estimated and delivers half the value because time ran out.",[20,6310,6311],{},[42,6312,6313],{},"Worth building first:",[95,6315,6316,6319,6322,6325,6328,6331],{},[98,6317,6318],{},"Inventory positions with real-time accuracy",[98,6320,6321],{},"Receiving and put-away workflows",[98,6323,6324],{},"Pick list generation (paper, mobile, or scanner-based depending on your operation)",[98,6326,6327],{},"Inventory adjustments with approval workflow and reason codes",[98,6329,6330],{},"Reorder point management with automated PO generation",[98,6332,6333],{},"Reports: stock on hand, inventory valuation, movement history, aging",[20,6335,6336],{},[42,6337,6338],{},"Worth building in phase 2:",[95,6340,6341,6344,6347,6350,6353],{},[98,6342,6343],{},"Cycle count programs and discrepancy management",[98,6345,6346],{},"Kitting and assembly tracking",[98,6348,6349],{},"Multi-location transfer management",[98,6351,6352],{},"Advanced picking strategies (FIFO, zone picking, batch picking)",[98,6354,6355],{},"Customer-facing inventory visibility",[20,6357,6358],{},[42,6359,6360],{},"Often not worth building at all:",[95,6362,6363,6366,6369],{},[98,6364,6365],{},"Carrier integrations (use a shipping platform like EasyPost or ShipStation that specializes in this)",[98,6367,6368],{},"Accounts payable (integrate with your accounting system rather than building it)",[98,6370,6371],{},"Complex financial reporting beyond inventory valuation (your ERP handles this better)",[15,6373,6375],{"id":6374},"the-mobile-and-scanner-question","The Mobile and Scanner Question",[20,6377,6378],{},"If you have a warehouse with any meaningful volume, the system needs a mobile-optimized interface or scanner integration. This is not optional — asking warehouse staff to walk back to a workstation to log every transaction is a recipe for deferred data entry and inaccurate inventory.",[20,6380,6381],{},"There are two approaches:",[20,6383,6384,6387],{},[42,6385,6386],{},"Mobile web interface."," A responsive web interface that works on a ruggedized Android device or tablet. Lower development cost, easier to update, requires WiFi. This is the right starting point for most operations.",[20,6389,6390,6393],{},[42,6391,6392],{},"Native scanner integration."," Integration with purpose-built barcode scanners (Zebra, Honeywell) via their SDKs, or via a companion app. Higher development cost, more reliable in poor connectivity environments, required for very high-volume scan-intensive operations.",[20,6395,6396],{},"For most businesses building a custom inventory system for the first time, start with a mobile web interface. Build the scanner integration when volume justifies it.",[15,6398,6400],{"id":6399},"realistic-timeline-and-budget","Realistic Timeline and Budget",[20,6402,6403],{},"A functional custom inventory management system for a mid-size operation — multiple warehouse locations, lot tracking, receiving/put-away/picking workflows, mobile support — typically takes 4-6 months to build and costs $100K-$200K depending on complexity.",[20,6405,6406],{},"The wide range is real and depends on: number of locations, lot/serial tracking requirements, mobile/scanner integration, integration with existing systems, and reporting complexity.",[20,6408,6409],{},"Plan the delivery in phases. A first version with inventory positions, receiving, basic picking, and essential reports ships faster and starts delivering value while the more complex features are built. I'd rather have you using phase one in 10 weeks than waiting 5 months for the full system.",[15,6411,6413],{"id":6412},"the-make-or-buy-decision-for-inventory","The Make-or-Buy Decision for Inventory",[20,6415,6416],{},"If your current inventory system forces your team to maintain parallel spreadsheets for more than two workflows, that's the signal. The workarounds are costing more than you're measuring — in labor, in errors, in stockouts, and in the inventory carrying costs of safety stock you hold because you don't trust the system's accuracy.",[20,6418,6419],{},"Custom isn't the answer for every inventory challenge. But when your operations genuinely don't fit the standard model, the cost of fighting a system that doesn't fit is eventually greater than the cost of building one that does.",[20,6421,6422,6423,229],{},"If you want to talk through your inventory management situation and whether a custom system makes sense for your operation, ",[223,6424,6426],{"href":225,"rel":6425},[227],"schedule a conversation at calendly.com/jamesrossjr",[30,6428],{},[15,6430,235],{"id":234},[95,6432,6433,6439,6443,6447],{},[98,6434,6435],{},[223,6436,6438],{"href":6437},"/blog/business-process-automation","Business Process Automation: The Systems That Pay for Themselves",[98,6440,6441],{},[223,6442,5536],{"href":5783},[98,6444,6445],{},[223,6446,5755],{"href":5754},[98,6448,6449],{},[223,6450,5749],{"href":5748},{"title":263,"searchDepth":264,"depth":264,"links":6452},[6453,6454,6455,6456,6457,6458,6459,6460,6461],{"id":6169,"depth":267,"text":6170},{"id":6182,"depth":267,"text":6183},{"id":6215,"depth":267,"text":6216},{"id":6258,"depth":267,"text":6259},{"id":6304,"depth":267,"text":6305},{"id":6374,"depth":267,"text":6375},{"id":6399,"depth":267,"text":6400},{"id":6412,"depth":267,"text":6413},{"id":234,"depth":267,"text":235},"Off-the-shelf inventory software handles standard workflows. When your inventory operations are genuinely complex, a custom inventory management system delivers what generic tools can't.",[6464,6152],"custom inventory management system",{},"/blog/custom-inventory-management-system",{"title":6163,"description":6462},"blog/custom-inventory-management-system",[6470,5788,5789,6471,6472],"Inventory Management","Operations","Warehouse Management","9qQaP9-5QnoftC-uAGs9t_ShFjJhcdddV51NqqI3tQM",{"id":6475,"title":6476,"author":6477,"body":6479,"category":6849,"date":275,"description":6850,"extension":277,"featured":278,"image":279,"keywords":6851,"meta":6859,"navigation":284,"path":6860,"readTime":381,"seo":6861,"stem":6862,"tags":6863,"__hash__":6869},"blog/blog/dal-riata-irish-kingdom-created-scotland.md","Dal Riata: The Irish Kingdom That Created Scotland",{"name":9,"bio":6478},"Author of The Forge of Tongues — 22,000 Years of Migration, Mutation, and Memory",{"type":12,"value":6480,"toc":6839},[6481,6485,6488,6495,6498,6501,6503,6507,6524,6531,6536,6547,6550,6552,6556,6559,6564,6569,6575,6581,6587,6593,6600,6607,6609,6613,6616,6619,6622,6625,6627,6631,6646,6649,6652,6654,6658,6661,6667,6670,6675,6678,6681,6683,6687,6795,6798,6800,6804,6830,6833],[15,6482,6484],{"id":6483},"the-kingdom-between-two-worlds","The Kingdom Between Two Worlds",[20,6486,6487],{},"In the late fifth and early sixth century AD, a kingdom straddled the North Channel — the narrow strait of water between the northeastern coast of Ireland and the southwestern shore of what the Romans had called Caledonia. On the Irish side: the territory of Dál Fiatach and its neighbours in what is now County Antrim. On the Scottish side: the rocky peninsula of Kintyre and the islands of Islay, Jura, and Colonsay.",[20,6489,6490,6491,6494],{},"This kingdom was ",[42,6492,6493],{},"Dal Riata"," — sometimes spelled Dál Riata or Dalriada in older sources. Its people were Gaelic-speaking Irish who had been making the crossing between Ireland and Scotland for generations before their settlement became permanent and politically organised enough to be called a kingdom.",[20,6496,6497],{},"The crossing of those twenty-one miles of water was the founding act of Scotland. Not Scotland as a Roman province — that had been Caledonia, never subdued, never fully Roman. Not Scotland as a Pictish kingdom — that had been there since before the Romans. But Scotland as a Gaelic-speaking political entity — the kingdom that would eventually absorb the Picts, produce the kings who unified the north, and give rise to the Highland clan system that persists in memory and in tartan to the present day.",[20,6499,6500],{},"Dal Riata was where that began.",[30,6502],{},[15,6504,6506],{"id":6505},"the-sons-of-erc","The Sons of Erc",[20,6508,6509,6510,6513,6514,1486,6517,3780,6520,6523],{},"The traditional founding of the Scottish Dal Riata is attributed to three brothers: the sons of ",[42,6511,6512],{},"Erc",", King of Dal Riata, who led the Irish side of the kingdom. The brothers were ",[42,6515,6516],{},"Fergus Mór mac Eirc",[42,6518,6519],{},"Loarn mac Eirc",[42,6521,6522],{},"Óengus mac Eirc",". According to the tradition, they crossed from Ireland to Scotland around 500 AD and established the Scottish portion of the kingdom with their followers.",[20,6525,6526,6527,6530],{},"Fergus Mór is conventionally cited as the most prominent of the brothers — medieval sources sometimes describe him as the ",[5826,6528,6529],{},"first"," king of the Scottish Dal Riata, and his name became the symbolic origin of the royal line that would eventually include Cináed mac Ailpín (Kenneth MacAlpin), the ninth-century king credited with unifying Picts and Scots.",[20,6532,6533],{},[42,6534,6535],{},"But Loarn was the elder brother.",[20,6537,6538,6539,6542,6543,6546],{},"The territory that became known as ",[42,6540,6541],{},"Cenél Loairn"," — \"the kindred of Loarn\" — encompassed the northern division of the Scottish Dal Riata, including the district of Lorne (which preserves his name) and extending northward into what would become the territories contested by the mormaers of Moray. Fergus's kindred — ",[42,6544,6545],{},"Cenél nGabráin"," — took the southern districts and the kingship that eventually passed to the Scottish royal line.",[20,6548,6549],{},"Loarn got the north. Fergus got the crown. The elder brother's descendants took the harder road.",[30,6551],{},[15,6553,6555],{"id":6554},"the-traditional-genealogy","The Traditional Genealogy",[20,6557,6558],{},"The Ross clan's traditional genealogy connects the chiefs to Dal Riata through the Cenél Loairn line. The chain runs:",[20,6560,6561,6563],{},[42,6562,6519],{}," → his descendants form the Cenél Loairn of Dal Riata",[20,6565,6566,6568],{},[42,6567,6541],{}," → several generations of chiefs controlling the northern territories",[20,6570,6571,6574],{},[42,6572,6573],{},"The O'Beolan abbots of Applecross"," → a hereditary abbatial family at the monastery founded by St Maelrubha in 673 AD on the Applecross Peninsula in Ross-shire. The O'Beolans are traditionally connected to the Cenél Loairn.",[20,6576,6577,6580],{},[42,6578,6579],{},"Fearchar mac an t-Sagairt"," — \"Farquhar, Son of the Priest\" — an O'Beolan hereditary abbot who rose to secular prominence in the early 13th century, was granted the earldom of Ross by Alexander II of Scotland in 1215, and became the first Earl of Ross. From the earldom came the hereditary surname \"Ross.\"",[20,6582,6583,6586],{},[42,6584,6585],{},"The Earls of Ross"," → through the medieval period, contested, forfeited, regained. The earldom passed between Ross chiefs and Scottish royals through several generations.",[20,6588,6589,6592],{},[42,6590,6591],{},"The chiefs of Clan Ross"," → from the earls to the present day.",[20,6594,6595,6596,6599],{},"The probability that every named individual in this chain was the literal biological father of the next is low — Appendix K of ",[5826,6597,6598],{},"The Forge of Tongues"," assigns confidence levels ranging from 90% (modern documented links) to perhaps 20–30% (the connection of the O'Beolans to the Cenél Loairn), to lower still for the link from the Cenél Loairn to Loarn mac Eirc himself.",[20,6601,6602,6603,6606],{},"But the ",[5826,6604,6605],{},"broad pattern"," — that the Ross line represents a northern Scottish Highland lineage with Dal Riata origins, descending from the Cenél Loairn rather than the Cenél nGabráin — is consistent with the documentary record, the geographic distribution of the clan, and the genetic evidence.",[30,6608],{},[15,6610,6612],{"id":6611},"what-the-dna-says-about-the-dal-riata-crossing","What the DNA Says About the Dal Riata Crossing",[20,6614,6615],{},"The Dal Riata migration from Ireland to Scotland was not a genetic revolution in the same sense as the Bell Beaker arrival 2,000 years earlier. The populations on both sides of the North Channel had been in contact for generations. The Irish and the Picts of western Scotland shared broadly similar genetic profiles — both predominantly R1b-L21, the Atlantic Celtic marker that had dominated the region since the Bronze Age.",[20,6617,6618],{},"What the Dal Riata crossing established was a political and cultural entity — a Gaelic-speaking kingdom with Irish origin myths, Irish law, Irish genealogies, and the Irish language — on Scottish soil. The genetic similarity between the two populations meant that the Dal Riata settlers blended into the existing population without dramatic genetic change. What they brought was language, identity, and the political framework of the kingdom.",[20,6620,6621],{},"For the Ross line specifically: the Y-chromosome test showing R1b-L21 without M222 is consistent with a Dal Riata origin in the Cenél Loairn. M222 — the marker associated with Niall of the Nine Hostages and the Uí Néill dynasty — is common in the Dal Riata populations descended from Fergus Mór's Cenél nGabráin, which had stronger Uí Néill connections. The absence of M222 in the Ross line is consistent with the tradition that the Rosses descend from Loarn rather than Fergus — the elder brother's line, which may have diverged from the Uí Néill genealogical orbit earlier.",[20,6623,6624],{},"This is suggestive, not conclusive. But the genetics point in the same direction the tradition points.",[30,6626],{},[15,6628,6630],{"id":6629},"lorne-the-territory-that-kept-loarns-name","Lorne: The Territory That Kept Loarn's Name",[20,6632,6633,6634,6637,6638,6641,6642,6645],{},"The district of ",[42,6635,6636],{},"Lorne"," in Argyll and Bute — the territory stretching east from the coast around Oban, including the lochs and mountains of what is now one of Scotland's most scenic landscapes — preserves the name of Loarn mac Eirc in its topography. ",[5826,6639,6640],{},"Lorn",", from ",[5826,6643,6644],{},"Latharna",", derives from the same root as Loarn.",[20,6647,6648],{},"This is not metaphorical. The persistence of a name in a landscape for 1,500 years marks the territory as having been genuinely associated with the person named. Place-names survive because communities use them and transmit them. Lorne exists in the landscape because Loarn's kindred held the territory and named it.",[20,6650,6651],{},"The northern territories of the Cenél Loairn extended beyond Lorne — through Morvern and Ardnamurchan, northward into the Great Glen, eventually reaching the lands that would become Moray and then, further north still, Ross-shire. The northward push of Cenél Loairn interests through the first millennium tracks with the eventual emergence of the Ross earldom in the far north.",[30,6653],{},[15,6655,6657],{"id":6656},"dal-riata-and-the-making-of-alba","Dal Riata and the Making of Alba",[20,6659,6660],{},"The Dal Riata kingdom lasted in its distinctive form for approximately three centuries — from around 500 AD through the ninth century, when it was transformed by Viking raids, Pictish political pressure, and the eventual union that produced the Kingdom of Alba.",[20,6662,6663,6666],{},[42,6664,6665],{},"Cináed mac Ailpín"," — Kenneth MacAlpin, fl. 843 AD — is the king conventionally credited with unifying the Picts and Scots into a single kingdom. His exact origins are disputed, but he claimed descent from the Cenél nGabráin line of Dal Riata — Fergus Mór's branch, the southern kindred. The subsequent Scottish kings traced their legitimacy through Fergus's line.",[20,6668,6669],{},"Loarn's line — the northern kindred — did not become the royal succession. But they did not disappear. The mormaers of Moray — the great northern magnates who contested Scottish kingship for generations — drew their power from territories that had been Cenél Loairn country. Among those mormaers was one whose name is known to every English-speaker who has encountered Shakespeare:",[20,6671,6672,229],{},[42,6673,6674],{},"Macbeth",[20,6676,6677],{},"The claim that the Ross line descends from the same stock as Macbeth — from the Cenél Loairn and its mormaer successors — is one of the more striking points in the Ross traditional genealogy. It places the Ross chiefs in the tradition of the northern Scottish magnates who competed with the southern royal line for generations before the southern line consolidated its hold.",[20,6679,6680],{},"The elder brother's descendants never quite stopped contesting.",[30,6682],{},[15,6684,6686],{"id":6685},"key-facts-dal-riata","Key Facts: Dal Riata",[6688,6689,6690,6701],"table",{},[6691,6692,6693],"thead",{},[6694,6695,6696,6699],"tr",{},[6697,6698],"th",{},[6697,6700],{},[6702,6703,6704,6715,6725,6735,6745,6755,6765,6775,6785],"tbody",{},[6694,6705,6706,6712],{},[6707,6708,6709],"td",{},[42,6710,6711],{},"Period",[6707,6713,6714],{},"c. 500–850 AD (as a distinct political entity)",[6694,6716,6717,6722],{},[6707,6718,6719],{},[42,6720,6721],{},"Territory",[6707,6723,6724],{},"Northeastern Ireland + western Scotland (Argyll, Inner Hebrides)",[6694,6726,6727,6732],{},[6707,6728,6729],{},[42,6730,6731],{},"Founded by",[6707,6733,6734],{},"Sons of Erc: Fergus Mór, Loarn, Óengus",[6694,6736,6737,6742],{},[6707,6738,6739],{},[42,6740,6741],{},"Language",[6707,6743,6744],{},"Gaelic (Old Irish)",[6694,6746,6747,6752],{},[6707,6748,6749],{},[42,6750,6751],{},"Religion",[6707,6753,6754],{},"Christian (Columba founded Iona within Dal Riata territory, 563 AD)",[6694,6756,6757,6762],{},[6707,6758,6759],{},[42,6760,6761],{},"Kindreds",[6707,6763,6764],{},"Cenél nGabráin (south), Cenél Loairn (north), Cenél nÓengusa (Islay)",[6694,6766,6767,6772],{},[6707,6768,6769],{},[42,6770,6771],{},"Genetic legacy",[6707,6773,6774],{},"R1b-L21, primarily pre-M222 (consistent with pre-Uí Néill divergence)",[6694,6776,6777,6782],{},[6707,6778,6779],{},[42,6780,6781],{},"Ross connection",[6707,6783,6784],{},"Cenél Loairn → O'Beolans of Applecross → Earl of Ross",[6694,6786,6787,6792],{},[6707,6788,6789],{},[42,6790,6791],{},"Scottish legacy",[6707,6793,6794],{},"Dal Riata kings became the Kings of Alba; Gaelic replaced Pictish",[20,6796,6797],{},"Dal Riata is where Scotland was forged. The brothers who led the crossing gave their kindred's names to the territories they settled. Fergus's line became the royal succession. Loarn's line became the northern magnates — the mormaers, the abbots, the earls — who held the Highland frontier for a thousand years.",[30,6799],{},[15,6801,6803],{"id":6802},"related-articles","Related Articles",[95,6805,6806,6812,6818,6824],{},[98,6807,6808],{},[223,6809,6811],{"href":6810},"/blog/loarn-mac-eirc-elder-brother","Loarn mac Eirc: The Elder Brother and the Senior Blood",[98,6813,6814],{},[223,6815,6817],{"href":6816},"/blog/bell-beaker-conquest-ireland-britain","The Bell Beaker Conquest: How Bronze Age Migrants Replaced Ireland's Men",[98,6819,6820],{},[223,6821,6823],{"href":6822},"/blog/applecross-obeolans-monks-dynasty","The O'Beolans of Applecross: The Monks Who Became a Dynasty",[98,6825,6826],{},[223,6827,6829],{"href":6828},"/blog/macbeth-mormaers-moray-clan-ross","Macbeth, the Mormaers of Moray, and Clan Ross",[20,6831,6832],{},"The Ross clan's story begins at that crossing.",[20,6834,6835],{},[223,6836,6838],{"href":6837},"/book","Read the full account of Loarn mac Eirc and the Dal Riata crossing in The Forge of Tongues.",{"title":263,"searchDepth":264,"depth":264,"links":6840},[6841,6842,6843,6844,6845,6846,6847,6848],{"id":6483,"depth":267,"text":6484},{"id":6505,"depth":267,"text":6506},{"id":6554,"depth":267,"text":6555},{"id":6611,"depth":267,"text":6612},{"id":6629,"depth":267,"text":6630},{"id":6656,"depth":267,"text":6657},{"id":6685,"depth":267,"text":6686},{"id":6802,"depth":267,"text":6803},"Heritage","Around 500 AD, an Irish kingdom called Dal Riata established permanent settlements in what is now western Scotland. From that crossing — and from the brothers who led it — every Scottish Highland clan traces its ultimate origin.",[6852,6853,6854,6855,6856,6857,6858],"dal riata","dal riata history","scottish origin ireland","loarn mac eirc","fergus mor mac eirc","irish kingdom scotland","clan ross origin",{},"/blog/dal-riata-irish-kingdom-created-scotland",{"title":6476,"description":6850},"blog/dal-riata-irish-kingdom-created-scotland",[6493,6864,6865,6866,6867,6868],"Scottish History","Irish History","Clan Ross","Loarn Mac Eirc","Scottish Origins","QP05Qa_MkcCndyNGRPmYysLMnbUFOHQFFuSPQvZd8Jk",{"id":6871,"title":2531,"author":6872,"body":6873,"category":1329,"date":275,"description":8159,"extension":277,"featured":278,"image":279,"keywords":8160,"meta":8163,"navigation":284,"path":2530,"readTime":286,"seo":8164,"stem":8165,"tags":8166,"__hash__":8170},"blog/blog/data-encryption-guide.md",{"name":9,"bio":10},{"type":12,"value":6874,"toc":8152},[6875,6878,6881,6884,6888,6891,6897,6900,6903,6909,6974,6984,6988,6991,6997,7003,7437,7440,7443,7453,7456,7681,7685,7688,7694,7700,7978,7981,7987,7991,7994,7997,8000,8003,8006,8111,8114,8117,8119,8125,8127,8129,8149],[301,6876,2531],{"id":6877},"data-encryption-in-applications-at-rest-in-transit-and-in-memory",[20,6879,6880],{},"Encryption is frequently misunderstood in application development. Teams think \"we use HTTPS\" and consider data protection addressed. HTTPS encrypts data in transit — the network path between client and server. It says nothing about how data is stored, how it moves internally between services, or how long sensitive values linger in application memory.",[20,6882,6883],{},"Comprehensive data protection requires thinking about all three states: in transit, at rest, and in memory. Here is what each requires and how to implement it.",[15,6885,6887],{"id":6886},"data-in-transit","Data in Transit",[20,6889,6890],{},"HTTPS (TLS) encrypts data between the user's browser and your server. This is table stakes in 2026. Beyond HTTPS for public-facing connections, consider:",[20,6892,6893,6896],{},[42,6894,6895],{},"Service-to-service communication."," If your application has multiple services communicating internally, that traffic also needs encryption. Internal network traffic that is unencrypted is readable to anyone with access to that network — a breach that gets an attacker onto your internal network can now read all your internal API calls.",[20,6898,6899],{},"For services communicating over a private network between services you trust, mTLS (mutual TLS) provides both encryption and authentication. Both parties present certificates, and neither accepts connections from an unauthenticated peer. This is more complex to manage but provides strong guarantees.",[20,6901,6902],{},"For simpler internal service communication, HTTPS with a self-signed certificate or a private CA provides encryption without mutual authentication. At minimum, enforce HTTPS for all inter-service communication.",[20,6904,6905,6908],{},[42,6906,6907],{},"Database connections."," Many applications connect to their database over an unencrypted connection, assuming the database is on the same network and therefore safe. Enable TLS for database connections:",[321,6910,6912],{"className":1390,"code":6911,"language":1392,"meta":263,"style":263},"// Prisma with SSL required\nconst prisma = new PrismaClient({\n datasources: {\n db: {\n url: process.env.DATABASE_URL + \"?sslmode=require\",\n },\n },\n});\n",[108,6913,6914,6919,6936,6941,6946,6962,6966,6970],{"__ignoreMap":263},[329,6915,6916],{"class":331,"line":332},[329,6917,6918],{"class":620},"// Prisma with SSL required\n",[329,6920,6921,6923,6926,6928,6931,6934],{"class":331,"line":267},[329,6922,3596],{"class":1182},[329,6924,6925],{"class":585}," prisma",[329,6927,1916],{"class":1182},[329,6929,6930],{"class":1182}," new",[329,6932,6933],{"class":1172}," PrismaClient",[329,6935,2222],{"class":505},[329,6937,6938],{"class":331,"line":264},[329,6939,6940],{"class":505}," datasources: {\n",[329,6942,6943],{"class":331,"line":348},[329,6944,6945],{"class":505}," db: {\n",[329,6947,6948,6951,6954,6957,6960],{"class":331,"line":354},[329,6949,6950],{"class":505}," url: process.env.",[329,6952,6953],{"class":585},"DATABASE_URL",[329,6955,6956],{"class":1182}," +",[329,6958,6959],{"class":516}," \"?sslmode=require\"",[329,6961,1540],{"class":505},[329,6963,6964],{"class":331,"line":360},[329,6965,5048],{"class":505},[329,6967,6968],{"class":331,"line":286},[329,6969,5048],{"class":505},[329,6971,6972],{"class":331,"line":370},[329,6973,1632],{"class":505},[20,6975,6976,6977,3557,6980,6983],{},"For PostgreSQL, ensure your database server is configured with SSL enabled and your connection string includes ",[108,6978,6979],{},"sslmode=require",[108,6981,6982],{},"sslmode=verify-full"," (which also verifies the certificate chain).",[15,6985,6987],{"id":6986},"data-at-rest","Data at Rest",[20,6989,6990],{},"HTTPS protects your data as it crosses the network. It does not protect your data when it is stored. A database dump, a stolen backup, a compromised read replica — these expose your data regardless of how well you secured transit.",[20,6992,6993,6996],{},[42,6994,6995],{},"Full-disk encryption."," Modern cloud providers encrypt storage volumes at rest by default. Verify this is enabled for every volume in your infrastructure. AWS EBS volumes should have encryption at rest enabled. This protects against the physical disk being removed from a data center, but not against attackers who gain access to your running system.",[20,6998,6999,7002],{},[42,7000,7001],{},"Application-level encryption."," For sensitive fields (healthcare data, PII, financial information, API keys, OAuth tokens, social security numbers), encrypt the data in your application before it reaches the database. The database stores ciphertext. Even a full database dump is useless without the encryption key.",[321,7004,7006],{"className":1390,"code":7005,"language":1392,"meta":263,"style":263},"import { createCipheriv, createDecipheriv, randomBytes } from \"crypto\";\n\nConst ALGORITHM = \"aes-256-gcm\";\nconst KEY = Buffer.from(process.env.ENCRYPTION_KEY!, \"hex\"); // 32 bytes\n\nFunction encrypt(plaintext: string): string {\n const iv = randomBytes(12); // 96-bit IV for GCM\n const cipher = createCipheriv(ALGORITHM, KEY, iv);\n\n let ciphertext = cipher.update(plaintext, \"utf8\", \"hex\");\n ciphertext += cipher.final(\"hex\");\n const tag = cipher.getAuthTag().toString(\"hex\");\n\n // Store iv:tag:ciphertext together\n return `${iv.toString(\"hex\")}:${tag}:${ciphertext}`;\n}\n\nFunction decrypt(encrypted: string): string {\n const [ivHex, tagHex, ciphertext] = encrypted.split(\":\");\n const iv = Buffer.from(ivHex, \"hex\");\n const tag = Buffer.from(tagHex, \"hex\");\n\n const decipher = createDecipheriv(ALGORITHM, KEY, iv);\n decipher.setAuthTag(tag);\n\n let plaintext = decipher.update(ciphertext, \"hex\", \"utf8\");\n plaintext += decipher.final(\"utf8\");\n return plaintext;\n}\n",[108,7007,7008,7021,7025,7040,7071,7075,7085,7106,7130,7134,7162,7180,7205,7209,7214,7250,7254,7258,7268,7303,7322,7341,7345,7367,7378,7382,7407,7424,7432],{"__ignoreMap":263},[329,7009,7010,7012,7015,7017,7019],{"class":331,"line":332},[329,7011,1399],{"class":1182},[329,7013,7014],{"class":505}," { createCipheriv, createDecipheriv, randomBytes } ",[329,7016,1405],{"class":1182},[329,7018,1408],{"class":516},[329,7020,1411],{"class":505},[329,7022,7023],{"class":331,"line":267},[329,7024,340],{"emptyLinePlaceholder":284},[329,7026,7027,7030,7033,7035,7038],{"class":331,"line":264},[329,7028,7029],{"class":505},"Const ",[329,7031,7032],{"class":585},"ALGORITHM",[329,7034,1916],{"class":1182},[329,7036,7037],{"class":516}," \"aes-256-gcm\"",[329,7039,1411],{"class":505},[329,7041,7042,7044,7047,7049,7051,7053,7056,7059,7061,7063,7065,7068],{"class":331,"line":348},[329,7043,3596],{"class":1182},[329,7045,7046],{"class":585}," KEY",[329,7048,1916],{"class":1182},[329,7050,2116],{"class":505},[329,7052,1405],{"class":1172},[329,7054,7055],{"class":505},"(process.env.",[329,7057,7058],{"class":585},"ENCRYPTION_KEY",[329,7060,2431],{"class":1182},[329,7062,1486],{"class":505},[329,7064,4578],{"class":516},[329,7066,7067],{"class":505},"); ",[329,7069,7070],{"class":620},"// 32 bytes\n",[329,7072,7073],{"class":331,"line":354},[329,7074,340],{"emptyLinePlaceholder":284},[329,7076,7077,7079,7082],{"class":331,"line":360},[329,7078,1420],{"class":505},[329,7080,7081],{"class":1172},"encrypt",[329,7083,7084],{"class":505},"(plaintext: string): string {\n",[329,7086,7087,7089,7092,7094,7096,7098,7101,7103],{"class":331,"line":286},[329,7088,1910],{"class":1182},[329,7090,7091],{"class":585}," iv",[329,7093,1916],{"class":1182},[329,7095,1434],{"class":1172},[329,7097,1437],{"class":505},[329,7099,7100],{"class":585},"12",[329,7102,7067],{"class":505},[329,7104,7105],{"class":620},"// 96-bit IV for GCM\n",[329,7107,7108,7110,7113,7115,7118,7120,7122,7124,7127],{"class":331,"line":370},[329,7109,1910],{"class":1182},[329,7111,7112],{"class":585}," cipher",[329,7114,1916],{"class":1182},[329,7116,7117],{"class":1172}," createCipheriv",[329,7119,1437],{"class":505},[329,7121,7032],{"class":585},[329,7123,1486],{"class":505},[329,7125,7126],{"class":585},"KEY",[329,7128,7129],{"class":505},", iv);\n",[329,7131,7132],{"class":331,"line":375},[329,7133,340],{"emptyLinePlaceholder":284},[329,7135,7136,7139,7142,7144,7147,7150,7153,7156,7158,7160],{"class":331,"line":381},[329,7137,7138],{"class":1182}," let",[329,7140,7141],{"class":505}," ciphertext ",[329,7143,1511],{"class":1182},[329,7145,7146],{"class":505}," cipher.",[329,7148,7149],{"class":1172},"update",[329,7151,7152],{"class":505},"(plaintext, ",[329,7154,7155],{"class":516},"\"utf8\"",[329,7157,1486],{"class":505},[329,7159,4578],{"class":516},[329,7161,1454],{"class":505},[329,7163,7164,7166,7169,7171,7174,7176,7178],{"class":331,"line":387},[329,7165,7141],{"class":505},[329,7167,7168],{"class":1182},"+=",[329,7170,7146],{"class":505},[329,7172,7173],{"class":1172},"final",[329,7175,1437],{"class":505},[329,7177,4578],{"class":516},[329,7179,1454],{"class":505},[329,7181,7182,7184,7187,7189,7191,7194,7197,7199,7201,7203],{"class":331,"line":392},[329,7183,1910],{"class":1182},[329,7185,7186],{"class":585}," tag",[329,7188,1916],{"class":1182},[329,7190,7146],{"class":505},[329,7192,7193],{"class":1172},"getAuthTag",[329,7195,7196],{"class":505},"().",[329,7198,1446],{"class":1172},[329,7200,1437],{"class":505},[329,7202,4578],{"class":516},[329,7204,1454],{"class":505},[329,7206,7207],{"class":331,"line":398},[329,7208,340],{"emptyLinePlaceholder":284},[329,7210,7211],{"class":331,"line":403},[329,7212,7213],{"class":620}," // Store iv:tag:ciphertext together\n",[329,7215,7216,7218,7221,7224,7226,7228,7230,7232,7234,7237,7240,7242,7245,7248],{"class":331,"line":409},[329,7217,1431],{"class":1182},[329,7219,7220],{"class":516}," `${",[329,7222,7223],{"class":505},"iv",[329,7225,229],{"class":516},[329,7227,1446],{"class":1172},[329,7229,1437],{"class":516},[329,7231,4578],{"class":516},[329,7233,4632],{"class":516},[329,7235,7236],{"class":516},"}:${",[329,7238,7239],{"class":505},"tag",[329,7241,7236],{"class":516},[329,7243,7244],{"class":505},"ciphertext",[329,7246,7247],{"class":516},"}`",[329,7249,1411],{"class":505},[329,7251,7252],{"class":331,"line":415},[329,7253,1459],{"class":505},[329,7255,7256],{"class":331,"line":420},[329,7257,340],{"emptyLinePlaceholder":284},[329,7259,7260,7262,7265],{"class":331,"line":426},[329,7261,1420],{"class":505},[329,7263,7264],{"class":1172},"decrypt",[329,7266,7267],{"class":505},"(encrypted: string): string {\n",[329,7269,7270,7272,7275,7278,7280,7283,7285,7287,7289,7291,7294,7296,7298,7301],{"class":331,"line":887},[329,7271,1910],{"class":1182},[329,7273,7274],{"class":505}," [",[329,7276,7277],{"class":585},"ivHex",[329,7279,1486],{"class":505},[329,7281,7282],{"class":585},"tagHex",[329,7284,1486],{"class":505},[329,7286,7244],{"class":585},[329,7288,5351],{"class":505},[329,7290,1511],{"class":1182},[329,7292,7293],{"class":505}," encrypted.",[329,7295,5296],{"class":1172},[329,7297,1437],{"class":505},[329,7299,7300],{"class":516},"\":\"",[329,7302,1454],{"class":505},[329,7304,7305,7307,7309,7311,7313,7315,7318,7320],{"class":331,"line":895},[329,7306,1910],{"class":1182},[329,7308,7091],{"class":585},[329,7310,1916],{"class":1182},[329,7312,2116],{"class":505},[329,7314,1405],{"class":1172},[329,7316,7317],{"class":505},"(ivHex, ",[329,7319,4578],{"class":516},[329,7321,1454],{"class":505},[329,7323,7324,7326,7328,7330,7332,7334,7337,7339],{"class":331,"line":906},[329,7325,1910],{"class":1182},[329,7327,7186],{"class":585},[329,7329,1916],{"class":1182},[329,7331,2116],{"class":505},[329,7333,1405],{"class":1172},[329,7335,7336],{"class":505},"(tagHex, ",[329,7338,4578],{"class":516},[329,7340,1454],{"class":505},[329,7342,7343],{"class":331,"line":914},[329,7344,340],{"emptyLinePlaceholder":284},[329,7346,7347,7349,7352,7354,7357,7359,7361,7363,7365],{"class":331,"line":923},[329,7348,1910],{"class":1182},[329,7350,7351],{"class":585}," decipher",[329,7353,1916],{"class":1182},[329,7355,7356],{"class":1172}," createDecipheriv",[329,7358,1437],{"class":505},[329,7360,7032],{"class":585},[329,7362,1486],{"class":505},[329,7364,7126],{"class":585},[329,7366,7129],{"class":505},[329,7368,7369,7372,7375],{"class":331,"line":2282},[329,7370,7371],{"class":505}," decipher.",[329,7373,7374],{"class":1172},"setAuthTag",[329,7376,7377],{"class":505},"(tag);\n",[329,7379,7380],{"class":331,"line":2290},[329,7381,340],{"emptyLinePlaceholder":284},[329,7383,7385,7387,7390,7392,7394,7396,7399,7401,7403,7405],{"class":331,"line":7384},26,[329,7386,7138],{"class":1182},[329,7388,7389],{"class":505}," plaintext ",[329,7391,1511],{"class":1182},[329,7393,7371],{"class":505},[329,7395,7149],{"class":1172},[329,7397,7398],{"class":505},"(ciphertext, ",[329,7400,4578],{"class":516},[329,7402,1486],{"class":505},[329,7404,7155],{"class":516},[329,7406,1454],{"class":505},[329,7408,7410,7412,7414,7416,7418,7420,7422],{"class":331,"line":7409},27,[329,7411,7389],{"class":505},[329,7413,7168],{"class":1182},[329,7415,7371],{"class":505},[329,7417,7173],{"class":1172},[329,7419,1437],{"class":505},[329,7421,7155],{"class":516},[329,7423,1454],{"class":505},[329,7425,7427,7429],{"class":331,"line":7426},28,[329,7428,1431],{"class":1182},[329,7430,7431],{"class":505}," plaintext;\n",[329,7433,7435],{"class":331,"line":7434},29,[329,7436,1459],{"class":505},[20,7438,7439],{},"AES-256-GCM is the right algorithm for this purpose. It provides both confidentiality (AES) and authentication (GCM's authentication tag). The authentication tag prevents tampering — if the ciphertext is modified, decryption fails with an error. Always use GCM mode, not CBC or ECB, which lack authentication.",[20,7441,7442],{},"The initialization vector (IV) must be unique for every encryption operation. Reusing an IV with the same key completely breaks GCM's security. Generate a new random IV for every encryption call and store it alongside the ciphertext.",[20,7444,7445,7448,7449,7452],{},[42,7446,7447],{},"Searchable encryption."," A limitation of application-level encryption is that you cannot query encrypted fields directly. You cannot do ",[108,7450,7451],{},"WHERE encrypted_email = $1"," because the stored value is ciphertext, not plaintext. Workarounds include storing a hash of the value alongside the encrypted value (for exact-match lookups), decrypting in the application and filtering in memory (expensive at scale), or using a specialized searchable encryption scheme (complex, limited support).",[20,7454,7455],{},"For fields you need to query, hash alongside encryption:",[321,7457,7459],{"className":1390,"code":7458,"language":1392,"meta":263,"style":263},"import { createHash } from \"crypto\";\n\nFunction hashForLookup(value: string): string {\n // HMAC-SHA256 with a separate lookup key (not the encryption key)\n const LOOKUP_KEY = process.env.LOOKUP_HMAC_KEY!;\n return createHmac(\"sha256\", LOOKUP_KEY).update(value).digest(\"hex\");\n}\n\n// Store both\nawait db.user.create({\n data: {\n emailEncrypted: encrypt(email),\n emailHash: hashForLookup(email.toLowerCase()),\n },\n});\n\n// Query by hash\nconst user = await db.user.findFirst({\n where: { emailHash: hashForLookup(email.toLowerCase()) },\n});\nif (user) {\n const decryptedEmail = decrypt(user.emailEncrypted);\n}\n",[108,7460,7461,7474,7478,7488,7493,7511,7544,7548,7552,7557,7569,7574,7584,7600,7604,7608,7612,7617,7636,7650,7654,7662,7677],{"__ignoreMap":263},[329,7462,7463,7465,7468,7470,7472],{"class":331,"line":332},[329,7464,1399],{"class":1182},[329,7466,7467],{"class":505}," { createHash } ",[329,7469,1405],{"class":1182},[329,7471,1408],{"class":516},[329,7473,1411],{"class":505},[329,7475,7476],{"class":331,"line":267},[329,7477,340],{"emptyLinePlaceholder":284},[329,7479,7480,7482,7485],{"class":331,"line":264},[329,7481,1420],{"class":505},[329,7483,7484],{"class":1172},"hashForLookup",[329,7486,7487],{"class":505},"(value: string): string {\n",[329,7489,7490],{"class":331,"line":348},[329,7491,7492],{"class":620}," // HMAC-SHA256 with a separate lookup key (not the encryption key)\n",[329,7494,7495,7497,7500,7502,7504,7507,7509],{"class":331,"line":354},[329,7496,1910],{"class":1182},[329,7498,7499],{"class":585}," LOOKUP_KEY",[329,7501,1916],{"class":1182},[329,7503,5016],{"class":505},[329,7505,7506],{"class":585},"LOOKUP_HMAC_KEY",[329,7508,2431],{"class":1182},[329,7510,1411],{"class":505},[329,7512,7513,7515,7518,7520,7523,7525,7528,7530,7532,7535,7538,7540,7542],{"class":331,"line":360},[329,7514,1431],{"class":1182},[329,7516,7517],{"class":1172}," createHmac",[329,7519,1437],{"class":505},[329,7521,7522],{"class":516},"\"sha256\"",[329,7524,1486],{"class":505},[329,7526,7527],{"class":585},"LOOKUP_KEY",[329,7529,1443],{"class":505},[329,7531,7149],{"class":1172},[329,7533,7534],{"class":505},"(value).",[329,7536,7537],{"class":1172},"digest",[329,7539,1437],{"class":505},[329,7541,4578],{"class":516},[329,7543,1454],{"class":505},[329,7545,7546],{"class":331,"line":286},[329,7547,1459],{"class":505},[329,7549,7550],{"class":331,"line":370},[329,7551,340],{"emptyLinePlaceholder":284},[329,7553,7554],{"class":331,"line":375},[329,7555,7556],{"class":620},"// Store both\n",[329,7558,7559,7561,7564,7567],{"class":331,"line":381},[329,7560,3633],{"class":1182},[329,7562,7563],{"class":505}," db.user.",[329,7565,7566],{"class":1172},"create",[329,7568,2222],{"class":505},[329,7570,7571],{"class":331,"line":387},[329,7572,7573],{"class":505}," data: {\n",[329,7575,7576,7579,7581],{"class":331,"line":392},[329,7577,7578],{"class":505}," emailEncrypted: ",[329,7580,7081],{"class":1172},[329,7582,7583],{"class":505},"(email),\n",[329,7585,7586,7589,7591,7594,7597],{"class":331,"line":398},[329,7587,7588],{"class":505}," emailHash: ",[329,7590,7484],{"class":1172},[329,7592,7593],{"class":505},"(email.",[329,7595,7596],{"class":1172},"toLowerCase",[329,7598,7599],{"class":505},"()),\n",[329,7601,7602],{"class":331,"line":403},[329,7603,5048],{"class":505},[329,7605,7606],{"class":331,"line":409},[329,7607,1632],{"class":505},[329,7609,7610],{"class":331,"line":415},[329,7611,340],{"emptyLinePlaceholder":284},[329,7613,7614],{"class":331,"line":420},[329,7615,7616],{"class":620},"// Query by hash\n",[329,7618,7619,7621,7624,7626,7629,7631,7634],{"class":331,"line":426},[329,7620,3596],{"class":1182},[329,7622,7623],{"class":585}," user",[329,7625,1916],{"class":1182},[329,7627,7628],{"class":1182}," await",[329,7630,7563],{"class":505},[329,7632,7633],{"class":1172},"findFirst",[329,7635,2222],{"class":505},[329,7637,7638,7641,7643,7645,7647],{"class":331,"line":887},[329,7639,7640],{"class":505}," where: { emailHash: ",[329,7642,7484],{"class":1172},[329,7644,7593],{"class":505},[329,7646,7596],{"class":1172},[329,7648,7649],{"class":505},"()) },\n",[329,7651,7652],{"class":331,"line":895},[329,7653,1632],{"class":505},[329,7655,7656,7659],{"class":331,"line":906},[329,7657,7658],{"class":1182},"if",[329,7660,7661],{"class":505}," (user) {\n",[329,7663,7664,7666,7669,7671,7674],{"class":331,"line":914},[329,7665,1910],{"class":1182},[329,7667,7668],{"class":585}," decryptedEmail",[329,7670,1916],{"class":1182},[329,7672,7673],{"class":1172}," decrypt",[329,7675,7676],{"class":505},"(user.emailEncrypted);\n",[329,7678,7679],{"class":331,"line":923},[329,7680,1459],{"class":505},[15,7682,7684],{"id":7683},"key-management","Key Management",[20,7686,7687],{},"Encryption is only as secure as your key management. A well-implemented encryption scheme with a compromised key offers no protection.",[20,7689,7690,7693],{},[42,7691,7692],{},"Never hardcode encryption keys."," Keys must come from environment variables, secrets management systems, or dedicated key management services. A key in source code is a key shared with everyone who has repository access and everyone who will ever have access to the repository history.",[20,7695,7696,7699],{},[42,7697,7698],{},"Use a KMS for production."," AWS KMS, Google Cloud KMS, and HashiCorp Vault provide managed key management with audit logging, key rotation, and access controls. Rather than loading your encryption key as an environment variable (which puts the raw key material in your process environment), use a KMS:",[321,7701,7703],{"className":1390,"code":7702,"language":1392,"meta":263,"style":263},"import { KMSClient, EncryptCommand, DecryptCommand } from \"@aws-sdk/client-kms\";\n\nConst kms = new KMSClient({ region: \"us-east-1\" });\n\nAsync function encryptWithKms(plaintext: string): Promise\u003Cstring> {\n const command = new EncryptCommand({\n KeyId: process.env.KMS_KEY_ID!,\n Plaintext: Buffer.from(plaintext),\n });\n const response = await kms.send(command);\n return Buffer.from(response.CiphertextBlob!).toString(\"base64\");\n}\n\nAsync function decryptWithKms(ciphertext: string): Promise\u003Cstring> {\n const command = new DecryptCommand({\n CiphertextBlob: Buffer.from(ciphertext, \"base64\"),\n });\n const response = await kms.send(command);\n return Buffer.from(response.Plaintext!).toString(\"utf8\");\n}\n",[108,7704,7705,7719,7723,7743,7747,7781,7797,7809,7819,7823,7842,7865,7869,7873,7902,7917,7931,7935,7951,7974],{"__ignoreMap":263},[329,7706,7707,7709,7712,7714,7717],{"class":331,"line":332},[329,7708,1399],{"class":1182},[329,7710,7711],{"class":505}," { KMSClient, EncryptCommand, DecryptCommand } ",[329,7713,1405],{"class":1182},[329,7715,7716],{"class":516}," \"@aws-sdk/client-kms\"",[329,7718,1411],{"class":505},[329,7720,7721],{"class":331,"line":267},[329,7722,340],{"emptyLinePlaceholder":284},[329,7724,7725,7728,7730,7732,7735,7738,7741],{"class":331,"line":264},[329,7726,7727],{"class":505},"Const kms ",[329,7729,1511],{"class":1182},[329,7731,6930],{"class":1182},[329,7733,7734],{"class":1172}," KMSClient",[329,7736,7737],{"class":505},"({ region: ",[329,7739,7740],{"class":516},"\"us-east-1\"",[329,7742,2241],{"class":505},[329,7744,7745],{"class":331,"line":348},[329,7746,340],{"emptyLinePlaceholder":284},[329,7748,7749,7752,7754,7757,7759,7762,7764,7766,7768,7770,7773,7775,7778],{"class":331,"line":354},[329,7750,7751],{"class":505},"Async ",[329,7753,2088],{"class":1182},[329,7755,7756],{"class":1172}," encryptWithKms",[329,7758,1437],{"class":505},[329,7760,7761],{"class":1482},"plaintext",[329,7763,2099],{"class":1182},[329,7765,4556],{"class":585},[329,7767,4632],{"class":505},[329,7769,2099],{"class":1182},[329,7771,7772],{"class":1172}," Promise",[329,7774,1652],{"class":505},[329,7776,7777],{"class":585},"string",[329,7779,7780],{"class":505},"> {\n",[329,7782,7783,7785,7788,7790,7792,7795],{"class":331,"line":360},[329,7784,1910],{"class":1182},[329,7786,7787],{"class":585}," command",[329,7789,1916],{"class":1182},[329,7791,6930],{"class":1182},[329,7793,7794],{"class":1172}," EncryptCommand",[329,7796,2222],{"class":505},[329,7798,7799,7802,7805,7807],{"class":331,"line":286},[329,7800,7801],{"class":505}," KeyId: process.env.",[329,7803,7804],{"class":585},"KMS_KEY_ID",[329,7806,2431],{"class":1182},[329,7808,1540],{"class":505},[329,7810,7811,7814,7816],{"class":331,"line":370},[329,7812,7813],{"class":505}," Plaintext: Buffer.",[329,7815,1405],{"class":1172},[329,7817,7818],{"class":505},"(plaintext),\n",[329,7820,7821],{"class":331,"line":375},[329,7822,2241],{"class":505},[329,7824,7825,7827,7829,7831,7833,7836,7839],{"class":331,"line":381},[329,7826,1910],{"class":1182},[329,7828,2212],{"class":585},[329,7830,1916],{"class":1182},[329,7832,7628],{"class":1182},[329,7834,7835],{"class":505}," kms.",[329,7837,7838],{"class":1172},"send",[329,7840,7841],{"class":505},"(command);\n",[329,7843,7844,7846,7848,7850,7853,7855,7857,7859,7861,7863],{"class":331,"line":387},[329,7845,1431],{"class":1182},[329,7847,2116],{"class":505},[329,7849,1405],{"class":1172},[329,7851,7852],{"class":505},"(response.CiphertextBlob",[329,7854,2431],{"class":1182},[329,7856,1443],{"class":505},[329,7858,1446],{"class":1172},[329,7860,1437],{"class":505},[329,7862,1451],{"class":516},[329,7864,1454],{"class":505},[329,7866,7867],{"class":331,"line":392},[329,7868,1459],{"class":505},[329,7870,7871],{"class":331,"line":398},[329,7872,340],{"emptyLinePlaceholder":284},[329,7874,7875,7877,7879,7882,7884,7886,7888,7890,7892,7894,7896,7898,7900],{"class":331,"line":403},[329,7876,7751],{"class":505},[329,7878,2088],{"class":1182},[329,7880,7881],{"class":1172}," decryptWithKms",[329,7883,1437],{"class":505},[329,7885,7244],{"class":1482},[329,7887,2099],{"class":1182},[329,7889,4556],{"class":585},[329,7891,4632],{"class":505},[329,7893,2099],{"class":1182},[329,7895,7772],{"class":1172},[329,7897,1652],{"class":505},[329,7899,7777],{"class":585},[329,7901,7780],{"class":505},[329,7903,7904,7906,7908,7910,7912,7915],{"class":331,"line":409},[329,7905,1910],{"class":1182},[329,7907,7787],{"class":585},[329,7909,1916],{"class":1182},[329,7911,6930],{"class":1182},[329,7913,7914],{"class":1172}," DecryptCommand",[329,7916,2222],{"class":505},[329,7918,7919,7922,7924,7926,7928],{"class":331,"line":415},[329,7920,7921],{"class":505}," CiphertextBlob: Buffer.",[329,7923,1405],{"class":1172},[329,7925,7398],{"class":505},[329,7927,1451],{"class":516},[329,7929,7930],{"class":505},"),\n",[329,7932,7933],{"class":331,"line":420},[329,7934,2241],{"class":505},[329,7936,7937,7939,7941,7943,7945,7947,7949],{"class":331,"line":426},[329,7938,1910],{"class":1182},[329,7940,2212],{"class":585},[329,7942,1916],{"class":1182},[329,7944,7628],{"class":1182},[329,7946,7835],{"class":505},[329,7948,7838],{"class":1172},[329,7950,7841],{"class":505},[329,7952,7953,7955,7957,7959,7962,7964,7966,7968,7970,7972],{"class":331,"line":887},[329,7954,1431],{"class":1182},[329,7956,2116],{"class":505},[329,7958,1405],{"class":1172},[329,7960,7961],{"class":505},"(response.Plaintext",[329,7963,2431],{"class":1182},[329,7965,1443],{"class":505},[329,7967,1446],{"class":1172},[329,7969,1437],{"class":505},[329,7971,7155],{"class":516},[329,7973,1454],{"class":505},[329,7975,7976],{"class":331,"line":895},[329,7977,1459],{"class":505},[20,7979,7980],{},"This pattern means your application never holds the raw key material. The KMS holds the key. Every encryption and decryption is an API call that KMS logs. Access to the key is controlled by IAM policies.",[20,7982,7983,7986],{},[42,7984,7985],{},"Envelope encryption for bulk data."," For encrypting large amounts of data, do not call the KMS for every record — API latency and costs add up. Use envelope encryption: generate a data encryption key (DEK) locally, encrypt your data with the DEK, then encrypt the DEK with a KMS key master key. Store the encrypted DEK alongside the encrypted data. Decrypt the DEK with KMS when you need to decrypt data.",[15,7988,7990],{"id":7989},"data-in-memory","Data in Memory",[20,7992,7993],{},"Sensitive data in memory — passwords during authentication, decrypted PII, API keys — lingers longer than developers expect. Garbage collectors do not immediately reclaim memory. Memory dumps, core dumps, and swap files can expose this data.",[20,7995,7996],{},"In most high-level languages, you have limited control over when memory is reclaimed. Practical mitigations:",[20,7998,7999],{},"Do not store sensitive data in logs. A log statement that logs the full request body will log passwords, tokens, and personal data to disk. Redact sensitive fields explicitly.",[20,8001,8002],{},"Minimize the scope of sensitive variables. Decrypt data close to where you use it, use it, then let the variable go out of scope. Do not decrypt at the top of a function and use the decrypted value ten function calls later.",[20,8004,8005],{},"Use secure comparison functions for sensitive comparisons:",[321,8007,8009],{"className":1390,"code":8008,"language":1392,"meta":263,"style":263},"import { timingSafeEqual } from \"crypto\";\n\nFunction constantTimeEqual(a: string, b: string): boolean {\n if (a.length !== b.length) {\n // Return false but still do a comparison to prevent timing side channels\n timingSafeEqual(Buffer.from(a), Buffer.from(a));\n return false;\n }\n return timingSafeEqual(Buffer.from(a), Buffer.from(b));\n}\n",[108,8010,8011,8024,8028,8038,8056,8061,8078,8086,8090,8107],{"__ignoreMap":263},[329,8012,8013,8015,8018,8020,8022],{"class":331,"line":332},[329,8014,1399],{"class":1182},[329,8016,8017],{"class":505}," { timingSafeEqual } ",[329,8019,1405],{"class":1182},[329,8021,1408],{"class":516},[329,8023,1411],{"class":505},[329,8025,8026],{"class":331,"line":267},[329,8027,340],{"emptyLinePlaceholder":284},[329,8029,8030,8032,8035],{"class":331,"line":264},[329,8031,1420],{"class":505},[329,8033,8034],{"class":1172},"constantTimeEqual",[329,8036,8037],{"class":505},"(a: string, b: string): boolean {\n",[329,8039,8040,8042,8045,8047,8049,8052,8054],{"class":331,"line":348},[329,8041,2425],{"class":1182},[329,8043,8044],{"class":505}," (a.",[329,8046,4760],{"class":585},[329,8048,4763],{"class":1182},[329,8050,8051],{"class":505}," b.",[329,8053,4760],{"class":585},[329,8055,2105],{"class":505},[329,8057,8058],{"class":331,"line":354},[329,8059,8060],{"class":620}," // Return false but still do a comparison to prevent timing side channels\n",[329,8062,8063,8065,8068,8070,8073,8075],{"class":331,"line":360},[329,8064,4787],{"class":1172},[329,8066,8067],{"class":505},"(Buffer.",[329,8069,1405],{"class":1172},[329,8071,8072],{"class":505},"(a), Buffer.",[329,8074,1405],{"class":1172},[329,8076,8077],{"class":505},"(a));\n",[329,8079,8080,8082,8084],{"class":331,"line":286},[329,8081,1431],{"class":1182},[329,8083,4703],{"class":585},[329,8085,1411],{"class":505},[329,8087,8088],{"class":331,"line":370},[329,8089,2466],{"class":505},[329,8091,8092,8094,8096,8098,8100,8102,8104],{"class":331,"line":375},[329,8093,1431],{"class":1182},[329,8095,4787],{"class":1172},[329,8097,8067],{"class":505},[329,8099,1405],{"class":1172},[329,8101,8072],{"class":505},[329,8103,1405],{"class":1172},[329,8105,8106],{"class":505},"(b));\n",[329,8108,8109],{"class":331,"line":381},[329,8110,1459],{"class":505},[20,8112,8113],{},"The timing-safe comparison prevents timing attacks where an attacker measures response time differences to determine how many characters of a token they guessed correctly.",[20,8115,8116],{},"For applications handling very sensitive data (healthcare, financial), consider memory-secure libraries that zero memory buffers after use and prevent sensitive data from being paged to swap. These are rare requirements for most web applications but standard for high-security environments.",[30,8118],{},[20,8120,8121,8122,229],{},"If you want help designing a data encryption strategy for sensitive fields in your application or need a review of your current cryptographic implementation, book a session at ",[223,8123,225],{"href":225,"rel":8124},[227],[30,8126],{},[15,8128,235],{"id":234},[95,8130,8131,8135,8141,8145],{},[98,8132,8133],{},[223,8134,1333],{"href":2550},[98,8136,8137],{},[223,8138,8140],{"href":8139},"/blog/security-headers-web-apps","Security Headers for Web Applications: The Complete Configuration Guide",[98,8142,8143],{},[223,8144,2513],{"href":2512},[98,8146,8147],{},[223,8148,2519],{"href":2518},[1301,8150,8151],{},"html pre.shiki code .sAwPA, html code.shiki .sAwPA{--shiki-default:#6A737D}html pre.shiki code .snl16, html code.shiki .snl16{--shiki-default:#F97583}html pre.shiki code .sDLfK, html code.shiki .sDLfK{--shiki-default:#79B8FF}html pre.shiki code .svObZ, html code.shiki .svObZ{--shiki-default:#B392F0}html pre.shiki code .s95oV, html code.shiki .s95oV{--shiki-default:#E1E4E8}html pre.shiki code .sU2Wk, html code.shiki .sU2Wk{--shiki-default:#9ECBFF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html pre.shiki code .s9osk, html code.shiki .s9osk{--shiki-default:#FFAB70}",{"title":263,"searchDepth":264,"depth":264,"links":8153},[8154,8155,8156,8157,8158],{"id":6886,"depth":267,"text":6887},{"id":6986,"depth":267,"text":6987},{"id":7683,"depth":267,"text":7684},{"id":7989,"depth":267,"text":7990},{"id":234,"depth":267,"text":235},"A developer's guide to data encryption — encrypting database fields, TLS in transit, key management patterns, and handling sensitive data in memory without leakage.",[8161,8162],"data encryption","application security",{},{"title":2531,"description":8159},"blog/data-encryption-guide",[8167,1329,8168,8169],"Data Encryption","Cryptography","Database","FeSlBtcC3BUPVOa9Q6WkZje1GII_GfBvjBvIXaFNlS8",{"id":8172,"title":8173,"author":8174,"body":8175,"category":274,"date":275,"description":9851,"extension":277,"featured":278,"image":279,"keywords":9852,"meta":9855,"navigation":284,"path":9856,"readTime":286,"seo":9857,"stem":9858,"tags":9859,"__hash__":9861},"blog/blog/database-backup-strategies.md","Database Backup Strategies for Production: The Ones That Actually Work",{"name":9,"bio":10},{"type":12,"value":8176,"toc":9839},[8177,8180,8183,8187,8190,8200,8206,8212,8218,8221,8225,8234,8240,8243,8251,8255,8258,8573,8576,8582,8585,8748,8752,8755,8758,8790,8793,8867,8870,8959,8963,8966,8971,8985,8990,9001,9004,9008,9011,9055,9058,9099,9102,9105,9109,9112,9144,9147,9292,9296,9299,9370,9373,9620,9624,9627,9795,9798,9800,9806,9808,9810,9836],[20,8178,8179],{},"Most developers think about backups after they need them. By then it is too late to find out that the backup process was silently failing, that the backup files were corrupted, or that the restore procedure takes six hours and was never tested. A backup that has never been restored is not a backup — it is a ritual that makes you feel better.",[20,8181,8182],{},"This article walks through backup strategies that actually protect your data, and the restore testing that proves they work.",[15,8184,8186],{"id":8185},"what-you-are-actually-protecting-against","What You Are Actually Protecting Against",[20,8188,8189],{},"Different threats require different backup strategies:",[20,8191,8192,8195,8196,8199],{},[42,8193,8194],{},"Accidental deletion or corruption."," A developer runs ",[108,8197,8198],{},"DELETE FROM users"," without a WHERE clause. A bug corrupts rows in the database. You need to recover from a specific point in time before the mistake.",[20,8201,8202,8205],{},[42,8203,8204],{},"Infrastructure failure."," Your cloud provider has an outage, a disk fails, the database instance terminates. You need to restore to a new instance quickly.",[20,8207,8208,8211],{},[42,8209,8210],{},"Ransomware or security breach."," An attacker encrypts or destroys your data. You need an offline copy that cannot be reached by the attacker.",[20,8213,8214,8217],{},[42,8215,8216],{},"Disaster recovery."," Your entire region goes offline. You need to restore in a different region.",[20,8219,8220],{},"Each of these requires a different element of your backup strategy.",[15,8222,8224],{"id":8223},"physical-vs-logical-backups","Physical vs Logical Backups",[20,8226,8227,2428,8230,8233],{},[42,8228,8229],{},"Logical backups",[108,8231,8232],{},"pg_dump",") export the data as SQL statements. They are database-version independent, human-readable, and easy to restore specific tables or rows from.",[20,8235,8236,8239],{},[42,8237,8238],{},"Physical backups"," copy the actual database files. They are faster for large databases, require the same PostgreSQL version to restore, but support point-in-time recovery (PITR) when combined with WAL archiving.",[20,8241,8242],{},"For most production databases, use both:",[95,8244,8245,8248],{},[98,8246,8247],{},"Logical backups for selective restores and migration safety",[98,8249,8250],{},"Physical backups with WAL archiving for PITR",[15,8252,8254],{"id":8253},"setting-up-automated-logical-backups","Setting Up Automated Logical Backups",[20,8256,8257],{},"A simple backup script:",[321,8259,8261],{"className":1158,"code":8260,"language":1160,"meta":263,"style":263},"#!/bin/bash\n# backup.sh\n\nSet -euo pipefail\n\nTIMESTAMP=$(date +%Y%m%d_%H%M%S)\nBACKUP_FILE=\"backup_${TIMESTAMP}.sql.gz\"\nS3_BUCKET=\"s3://your-backup-bucket/postgres\"\nRETENTION_DAYS=30\n\n# Create backup\npg_dump \\\n --format=custom \\\n --compress=9 \\\n --no-owner \\\n --no-acl \\\n \"${DATABASE_URL}\" \\\n | gzip > \"/tmp/${BACKUP_FILE}\"\n\n# Upload to S3\naws s3 cp \"/tmp/${BACKUP_FILE}\" \"${S3_BUCKET}/${BACKUP_FILE}\"\n\n# Clean up local file\nrm \"/tmp/${BACKUP_FILE}\"\n\n# Remove backups older than retention period\naws s3 ls \"${S3_BUCKET}/\" \\\n | awk '{print $4}' \\\n | sort \\\n | head -n -${RETENTION_DAYS} \\\n | xargs -I{} aws s3 rm \"${S3_BUCKET}/{}\"\n\nEcho \"Backup completed: ${BACKUP_FILE}\"\n",[108,8262,8263,8267,8272,8276,8287,8291,8308,8323,8333,8343,8347,8352,8359,8366,8373,8380,8387,8399,8417,8421,8426,8454,8458,8463,8474,8478,8483,8501,8513,8522,8542,8555,8560],{"__ignoreMap":263},[329,8264,8265],{"class":331,"line":332},[329,8266,2990],{"class":620},[329,8268,8269],{"class":331,"line":267},[329,8270,8271],{"class":620},"# backup.sh\n",[329,8273,8274],{"class":331,"line":264},[329,8275,340],{"emptyLinePlaceholder":284},[329,8277,8278,8281,8284],{"class":331,"line":348},[329,8279,8280],{"class":1172},"Set",[329,8282,8283],{"class":585}," -euo",[329,8285,8286],{"class":516}," pipefail\n",[329,8288,8289],{"class":331,"line":354},[329,8290,340],{"emptyLinePlaceholder":284},[329,8292,8293,8296,8298,8300,8303,8306],{"class":331,"line":360},[329,8294,8295],{"class":505},"TIMESTAMP",[329,8297,1511],{"class":1182},[329,8299,3060],{"class":505},[329,8301,8302],{"class":1172},"date",[329,8304,8305],{"class":516}," +%Y%m%d_%H%M%S",[329,8307,1611],{"class":505},[329,8309,8310,8313,8315,8318,8320],{"class":331,"line":286},[329,8311,8312],{"class":505},"BACKUP_FILE",[329,8314,1511],{"class":1182},[329,8316,8317],{"class":516},"\"backup_${",[329,8319,8295],{"class":505},[329,8321,8322],{"class":516},"}.sql.gz\"\n",[329,8324,8325,8328,8330],{"class":331,"line":370},[329,8326,8327],{"class":505},"S3_BUCKET",[329,8329,1511],{"class":1182},[329,8331,8332],{"class":516},"\"s3://your-backup-bucket/postgres\"\n",[329,8334,8335,8338,8340],{"class":331,"line":375},[329,8336,8337],{"class":505},"RETENTION_DAYS",[329,8339,1511],{"class":1182},[329,8341,8342],{"class":516},"30\n",[329,8344,8345],{"class":331,"line":381},[329,8346,340],{"emptyLinePlaceholder":284},[329,8348,8349],{"class":331,"line":387},[329,8350,8351],{"class":620},"# Create backup\n",[329,8353,8354,8356],{"class":331,"line":392},[329,8355,8232],{"class":1172},[329,8357,8358],{"class":585}," \\\n",[329,8360,8361,8364],{"class":331,"line":398},[329,8362,8363],{"class":585}," --format=custom",[329,8365,8358],{"class":585},[329,8367,8368,8371],{"class":331,"line":403},[329,8369,8370],{"class":585}," --compress=9",[329,8372,8358],{"class":585},[329,8374,8375,8378],{"class":331,"line":409},[329,8376,8377],{"class":585}," --no-owner",[329,8379,8358],{"class":585},[329,8381,8382,8385],{"class":331,"line":415},[329,8383,8384],{"class":585}," --no-acl",[329,8386,8358],{"class":585},[329,8388,8389,8392,8394,8397],{"class":331,"line":420},[329,8390,8391],{"class":516}," \"${",[329,8393,6953],{"class":505},[329,8395,8396],{"class":516},"}\"",[329,8398,8358],{"class":585},[329,8400,8401,8403,8406,8409,8412,8414],{"class":331,"line":426},[329,8402,1183],{"class":1182},[329,8404,8405],{"class":1172}," gzip",[329,8407,8408],{"class":1182}," >",[329,8410,8411],{"class":516}," \"/tmp/${",[329,8413,8312],{"class":505},[329,8415,8416],{"class":516},"}\"\n",[329,8418,8419],{"class":331,"line":887},[329,8420,340],{"emptyLinePlaceholder":284},[329,8422,8423],{"class":331,"line":895},[329,8424,8425],{"class":620},"# Upload to S3\n",[329,8427,8428,8431,8434,8437,8439,8441,8443,8445,8447,8450,8452],{"class":331,"line":906},[329,8429,8430],{"class":1172},"aws",[329,8432,8433],{"class":516}," s3",[329,8435,8436],{"class":516}," cp",[329,8438,8411],{"class":516},[329,8440,8312],{"class":505},[329,8442,8396],{"class":516},[329,8444,8391],{"class":516},[329,8446,8327],{"class":505},[329,8448,8449],{"class":516},"}/${",[329,8451,8312],{"class":505},[329,8453,8416],{"class":516},[329,8455,8456],{"class":331,"line":914},[329,8457,340],{"emptyLinePlaceholder":284},[329,8459,8460],{"class":331,"line":923},[329,8461,8462],{"class":620},"# Clean up local file\n",[329,8464,8465,8468,8470,8472],{"class":331,"line":2282},[329,8466,8467],{"class":1172},"rm",[329,8469,8411],{"class":516},[329,8471,8312],{"class":505},[329,8473,8416],{"class":516},[329,8475,8476],{"class":331,"line":2290},[329,8477,340],{"emptyLinePlaceholder":284},[329,8479,8480],{"class":331,"line":7384},[329,8481,8482],{"class":620},"# Remove backups older than retention period\n",[329,8484,8485,8487,8489,8492,8494,8496,8499],{"class":331,"line":7409},[329,8486,8430],{"class":1172},[329,8488,8433],{"class":516},[329,8490,8491],{"class":516}," ls",[329,8493,8391],{"class":516},[329,8495,8327],{"class":505},[329,8497,8498],{"class":516},"}/\"",[329,8500,8358],{"class":585},[329,8502,8503,8505,8508,8511],{"class":331,"line":7426},[329,8504,1183],{"class":1182},[329,8506,8507],{"class":1172}," awk",[329,8509,8510],{"class":516}," '{print $4}'",[329,8512,8358],{"class":585},[329,8514,8515,8517,8520],{"class":331,"line":7434},[329,8516,1183],{"class":1182},[329,8518,8519],{"class":1172}," sort",[329,8521,8358],{"class":585},[329,8523,8525,8527,8530,8532,8535,8537,8540],{"class":331,"line":8524},30,[329,8526,1183],{"class":1182},[329,8528,8529],{"class":1172}," head",[329,8531,1742],{"class":585},[329,8533,8534],{"class":585}," -${",[329,8536,8337],{"class":505},[329,8538,8539],{"class":585},"}",[329,8541,8358],{"class":585},[329,8543,8545,8547,8549,8552],{"class":331,"line":8544},31,[329,8546,1183],{"class":1182},[329,8548,1186],{"class":1172},[329,8550,8551],{"class":585}," -I",[329,8553,8554],{"class":505},"{} aws s3 rm \"${S3_BUCKET}/{}\"\n",[329,8556,8558],{"class":331,"line":8557},32,[329,8559,340],{"emptyLinePlaceholder":284},[329,8561,8563,8566,8569,8571],{"class":331,"line":8562},33,[329,8564,8565],{"class":1172},"Echo",[329,8567,8568],{"class":516}," \"Backup completed: ${",[329,8570,8312],{"class":505},[329,8572,8416],{"class":516},[20,8574,8575],{},"Schedule with cron (daily at 2am):",[321,8577,8580],{"className":8578,"code":8579,"language":1775},[1773],"0 2 * * * /path/to/backup.sh >> /var/log/db-backup.log 2>&1\n",[108,8581,8579],{"__ignoreMap":263},[20,8583,8584],{},"Or as a Kubernetes CronJob if you are running in containers:",[321,8586,8588],{"className":496,"code":8587,"language":498,"meta":263,"style":263},"apiVersion: batch/v1\nkind: CronJob\nmetadata:\n name: postgres-backup\nspec:\n schedule: \"0 2 * * *\"\n jobTemplate:\n spec:\n template:\n spec:\n containers:\n - name: backup\n image: postgres:16\n command: [\"/bin/bash\", \"/scripts/backup.sh\"]\n envFrom:\n - secretRef:\n name: postgres-secrets\n restartPolicy: OnFailure\n",[108,8589,8590,8600,8610,8617,8626,8633,8643,8650,8657,8664,8670,8677,8688,8697,8713,8720,8729,8738],{"__ignoreMap":263},[329,8591,8592,8595,8597],{"class":331,"line":332},[329,8593,8594],{"class":509},"apiVersion",[329,8596,513],{"class":505},[329,8598,8599],{"class":516},"batch/v1\n",[329,8601,8602,8605,8607],{"class":331,"line":267},[329,8603,8604],{"class":509},"kind",[329,8606,513],{"class":505},[329,8608,8609],{"class":516},"CronJob\n",[329,8611,8612,8615],{"class":331,"line":264},[329,8613,8614],{"class":509},"metadata",[329,8616,535],{"class":505},[329,8618,8619,8621,8623],{"class":331,"line":348},[329,8620,4863],{"class":509},[329,8622,513],{"class":505},[329,8624,8625],{"class":516},"postgres-backup\n",[329,8627,8628,8631],{"class":331,"line":354},[329,8629,8630],{"class":509},"spec",[329,8632,535],{"class":505},[329,8634,8635,8638,8640],{"class":331,"line":360},[329,8636,8637],{"class":509}," schedule",[329,8639,513],{"class":505},[329,8641,8642],{"class":516},"\"0 2 * * *\"\n",[329,8644,8645,8648],{"class":331,"line":286},[329,8646,8647],{"class":509}," jobTemplate",[329,8649,535],{"class":505},[329,8651,8652,8655],{"class":331,"line":370},[329,8653,8654],{"class":509}," spec",[329,8656,535],{"class":505},[329,8658,8659,8662],{"class":331,"line":375},[329,8660,8661],{"class":509}," template",[329,8663,535],{"class":505},[329,8665,8666,8668],{"class":331,"line":381},[329,8667,8654],{"class":509},[329,8669,535],{"class":505},[329,8671,8672,8675],{"class":331,"line":387},[329,8673,8674],{"class":509}," containers",[329,8676,535],{"class":505},[329,8678,8679,8681,8683,8685],{"class":331,"line":392},[329,8680,666],{"class":505},[329,8682,510],{"class":509},[329,8684,513],{"class":505},[329,8686,8687],{"class":516},"backup\n",[329,8689,8690,8692,8694],{"class":331,"line":398},[329,8691,640],{"class":509},[329,8693,513],{"class":505},[329,8695,8696],{"class":516},"postgres:16\n",[329,8698,8699,8701,8703,8706,8708,8711],{"class":331,"line":403},[329,8700,7787],{"class":509},[329,8702,2736],{"class":505},[329,8704,8705],{"class":516},"\"/bin/bash\"",[329,8707,1486],{"class":505},[329,8709,8710],{"class":516},"\"/scripts/backup.sh\"",[329,8712,2742],{"class":505},[329,8714,8715,8718],{"class":331,"line":409},[329,8716,8717],{"class":509}," envFrom",[329,8719,535],{"class":505},[329,8721,8722,8724,8727],{"class":331,"line":415},[329,8723,666],{"class":505},[329,8725,8726],{"class":509},"secretRef",[329,8728,535],{"class":505},[329,8730,8731,8733,8735],{"class":331,"line":420},[329,8732,4863],{"class":509},[329,8734,513],{"class":505},[329,8736,8737],{"class":516},"postgres-secrets\n",[329,8739,8740,8743,8745],{"class":331,"line":426},[329,8741,8742],{"class":509}," restartPolicy",[329,8744,513],{"class":505},[329,8746,8747],{"class":516},"OnFailure\n",[15,8749,8751],{"id":8750},"point-in-time-recovery-with-wal-archiving","Point-in-Time Recovery With WAL Archiving",[20,8753,8754],{},"WAL (Write-Ahead Log) archiving lets you restore the database to any point in time by replaying log files. Combined with a base backup, this is the most powerful recovery option.",[20,8756,8757],{},"Configure PostgreSQL to archive WAL files:",[321,8759,8763],{"className":8760,"code":8761,"language":8762,"meta":263,"style":263},"language-ini shiki shiki-themes github-dark","# postgresql.conf\nwal_level = replica\narchive_mode = on\narchive_command = 'aws s3 cp %p s3://your-wal-bucket/%f'\narchive_timeout = 60 # Force WAL switch every 60 seconds\n","ini",[108,8764,8765,8770,8775,8780,8785],{"__ignoreMap":263},[329,8766,8767],{"class":331,"line":332},[329,8768,8769],{},"# postgresql.conf\n",[329,8771,8772],{"class":331,"line":267},[329,8773,8774],{},"wal_level = replica\n",[329,8776,8777],{"class":331,"line":264},[329,8778,8779],{},"archive_mode = on\n",[329,8781,8782],{"class":331,"line":348},[329,8783,8784],{},"archive_command = 'aws s3 cp %p s3://your-wal-bucket/%f'\n",[329,8786,8787],{"class":331,"line":354},[329,8788,8789],{},"archive_timeout = 60 # Force WAL switch every 60 seconds\n",[20,8791,8792],{},"Take a base physical backup periodically:",[321,8794,8796],{"className":1158,"code":8795,"language":1160,"meta":263,"style":263},"# Full physical backup with pg_basebackup\npg_basebackup \\\n --host=${PGHOST} \\\n --username=${PGUSER} \\\n --format=tar \\\n --gzip \\\n --checkpoint=fast \\\n --wal-method=stream \\\n --output-target-dir=/tmp/base_backup\n",[108,8797,8798,8803,8810,8822,8834,8841,8848,8855,8862],{"__ignoreMap":263},[329,8799,8800],{"class":331,"line":332},[329,8801,8802],{"class":620},"# Full physical backup with pg_basebackup\n",[329,8804,8805,8808],{"class":331,"line":267},[329,8806,8807],{"class":1172},"pg_basebackup",[329,8809,8358],{"class":585},[329,8811,8812,8815,8818,8820],{"class":331,"line":264},[329,8813,8814],{"class":585}," --host=${",[329,8816,8817],{"class":505},"PGHOST",[329,8819,8539],{"class":585},[329,8821,8358],{"class":585},[329,8823,8824,8827,8830,8832],{"class":331,"line":348},[329,8825,8826],{"class":585}," --username=${",[329,8828,8829],{"class":505},"PGUSER",[329,8831,8539],{"class":585},[329,8833,8358],{"class":585},[329,8835,8836,8839],{"class":331,"line":354},[329,8837,8838],{"class":585}," --format=tar",[329,8840,8358],{"class":585},[329,8842,8843,8846],{"class":331,"line":360},[329,8844,8845],{"class":585}," --gzip",[329,8847,8358],{"class":585},[329,8849,8850,8853],{"class":331,"line":286},[329,8851,8852],{"class":585}," --checkpoint=fast",[329,8854,8358],{"class":585},[329,8856,8857,8860],{"class":331,"line":370},[329,8858,8859],{"class":585}," --wal-method=stream",[329,8861,8358],{"class":585},[329,8863,8864],{"class":331,"line":375},[329,8865,8866],{"class":585}," --output-target-dir=/tmp/base_backup\n",[20,8868,8869],{},"To restore to a specific point in time:",[321,8871,8873],{"className":1158,"code":8872,"language":1160,"meta":263,"style":263},"# Restore the base backup\ntar -xzf base_backup.tar.gz -C /var/lib/postgresql/data/\n\n# Create recovery configuration\ncat > /var/lib/postgresql/data/recovery.conf \u003C\u003C EOF\nrestore_command = 'aws s3 cp s3://your-wal-bucket/%f %p'\nrecovery_target_time = '2026-03-03 14:00:00'\nrecovery_target_action = 'promote'\nEOF\n\n# Start PostgreSQL — it will replay WAL until the target time\npg_ctl start\n",[108,8874,8875,8880,8897,8901,8906,8922,8927,8932,8937,8942,8946,8951],{"__ignoreMap":263},[329,8876,8877],{"class":331,"line":332},[329,8878,8879],{"class":620},"# Restore the base backup\n",[329,8881,8882,8885,8888,8891,8894],{"class":331,"line":267},[329,8883,8884],{"class":1172},"tar",[329,8886,8887],{"class":585}," -xzf",[329,8889,8890],{"class":516}," base_backup.tar.gz",[329,8892,8893],{"class":585}," -C",[329,8895,8896],{"class":516}," /var/lib/postgresql/data/\n",[329,8898,8899],{"class":331,"line":264},[329,8900,340],{"emptyLinePlaceholder":284},[329,8902,8903],{"class":331,"line":348},[329,8904,8905],{"class":620},"# Create recovery configuration\n",[329,8907,8908,8911,8913,8916,8919],{"class":331,"line":354},[329,8909,8910],{"class":1172},"cat",[329,8912,8408],{"class":1182},[329,8914,8915],{"class":516}," /var/lib/postgresql/data/recovery.conf",[329,8917,8918],{"class":1182}," \u003C\u003C",[329,8920,8921],{"class":516}," EOF\n",[329,8923,8924],{"class":331,"line":360},[329,8925,8926],{"class":516},"restore_command = 'aws s3 cp s3://your-wal-bucket/%f %p'\n",[329,8928,8929],{"class":331,"line":286},[329,8930,8931],{"class":516},"recovery_target_time = '2026-03-03 14:00:00'\n",[329,8933,8934],{"class":331,"line":370},[329,8935,8936],{"class":516},"recovery_target_action = 'promote'\n",[329,8938,8939],{"class":331,"line":375},[329,8940,8941],{"class":516},"EOF\n",[329,8943,8944],{"class":331,"line":381},[329,8945,340],{"emptyLinePlaceholder":284},[329,8947,8948],{"class":331,"line":387},[329,8949,8950],{"class":620},"# Start PostgreSQL — it will replay WAL until the target time\n",[329,8952,8953,8956],{"class":331,"line":392},[329,8954,8955],{"class":1172},"pg_ctl",[329,8957,8958],{"class":516}," start\n",[15,8960,8962],{"id":8961},"managed-database-backup-features","Managed Database Backup Features",[20,8964,8965],{},"If you are using a managed PostgreSQL service (AWS RDS, Google Cloud SQL, Supabase, Neon), they provide automated backups and PITR out of the box. Understand what is and is not covered:",[20,8967,8968],{},[42,8969,8970],{},"What managed backups provide:",[95,8972,8973,8976,8979,8982],{},[98,8974,8975],{},"Automated daily or continuous backups",[98,8977,8978],{},"PITR to any second within the retention window",[98,8980,8981],{},"Cross-region backup replication (usually a paid option)",[98,8983,8984],{},"One-click restore",[20,8986,8987],{},[42,8988,8989],{},"What they do not protect against:",[95,8991,8992,8995,8998],{},[98,8993,8994],{},"Application-level data corruption (wrong data written correctly)",[98,8996,8997],{},"Account-level incidents (your cloud account compromised)",[98,8999,9000],{},"Cross-region disasters if backup replication is not enabled",[20,9002,9003],{},"Supplement managed backups with your own logical backups to an independent destination (different cloud provider, different account).",[15,9005,9007],{"id":9006},"backup-encryption-and-security","Backup Encryption and Security",[20,9009,9010],{},"Backups containing user data must be encrypted at rest. Most S3-compatible storage supports server-side encryption:",[321,9012,9014],{"className":1158,"code":9013,"language":1160,"meta":263,"style":263},"# Encrypt during upload\naws s3 cp backup.sql.gz s3://bucket/backup.sql.gz \\\n --server-side-encryption aws:kms \\\n --ssekms-key-id your-kms-key-id\n",[108,9015,9016,9021,9037,9047],{"__ignoreMap":263},[329,9017,9018],{"class":331,"line":332},[329,9019,9020],{"class":620},"# Encrypt during upload\n",[329,9022,9023,9025,9027,9029,9032,9035],{"class":331,"line":267},[329,9024,8430],{"class":1172},[329,9026,8433],{"class":516},[329,9028,8436],{"class":516},[329,9030,9031],{"class":516}," backup.sql.gz",[329,9033,9034],{"class":516}," s3://bucket/backup.sql.gz",[329,9036,8358],{"class":585},[329,9038,9039,9042,9045],{"class":331,"line":264},[329,9040,9041],{"class":585}," --server-side-encryption",[329,9043,9044],{"class":516}," aws:kms",[329,9046,8358],{"class":585},[329,9048,9049,9052],{"class":331,"line":348},[329,9050,9051],{"class":585}," --ssekms-key-id",[329,9053,9054],{"class":516}," your-kms-key-id\n",[20,9056,9057],{},"For additional protection, encrypt before uploading:",[321,9059,9061],{"className":1158,"code":9060,"language":1160,"meta":263,"style":263},"# Encrypt with GPG\ngpg --symmetric --cipher-algo AES256 backup.sql.gz\naws s3 cp backup.sql.gz.gpg s3://bucket/backup.sql.gz.gpg\n",[108,9062,9063,9068,9085],{"__ignoreMap":263},[329,9064,9065],{"class":331,"line":332},[329,9066,9067],{"class":620},"# Encrypt with GPG\n",[329,9069,9070,9073,9076,9079,9082],{"class":331,"line":267},[329,9071,9072],{"class":1172},"gpg",[329,9074,9075],{"class":585}," --symmetric",[329,9077,9078],{"class":585}," --cipher-algo",[329,9080,9081],{"class":516}," AES256",[329,9083,9084],{"class":516}," backup.sql.gz\n",[329,9086,9087,9089,9091,9093,9096],{"class":331,"line":264},[329,9088,8430],{"class":1172},[329,9090,8433],{"class":516},[329,9092,8436],{"class":516},[329,9094,9095],{"class":516}," backup.sql.gz.gpg",[329,9097,9098],{"class":516}," s3://bucket/backup.sql.gz.gpg\n",[20,9100,9101],{},"Store encryption keys separately from backups. A backup encrypted with a key that lives in the same compromised system is not protected.",[20,9103,9104],{},"For offline protection against ransomware, store at least one backup copy in a write-once storage tier (S3 Object Lock, or a physically separate offline store) that cannot be modified or deleted by the credentials your application uses.",[15,9106,9108],{"id":9107},"retention-policy","Retention Policy",[20,9110,9111],{},"A sensible retention policy:",[95,9113,9114,9120,9126,9132,9138],{},[98,9115,9116,9119],{},[42,9117,9118],{},"Continuous WAL archiving:"," 7-30 days (enables PITR within the window)",[98,9121,9122,9125],{},[42,9123,9124],{},"Daily logical backups:"," 30 days",[98,9127,9128,9131],{},[42,9129,9130],{},"Weekly backups:"," 3 months",[98,9133,9134,9137],{},[42,9135,9136],{},"Monthly backups:"," 1 year",[98,9139,9140,9143],{},[42,9141,9142],{},"Annual backups:"," 7 years (regulatory requirement for some industries)",[20,9145,9146],{},"Automate retention cleanup — manually managing this is error-prone. S3 lifecycle policies handle this:",[321,9148,9151],{"className":9149,"code":9150,"language":1884,"meta":263,"style":263},"language-json shiki shiki-themes github-dark","{\n \"Rules\": [\n {\n \"Id\": \"daily-backup-retention\",\n \"Prefix\": \"daily/\",\n \"Status\": \"Enabled\",\n \"Expiration\": { \"Days\": 30 }\n },\n {\n \"Id\": \"weekly-backup-retention\",\n \"Prefix\": \"weekly/\",\n \"Status\": \"Enabled\",\n \"Expiration\": { \"Days\": 90 }\n }\n ]\n}\n",[108,9152,9153,9158,9166,9170,9182,9194,9206,9224,9228,9232,9243,9254,9264,9279,9283,9288],{"__ignoreMap":263},[329,9154,9155],{"class":331,"line":332},[329,9156,9157],{"class":505},"{\n",[329,9159,9160,9163],{"class":331,"line":267},[329,9161,9162],{"class":585}," \"Rules\"",[329,9164,9165],{"class":505},": [\n",[329,9167,9168],{"class":331,"line":264},[329,9169,1503],{"class":505},[329,9171,9172,9175,9177,9180],{"class":331,"line":348},[329,9173,9174],{"class":585}," \"Id\"",[329,9176,513],{"class":505},[329,9178,9179],{"class":516},"\"daily-backup-retention\"",[329,9181,1540],{"class":505},[329,9183,9184,9187,9189,9192],{"class":331,"line":354},[329,9185,9186],{"class":585}," \"Prefix\"",[329,9188,513],{"class":505},[329,9190,9191],{"class":516},"\"daily/\"",[329,9193,1540],{"class":505},[329,9195,9196,9199,9201,9204],{"class":331,"line":360},[329,9197,9198],{"class":585}," \"Status\"",[329,9200,513],{"class":505},[329,9202,9203],{"class":516},"\"Enabled\"",[329,9205,1540],{"class":505},[329,9207,9208,9211,9214,9217,9219,9222],{"class":331,"line":286},[329,9209,9210],{"class":585}," \"Expiration\"",[329,9212,9213],{"class":505},": { ",[329,9215,9216],{"class":585},"\"Days\"",[329,9218,513],{"class":505},[329,9220,9221],{"class":585},"30",[329,9223,2466],{"class":505},[329,9225,9226],{"class":331,"line":370},[329,9227,5048],{"class":505},[329,9229,9230],{"class":331,"line":375},[329,9231,1503],{"class":505},[329,9233,9234,9236,9238,9241],{"class":331,"line":381},[329,9235,9174],{"class":585},[329,9237,513],{"class":505},[329,9239,9240],{"class":516},"\"weekly-backup-retention\"",[329,9242,1540],{"class":505},[329,9244,9245,9247,9249,9252],{"class":331,"line":387},[329,9246,9186],{"class":585},[329,9248,513],{"class":505},[329,9250,9251],{"class":516},"\"weekly/\"",[329,9253,1540],{"class":505},[329,9255,9256,9258,9260,9262],{"class":331,"line":392},[329,9257,9198],{"class":585},[329,9259,513],{"class":505},[329,9261,9203],{"class":516},[329,9263,1540],{"class":505},[329,9265,9266,9268,9270,9272,9274,9277],{"class":331,"line":398},[329,9267,9210],{"class":585},[329,9269,9213],{"class":505},[329,9271,9216],{"class":585},[329,9273,513],{"class":505},[329,9275,9276],{"class":585},"90",[329,9278,2466],{"class":505},[329,9280,9281],{"class":331,"line":403},[329,9282,2466],{"class":505},[329,9284,9285],{"class":331,"line":409},[329,9286,9287],{"class":505}," ]\n",[329,9289,9290],{"class":331,"line":415},[329,9291,1459],{"class":505},[15,9293,9295],{"id":9294},"the-most-important-part-testing-restores","The Most Important Part: Testing Restores",[20,9297,9298],{},"A backup you have never restored is theoretical. Test your restore procedure quarterly at minimum:",[321,9300,9302],{"className":1158,"code":9301,"language":1160,"meta":263,"style":263},"# Restore to a test database\npg_restore \\\n --dbname=postgres \\\n --create \\\n --no-owner \\\n --verbose \\\n backup.sql.gz\n\n# Verify row counts match production\npsql -c \"SELECT schemaname, tablename, n_live_tup FROM pg_stat_user_tables ORDER BY n_live_tup DESC;\" test_db\n",[108,9303,9304,9309,9316,9323,9330,9336,9343,9347,9351,9356],{"__ignoreMap":263},[329,9305,9306],{"class":331,"line":332},[329,9307,9308],{"class":620},"# Restore to a test database\n",[329,9310,9311,9314],{"class":331,"line":267},[329,9312,9313],{"class":1172},"pg_restore",[329,9315,8358],{"class":585},[329,9317,9318,9321],{"class":331,"line":264},[329,9319,9320],{"class":585}," --dbname=postgres",[329,9322,8358],{"class":585},[329,9324,9325,9328],{"class":331,"line":348},[329,9326,9327],{"class":585}," --create",[329,9329,8358],{"class":585},[329,9331,9332,9334],{"class":331,"line":354},[329,9333,8377],{"class":585},[329,9335,8358],{"class":585},[329,9337,9338,9341],{"class":331,"line":360},[329,9339,9340],{"class":585}," --verbose",[329,9342,8358],{"class":585},[329,9344,9345],{"class":331,"line":286},[329,9346,9084],{"class":516},[329,9348,9349],{"class":331,"line":370},[329,9350,340],{"emptyLinePlaceholder":284},[329,9352,9353],{"class":331,"line":375},[329,9354,9355],{"class":620},"# Verify row counts match production\n",[329,9357,9358,9361,9364,9367],{"class":331,"line":381},[329,9359,9360],{"class":1172},"psql",[329,9362,9363],{"class":585}," -c",[329,9365,9366],{"class":516}," \"SELECT schemaname, tablename, n_live_tup FROM pg_stat_user_tables ORDER BY n_live_tup DESC;\"",[329,9368,9369],{"class":516}," test_db\n",[20,9371,9372],{},"Automated restore testing:",[321,9374,9376],{"className":1158,"code":9375,"language":1160,"meta":263,"style":263},"#!/bin/bash\n# test-restore.sh — Run weekly\n\nBACKUP_FILE=$(aws s3 ls s3://backup-bucket/ | sort | tail -1 | awk '{print $4}')\n\nAws s3 cp \"s3://backup-bucket/${BACKUP_FILE}\" /tmp/test-backup.sql.gz\n\n# Create test database\npsql -c \"DROP DATABASE IF EXISTS restore_test;\"\npsql -c \"CREATE DATABASE restore_test;\"\n\n# Restore\npg_restore --dbname=restore_test /tmp/test-backup.sql.gz\n\n# Verify basic integrity\nROW_COUNT=$(psql -t -c \"SELECT SUM(n_live_tup) FROM pg_stat_user_tables;\" restore_test)\necho \"Restored row count: ${ROW_COUNT}\"\n\nIf [ \"${ROW_COUNT}\" -lt \"1000\" ]; then\n echo \"WARNING: Suspicious row count after restore\"\n # Send alert\nfi\n\n# Clean up\npsql -c \"DROP DATABASE restore_test;\"\nrm /tmp/test-backup.sql.gz\n\nEcho \"Restore test completed successfully\"\n",[108,9377,9378,9382,9387,9391,9428,9432,9451,9455,9460,9469,9478,9482,9487,9496,9500,9505,9529,9540,9544,9569,9576,9581,9585,9589,9594,9603,9609,9613],{"__ignoreMap":263},[329,9379,9380],{"class":331,"line":332},[329,9381,2990],{"class":620},[329,9383,9384],{"class":331,"line":267},[329,9385,9386],{"class":620},"# test-restore.sh — Run weekly\n",[329,9388,9389],{"class":331,"line":264},[329,9390,340],{"emptyLinePlaceholder":284},[329,9392,9393,9395,9397,9399,9401,9403,9405,9408,9410,9412,9414,9417,9420,9422,9424,9426],{"class":331,"line":348},[329,9394,8312],{"class":505},[329,9396,1511],{"class":1182},[329,9398,3060],{"class":505},[329,9400,8430],{"class":1172},[329,9402,8433],{"class":516},[329,9404,8491],{"class":516},[329,9406,9407],{"class":516}," s3://backup-bucket/",[329,9409,1183],{"class":1182},[329,9411,8519],{"class":1172},[329,9413,1183],{"class":1182},[329,9415,9416],{"class":1172}," tail",[329,9418,9419],{"class":585}," -1",[329,9421,1183],{"class":1182},[329,9423,8507],{"class":1172},[329,9425,8510],{"class":516},[329,9427,1611],{"class":505},[329,9429,9430],{"class":331,"line":354},[329,9431,340],{"emptyLinePlaceholder":284},[329,9433,9434,9437,9439,9441,9444,9446,9448],{"class":331,"line":360},[329,9435,9436],{"class":1172},"Aws",[329,9438,8433],{"class":516},[329,9440,8436],{"class":516},[329,9442,9443],{"class":516}," \"s3://backup-bucket/${",[329,9445,8312],{"class":505},[329,9447,8396],{"class":516},[329,9449,9450],{"class":516}," /tmp/test-backup.sql.gz\n",[329,9452,9453],{"class":331,"line":286},[329,9454,340],{"emptyLinePlaceholder":284},[329,9456,9457],{"class":331,"line":370},[329,9458,9459],{"class":620},"# Create test database\n",[329,9461,9462,9464,9466],{"class":331,"line":375},[329,9463,9360],{"class":1172},[329,9465,9363],{"class":585},[329,9467,9468],{"class":516}," \"DROP DATABASE IF EXISTS restore_test;\"\n",[329,9470,9471,9473,9475],{"class":331,"line":381},[329,9472,9360],{"class":1172},[329,9474,9363],{"class":585},[329,9476,9477],{"class":516}," \"CREATE DATABASE restore_test;\"\n",[329,9479,9480],{"class":331,"line":387},[329,9481,340],{"emptyLinePlaceholder":284},[329,9483,9484],{"class":331,"line":392},[329,9485,9486],{"class":620},"# Restore\n",[329,9488,9489,9491,9494],{"class":331,"line":398},[329,9490,9313],{"class":1172},[329,9492,9493],{"class":585}," --dbname=restore_test",[329,9495,9450],{"class":516},[329,9497,9498],{"class":331,"line":403},[329,9499,340],{"emptyLinePlaceholder":284},[329,9501,9502],{"class":331,"line":409},[329,9503,9504],{"class":620},"# Verify basic integrity\n",[329,9506,9507,9510,9512,9514,9516,9519,9521,9524,9527],{"class":331,"line":415},[329,9508,9509],{"class":505},"ROW_COUNT",[329,9511,1511],{"class":1182},[329,9513,3060],{"class":505},[329,9515,9360],{"class":1172},[329,9517,9518],{"class":585}," -t",[329,9520,9363],{"class":585},[329,9522,9523],{"class":516}," \"SELECT SUM(n_live_tup) FROM pg_stat_user_tables;\"",[329,9525,9526],{"class":516}," restore_test",[329,9528,1611],{"class":505},[329,9530,9531,9533,9536,9538],{"class":331,"line":420},[329,9532,1739],{"class":585},[329,9534,9535],{"class":516}," \"Restored row count: ${",[329,9537,9509],{"class":505},[329,9539,8416],{"class":516},[329,9541,9542],{"class":331,"line":426},[329,9543,340],{"emptyLinePlaceholder":284},[329,9545,9546,9548,9550,9553,9555,9557,9560,9563,9565,9567],{"class":331,"line":887},[329,9547,3173],{"class":1172},[329,9549,3096],{"class":505},[329,9551,9552],{"class":516},"\"${",[329,9554,9509],{"class":505},[329,9556,8396],{"class":516},[329,9558,9559],{"class":585}," -lt",[329,9561,9562],{"class":516}," \"1000\"",[329,9564,3189],{"class":516},[329,9566,3192],{"class":505},[329,9568,3116],{"class":1182},[329,9570,9571,9573],{"class":331,"line":895},[329,9572,3121],{"class":585},[329,9574,9575],{"class":516}," \"WARNING: Suspicious row count after restore\"\n",[329,9577,9578],{"class":331,"line":906},[329,9579,9580],{"class":620}," # Send alert\n",[329,9582,9583],{"class":331,"line":914},[329,9584,3220],{"class":1182},[329,9586,9587],{"class":331,"line":923},[329,9588,340],{"emptyLinePlaceholder":284},[329,9590,9591],{"class":331,"line":2282},[329,9592,9593],{"class":620},"# Clean up\n",[329,9595,9596,9598,9600],{"class":331,"line":2290},[329,9597,9360],{"class":1172},[329,9599,9363],{"class":585},[329,9601,9602],{"class":516}," \"DROP DATABASE restore_test;\"\n",[329,9604,9605,9607],{"class":331,"line":7384},[329,9606,8467],{"class":1172},[329,9608,9450],{"class":516},[329,9610,9611],{"class":331,"line":7409},[329,9612,340],{"emptyLinePlaceholder":284},[329,9614,9615,9617],{"class":331,"line":7426},[329,9616,8565],{"class":1172},[329,9618,9619],{"class":516}," \"Restore test completed successfully\"\n",[15,9621,9623],{"id":9622},"monitoring-backup-health","Monitoring Backup Health",[20,9625,9626],{},"Alert when backups fail or are missing:",[321,9628,9630],{"className":1158,"code":9629,"language":1160,"meta":263,"style":263},"# Check that a backup file was created in the last 25 hours\nRECENT_BACKUP=$(aws s3 ls s3://backup-bucket/ \\\n | awk '{print $4}' \\\n | sort \\\n | tail -1)\n\nBACKUP_AGE_HOURS=$(python3 -c \"\nimport boto3, datetime\ns3 = boto3.client('s3')\nobj = s3.head_object(Bucket='backup-bucket', Key='${RECENT_BACKUP}')\nage = (datetime.datetime.now(datetime.timezone.utc) - obj['LastModified']).total_seconds() / 3600\nprint(f'{age:.1f}')\n\")\n\nIf (( $(echo \"${BACKUP_AGE_HOURS} > 25\" | bc -l) )); then\n echo \"ALERT: Most recent backup is ${BACKUP_AGE_HOURS} hours old\"\n # Send alert to your monitoring system\nfi\n",[108,9631,9632,9637,9656,9666,9674,9684,9688,9705,9710,9715,9725,9730,9735,9741,9745,9774,9786,9791],{"__ignoreMap":263},[329,9633,9634],{"class":331,"line":332},[329,9635,9636],{"class":620},"# Check that a backup file was created in the last 25 hours\n",[329,9638,9639,9642,9644,9646,9648,9650,9652,9654],{"class":331,"line":267},[329,9640,9641],{"class":505},"RECENT_BACKUP",[329,9643,1511],{"class":1182},[329,9645,3060],{"class":505},[329,9647,8430],{"class":1172},[329,9649,8433],{"class":516},[329,9651,8491],{"class":516},[329,9653,9407],{"class":516},[329,9655,8358],{"class":585},[329,9657,9658,9660,9662,9664],{"class":331,"line":264},[329,9659,1183],{"class":1182},[329,9661,8507],{"class":1172},[329,9663,8510],{"class":516},[329,9665,8358],{"class":585},[329,9667,9668,9670,9672],{"class":331,"line":348},[329,9669,1183],{"class":1182},[329,9671,8519],{"class":1172},[329,9673,8358],{"class":585},[329,9675,9676,9678,9680,9682],{"class":331,"line":354},[329,9677,1183],{"class":1182},[329,9679,9416],{"class":1172},[329,9681,9419],{"class":585},[329,9683,1611],{"class":505},[329,9685,9686],{"class":331,"line":360},[329,9687,340],{"emptyLinePlaceholder":284},[329,9689,9690,9693,9695,9697,9700,9702],{"class":331,"line":286},[329,9691,9692],{"class":505},"BACKUP_AGE_HOURS",[329,9694,1511],{"class":1182},[329,9696,3060],{"class":505},[329,9698,9699],{"class":1172},"python3",[329,9701,9363],{"class":585},[329,9703,9704],{"class":516}," \"\n",[329,9706,9707],{"class":331,"line":370},[329,9708,9709],{"class":516},"import boto3, datetime\n",[329,9711,9712],{"class":331,"line":375},[329,9713,9714],{"class":516},"s3 = boto3.client('s3')\n",[329,9716,9717,9720,9722],{"class":331,"line":381},[329,9718,9719],{"class":516},"obj = s3.head_object(Bucket='backup-bucket', Key='${",[329,9721,9641],{"class":505},[329,9723,9724],{"class":516},"}')\n",[329,9726,9727],{"class":331,"line":387},[329,9728,9729],{"class":516},"age = (datetime.datetime.now(datetime.timezone.utc) - obj['LastModified']).total_seconds() / 3600\n",[329,9731,9732],{"class":331,"line":392},[329,9733,9734],{"class":516},"print(f'{age:.1f}')\n",[329,9736,9737,9739],{"class":331,"line":398},[329,9738,3099],{"class":516},[329,9740,1611],{"class":505},[329,9742,9743],{"class":331,"line":403},[329,9744,340],{"emptyLinePlaceholder":284},[329,9746,9747,9749,9752,9754,9756,9758,9761,9763,9766,9769,9772],{"class":331,"line":409},[329,9748,3173],{"class":1172},[329,9750,9751],{"class":505}," (( $(",[329,9753,1739],{"class":585},[329,9755,8391],{"class":516},[329,9757,9692],{"class":505},[329,9759,9760],{"class":516},"} > 25\"",[329,9762,1183],{"class":1182},[329,9764,9765],{"class":1172}," bc",[329,9767,9768],{"class":585}," -l",[329,9770,9771],{"class":505},") )); ",[329,9773,3116],{"class":1182},[329,9775,9776,9778,9781,9783],{"class":331,"line":415},[329,9777,3121],{"class":585},[329,9779,9780],{"class":516}," \"ALERT: Most recent backup is ${",[329,9782,9692],{"class":505},[329,9784,9785],{"class":516},"} hours old\"\n",[329,9787,9788],{"class":331,"line":420},[329,9789,9790],{"class":620}," # Send alert to your monitoring system\n",[329,9792,9793],{"class":331,"line":426},[329,9794,3220],{"class":1182},[20,9796,9797],{},"Your backup strategy is only as good as the restore you have tested most recently. Build the testing into your operational routine, not as something to do when you remember.",[30,9799],{},[20,9801,9802,9803,229],{},"Need help designing a backup and recovery strategy for a production PostgreSQL database, or recovering from a data loss incident? This is exactly the kind of problem I work on with clients. Book a call: ",[223,9804,228],{"href":225,"rel":9805},[227],[30,9807],{},[15,9809,235],{"id":234},[95,9811,9812,9818,9824,9830],{},[98,9813,9814],{},[223,9815,9817],{"href":9816},"/blog/database-indexing-strategies","Database Indexing Strategies That Actually Make Queries Fast",[98,9819,9820],{},[223,9821,9823],{"href":9822},"/blog/database-migrations-guide","Database Migrations in Production: Zero-Downtime Strategies",[98,9825,9826],{},[223,9827,9829],{"href":9828},"/blog/database-query-performance","Database Query Performance: Finding and Fixing the Slow Ones",[98,9831,9832],{},[223,9833,9835],{"href":9834},"/blog/database-connection-pooling","Database Connection Pooling: Why It Matters and How to Configure It",[1301,9837,9838],{},"html pre.shiki code .sAwPA, html code.shiki .sAwPA{--shiki-default:#6A737D}html pre.shiki code .svObZ, html code.shiki .svObZ{--shiki-default:#B392F0}html pre.shiki code .sDLfK, html code.shiki .sDLfK{--shiki-default:#79B8FF}html pre.shiki code .sU2Wk, html code.shiki .sU2Wk{--shiki-default:#9ECBFF}html pre.shiki code .s95oV, html code.shiki .s95oV{--shiki-default:#E1E4E8}html pre.shiki code .snl16, html code.shiki .snl16{--shiki-default:#F97583}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html pre.shiki code .s4JwU, html code.shiki .s4JwU{--shiki-default:#85E89D}",{"title":263,"searchDepth":264,"depth":264,"links":9840},[9841,9842,9843,9844,9845,9846,9847,9848,9849,9850],{"id":8185,"depth":267,"text":8186},{"id":8223,"depth":267,"text":8224},{"id":8253,"depth":267,"text":8254},{"id":8750,"depth":267,"text":8751},{"id":8961,"depth":267,"text":8962},{"id":9006,"depth":267,"text":9007},{"id":9107,"depth":267,"text":9108},{"id":9294,"depth":267,"text":9295},{"id":9622,"depth":267,"text":9623},{"id":234,"depth":267,"text":235},"A practical guide to production database backups — physical vs logical backups, point-in-time recovery, automated backup testing, retention policies, and restoring when it matters.",[9853,9854],"database backup","production database",{},"/blog/database-backup-strategies",{"title":8173,"description":9851},"blog/database-backup-strategies",[8169,9860,6471],"PostgreSQL","tfPeFuqp17ILASomx3qFrjrCakk7N8C94IKJW4V-Un0",{"id":9863,"title":9835,"author":9864,"body":9865,"category":274,"date":275,"description":10603,"extension":277,"featured":278,"image":279,"keywords":10604,"meta":10607,"navigation":284,"path":9834,"readTime":286,"seo":10608,"stem":10609,"tags":10610,"__hash__":10611},"blog/blog/database-connection-pooling.md",{"name":9,"bio":10},{"type":12,"value":9866,"toc":10593},[9867,9870,9873,9877,9880,9883,9887,9890,9900,9906,9912,9916,9919,9967,9970,9976,9979,10002,10009,10013,10016,10023,10026,10029,10125,10129,10132,10135,10141,10147,10153,10156,10272,10275,10280,10283,10299,10305,10309,10312,10315,10321,10328,10390,10408,10412,10415,10421,10427,10433,10557,10560,10562,10568,10570,10572,10590],[20,9868,9869],{},"Database connections are expensive. Each PostgreSQL connection consumes roughly 5-10MB of server memory and requires a dedicated process on the database server. When you have 20 connections, that is manageable. When you have 200, you are running into the database server's resource limits. When you have 2,000 — which happens quickly with serverless or container deployments — you are actively degrading database performance.",[20,9871,9872],{},"Connection pooling is how you solve this.",[15,9874,9876],{"id":9875},"what-a-connection-pool-does","What a Connection Pool Does",[20,9878,9879],{},"A connection pool maintains a set of open database connections that your application shares. Instead of opening a new connection for every request (expensive) and closing it when done, your application borrows a connection from the pool, uses it, and returns it. The pool keeps connections warm and ready.",[20,9881,9882],{},"The key insight: most web application requests touch the database for a few milliseconds out of a request lifecycle that might take 50-200ms. During that idle time, the connection could serve other requests. A pool of 20 connections can serve hundreds of concurrent requests efficiently.",[15,9884,9886],{"id":9885},"connection-pool-architecture-options","Connection Pool Architecture Options",[20,9888,9889],{},"There are three places where connection pooling can happen:",[20,9891,9892,9895,9896,9899],{},[42,9893,9894],{},"Application-level pooling"," (Prisma, ",[108,9897,9898],{},"pg"," connection pool, Sequelize): The pool lives in your application process. Simple to configure, no additional infrastructure.",[20,9901,9902,9905],{},[42,9903,9904],{},"External process pooling"," (PgBouncer): A separate proxy process manages connections. Multiple application instances share one pool. Essential for container/serverless environments.",[20,9907,9908,9911],{},[42,9909,9910],{},"Database-native pooling"," (Supabase Supavisor, Neon, AWS RDS Proxy): Managed pooling as a service. The easiest operational story but adds latency and may not support all PostgreSQL features.",[15,9913,9915],{"id":9914},"application-level-pooling-with-prisma","Application-Level Pooling With Prisma",[20,9917,9918],{},"Prisma includes a built-in connection pool:",[321,9920,9922],{"className":1390,"code":9921,"language":1392,"meta":263,"style":263},"const prisma = new PrismaClient({\n datasources: {\n db: {\n url: process.env.DATABASE_URL,\n },\n },\n})\n",[108,9923,9924,9938,9942,9946,9954,9958,9962],{"__ignoreMap":263},[329,9925,9926,9928,9930,9932,9934,9936],{"class":331,"line":332},[329,9927,3596],{"class":1182},[329,9929,6925],{"class":585},[329,9931,1916],{"class":1182},[329,9933,6930],{"class":1182},[329,9935,6933],{"class":1172},[329,9937,2222],{"class":505},[329,9939,9940],{"class":331,"line":267},[329,9941,6940],{"class":505},[329,9943,9944],{"class":331,"line":264},[329,9945,6945],{"class":505},[329,9947,9948,9950,9952],{"class":331,"line":348},[329,9949,6950],{"class":505},[329,9951,6953],{"class":585},[329,9953,1540],{"class":505},[329,9955,9956],{"class":331,"line":354},[329,9957,5048],{"class":505},[329,9959,9960],{"class":331,"line":360},[329,9961,5048],{"class":505},[329,9963,9964],{"class":331,"line":286},[329,9965,9966],{"class":505},"})\n",[20,9968,9969],{},"Configure the pool size in the connection URL:",[321,9971,9974],{"className":9972,"code":9973,"language":1775},[1773],"DATABASE_URL=\"postgresql://user:password@host:5432/db?connection_limit=10&pool_timeout=30\"\n",[108,9975,9973],{"__ignoreMap":263},[20,9977,9978],{},"Or in the URL's connection pool parameters:",[95,9980,9981,9990,9996],{},[98,9982,9983,9986,9987,4632],{},[108,9984,9985],{},"connection_limit",": Maximum number of connections in the pool (default: ",[108,9988,9989],{},"num_cpus * 2 + 1",[98,9991,9992,9995],{},[108,9993,9994],{},"pool_timeout",": How long to wait for a connection before throwing (seconds)",[98,9997,9998,10001],{},[108,9999,10000],{},"connect_timeout",": How long to wait for the connection to be established",[20,10003,10004,10005,10008],{},"For most applications on traditional servers, the default pool size formula is reasonable. But Prisma runs in your Node.js process — each application instance has its own pool. On a server with 4 application instances, a ",[108,10006,10007],{},"connection_limit=10"," means 40 total connections to the database.",[15,10010,10012],{"id":10011},"the-serverless-problem","The Serverless Problem",[20,10014,10015],{},"Serverless functions and containers change the math dramatically. A Lambda function or Cloudflare Worker might scale from 0 to 500 instances in seconds. If each instance creates a connection pool, you have 500+ connections hitting the database simultaneously — even for modest traffic.",[20,10017,10018,10019,10022],{},"PostgreSQL's default ",[108,10020,10021],{},"max_connections"," is 100. Most managed PostgreSQL services allow 200-1000 depending on the plan. 500 application instances with connection pools of 10 each means 5,000 connection attempts to a database that allows 200.",[20,10024,10025],{},"The result: connection errors, failed requests, and degraded performance across the board.",[20,10027,10028],{},"The solution for serverless is external pooling, or using a serverless-compatible database connection approach:",[321,10030,10032],{"className":1390,"code":10031,"language":1392,"meta":263,"style":263},"// For serverless: use pgBouncer or Prisma Accelerate\n// Set connection_limit=1 since each invocation is short-lived\nconst DATABASE_URL = `${process.env.DATABASE_URL}?connection_limit=1`\n\n// Or use Prisma Accelerate (managed pooling service)\nconst prisma = new PrismaClient({\n datasources: {\n db: {\n url: process.env.ACCELERATE_URL, // Prisma's managed pooler\n },\n },\n})\n",[108,10033,10034,10039,10044,10070,10074,10079,10093,10097,10101,10113,10117,10121],{"__ignoreMap":263},[329,10035,10036],{"class":331,"line":332},[329,10037,10038],{"class":620},"// For serverless: use pgBouncer or Prisma Accelerate\n",[329,10040,10041],{"class":331,"line":267},[329,10042,10043],{"class":620},"// Set connection_limit=1 since each invocation is short-lived\n",[329,10045,10046,10048,10051,10053,10055,10058,10060,10063,10065,10067],{"class":331,"line":264},[329,10047,3596],{"class":1182},[329,10049,10050],{"class":585}," DATABASE_URL",[329,10052,1916],{"class":1182},[329,10054,7220],{"class":516},[329,10056,10057],{"class":505},"process",[329,10059,229],{"class":516},[329,10061,10062],{"class":505},"env",[329,10064,229],{"class":516},[329,10066,6953],{"class":585},[329,10068,10069],{"class":516},"}?connection_limit=1`\n",[329,10071,10072],{"class":331,"line":348},[329,10073,340],{"emptyLinePlaceholder":284},[329,10075,10076],{"class":331,"line":354},[329,10077,10078],{"class":620},"// Or use Prisma Accelerate (managed pooling service)\n",[329,10080,10081,10083,10085,10087,10089,10091],{"class":331,"line":360},[329,10082,3596],{"class":1182},[329,10084,6925],{"class":585},[329,10086,1916],{"class":1182},[329,10088,6930],{"class":1182},[329,10090,6933],{"class":1172},[329,10092,2222],{"class":505},[329,10094,10095],{"class":331,"line":286},[329,10096,6940],{"class":505},[329,10098,10099],{"class":331,"line":370},[329,10100,6945],{"class":505},[329,10102,10103,10105,10108,10110],{"class":331,"line":375},[329,10104,6950],{"class":505},[329,10106,10107],{"class":585},"ACCELERATE_URL",[329,10109,1486],{"class":505},[329,10111,10112],{"class":620},"// Prisma's managed pooler\n",[329,10114,10115],{"class":331,"line":381},[329,10116,5048],{"class":505},[329,10118,10119],{"class":331,"line":387},[329,10120,5048],{"class":505},[329,10122,10123],{"class":331,"line":392},[329,10124,9966],{"class":505},[15,10126,10128],{"id":10127},"configuring-pgbouncer","Configuring PgBouncer",[20,10130,10131],{},"PgBouncer is the battle-tested external pooler for PostgreSQL. It sits between your application and PostgreSQL, maintaining a small pool of real database connections that it multiplexes across many application connections.",[20,10133,10134],{},"Three pooling modes:",[20,10136,10137,10140],{},[42,10138,10139],{},"Session pooling:"," A database connection is assigned for the duration of the client session. Least efficient — basically 1:1 mapping.",[20,10142,10143,10146],{},[42,10144,10145],{},"Transaction pooling:"," A database connection is held only for the duration of a transaction. The most efficient mode, compatible with most applications.",[20,10148,10149,10152],{},[42,10150,10151],{},"Statement pooling:"," A connection is returned after each statement. The most efficient but incompatible with multi-statement transactions.",[20,10154,10155],{},"Transaction pooling is the right choice for most web applications:",[321,10157,10159],{"className":8760,"code":10158,"language":8762,"meta":263,"style":263},"# pgbouncer.ini\n[databases]\nmyapp = host=db.example.com port=5432 dbname=myapp\n\n[pgbouncer]\nlisten_addr = 0.0.0.0\nlisten_port = 6432\nauth_type = scram-sha-256\nauth_file = /etc/pgbouncer/userlist.txt\n\nPool_mode = transaction\n\n# Maximum connections PgBouncer will maintain to PostgreSQL\nmax_client_conn = 1000 # Application connections to PgBouncer\ndefault_pool_size = 25 # Real PostgreSQL connections per database\nmin_pool_size = 5 # Minimum connections kept open\nmax_db_connections = 50 # Total connections to PostgreSQL\n\n# Timeout settings\nquery_timeout = 30\nquery_wait_timeout = 120\nclient_idle_timeout = 600\nserver_idle_timeout = 600\n",[108,10160,10161,10166,10171,10176,10180,10185,10190,10195,10200,10205,10209,10214,10218,10223,10228,10233,10238,10243,10247,10252,10257,10262,10267],{"__ignoreMap":263},[329,10162,10163],{"class":331,"line":332},[329,10164,10165],{},"# pgbouncer.ini\n",[329,10167,10168],{"class":331,"line":267},[329,10169,10170],{},"[databases]\n",[329,10172,10173],{"class":331,"line":264},[329,10174,10175],{},"myapp = host=db.example.com port=5432 dbname=myapp\n",[329,10177,10178],{"class":331,"line":348},[329,10179,340],{"emptyLinePlaceholder":284},[329,10181,10182],{"class":331,"line":354},[329,10183,10184],{},"[pgbouncer]\n",[329,10186,10187],{"class":331,"line":360},[329,10188,10189],{},"listen_addr = 0.0.0.0\n",[329,10191,10192],{"class":331,"line":286},[329,10193,10194],{},"listen_port = 6432\n",[329,10196,10197],{"class":331,"line":370},[329,10198,10199],{},"auth_type = scram-sha-256\n",[329,10201,10202],{"class":331,"line":375},[329,10203,10204],{},"auth_file = /etc/pgbouncer/userlist.txt\n",[329,10206,10207],{"class":331,"line":381},[329,10208,340],{"emptyLinePlaceholder":284},[329,10210,10211],{"class":331,"line":387},[329,10212,10213],{},"Pool_mode = transaction\n",[329,10215,10216],{"class":331,"line":392},[329,10217,340],{"emptyLinePlaceholder":284},[329,10219,10220],{"class":331,"line":398},[329,10221,10222],{},"# Maximum connections PgBouncer will maintain to PostgreSQL\n",[329,10224,10225],{"class":331,"line":403},[329,10226,10227],{},"max_client_conn = 1000 # Application connections to PgBouncer\n",[329,10229,10230],{"class":331,"line":409},[329,10231,10232],{},"default_pool_size = 25 # Real PostgreSQL connections per database\n",[329,10234,10235],{"class":331,"line":415},[329,10236,10237],{},"min_pool_size = 5 # Minimum connections kept open\n",[329,10239,10240],{"class":331,"line":420},[329,10241,10242],{},"max_db_connections = 50 # Total connections to PostgreSQL\n",[329,10244,10245],{"class":331,"line":426},[329,10246,340],{"emptyLinePlaceholder":284},[329,10248,10249],{"class":331,"line":887},[329,10250,10251],{},"# Timeout settings\n",[329,10253,10254],{"class":331,"line":895},[329,10255,10256],{},"query_timeout = 30\n",[329,10258,10259],{"class":331,"line":906},[329,10260,10261],{},"query_wait_timeout = 120\n",[329,10263,10264],{"class":331,"line":914},[329,10265,10266],{},"client_idle_timeout = 600\n",[329,10268,10269],{"class":331,"line":923},[329,10270,10271],{},"server_idle_timeout = 600\n",[20,10273,10274],{},"With this configuration, 1000 application connections share 25 real database connections. The efficiency comes from That application connections are only using the database for a small percentage of the time.",[20,10276,10277],{},[42,10278,10279],{},"PgBouncer caveats with transaction pooling:",[20,10281,10282],{},"Prepared statements do not work reliably in transaction pooling mode (the statement might be prepared on connection A but executed on connection B). This affects:",[95,10284,10285,10296],{},[98,10286,10287,10288,10291,10292,10295],{},"Prisma with ",[108,10289,10290],{},"prepared_statement_mode=1"," (disable with ",[108,10293,10294],{},"prepared_statements=false"," in connection URL)",[98,10297,10298],{},"Some PostgreSQL features that use session-level state",[321,10300,10303],{"className":10301,"code":10302,"language":1775},[1773],"DATABASE_URL=\"postgresql://user:pass@pgbouncer:6432/myapp?prepared_statements=false\"\n",[108,10304,10302],{"__ignoreMap":263},[15,10306,10308],{"id":10307},"right-sizing-your-pool","Right-Sizing Your Pool",[20,10310,10311],{},"Too few connections: requests queue waiting for a connection, increasing latency.\nToo many connections: the database degrades under the overhead of managing many connections.",[20,10313,10314],{},"The formula I start with:",[321,10316,10319],{"className":10317,"code":10318,"language":1775},[1773],"max_connections = num_cores * 4\n",[108,10320,10318],{"__ignoreMap":263},[20,10322,10323,10324,10327],{},"For a 4-core PostgreSQL server: 16 connections. This is a starting point, not a ceiling. Use ",[108,10325,10326],{},"pg_stat_activity"," to monitor actual connection usage:",[321,10329,10333],{"className":10330,"code":10331,"language":10332,"meta":263,"style":263},"language-sql shiki shiki-themes github-dark","-- Show current connections and what they are doing\nSELECT\n state,\n wait_event_type,\n wait_event,\n COUNT(*) as count,\n MAX(EXTRACT(EPOCH FROM (NOW() - state_change))) AS max_duration_seconds\nFROM pg_stat_activity\nWHERE datname = 'your_database'\nGROUP BY state, wait_event_type, wait_event\nORDER BY count DESC;\n","sql",[108,10334,10335,10340,10345,10350,10355,10360,10365,10370,10375,10380,10385],{"__ignoreMap":263},[329,10336,10337],{"class":331,"line":332},[329,10338,10339],{},"-- Show current connections and what they are doing\n",[329,10341,10342],{"class":331,"line":267},[329,10343,10344],{},"SELECT\n",[329,10346,10347],{"class":331,"line":264},[329,10348,10349],{}," state,\n",[329,10351,10352],{"class":331,"line":348},[329,10353,10354],{}," wait_event_type,\n",[329,10356,10357],{"class":331,"line":354},[329,10358,10359],{}," wait_event,\n",[329,10361,10362],{"class":331,"line":360},[329,10363,10364],{}," COUNT(*) as count,\n",[329,10366,10367],{"class":331,"line":286},[329,10368,10369],{}," MAX(EXTRACT(EPOCH FROM (NOW() - state_change))) AS max_duration_seconds\n",[329,10371,10372],{"class":331,"line":370},[329,10373,10374],{},"FROM pg_stat_activity\n",[329,10376,10377],{"class":331,"line":375},[329,10378,10379],{},"WHERE datname = 'your_database'\n",[329,10381,10382],{"class":331,"line":381},[329,10383,10384],{},"GROUP BY state, wait_event_type, wait_event\n",[329,10386,10387],{"class":331,"line":387},[329,10388,10389],{},"ORDER BY count DESC;\n",[20,10391,10392,10393,10396,10397,10400,10401,3557,10404,10407],{},"Healthy production output: most connections are ",[108,10394,10395],{},"idle"," (waiting in the pool), a small number are ",[108,10398,10399],{},"active"," (executing queries). If you see many connections in ",[108,10402,10403],{},"waiting on lock",[108,10405,10406],{},"idle in transaction",", you have other problems to investigate.",[15,10409,10411],{"id":10410},"monitoring-pool-health","Monitoring Pool Health",[20,10413,10414],{},"Track these metrics in your observability system:",[20,10416,10417,10420],{},[42,10418,10419],{},"Pool use:"," What percentage of pool connections are in use? Above 80% consistently indicates you need more connections or better query efficiency.",[20,10422,10423,10426],{},[42,10424,10425],{},"Wait time:"," How long do requests wait for a pool connection? Should be near zero. Spikes indicate the pool is undersized for your traffic.",[20,10428,10429,10432],{},[42,10430,10431],{},"Connection errors:"," Failed connection attempts indicate the pool is exhausted.",[321,10434,10436],{"className":1390,"code":10435,"language":1392,"meta":263,"style":263},"// With Prisma, you can track pool metrics through events\nconst prisma = new PrismaClient({\n log: ['query', 'warn', 'error'],\n})\n\nPrisma.$on('query', (e) => {\n if (e.duration > 1000) {\n console.warn(`Slow query (${e.duration}ms):`, e.query)\n }\n})\n",[108,10437,10438,10443,10457,10478,10482,10486,10509,10524,10549,10553],{"__ignoreMap":263},[329,10439,10440],{"class":331,"line":332},[329,10441,10442],{"class":620},"// With Prisma, you can track pool metrics through events\n",[329,10444,10445,10447,10449,10451,10453,10455],{"class":331,"line":267},[329,10446,3596],{"class":1182},[329,10448,6925],{"class":585},[329,10450,1916],{"class":1182},[329,10452,6930],{"class":1182},[329,10454,6933],{"class":1172},[329,10456,2222],{"class":505},[329,10458,10459,10462,10465,10467,10470,10472,10475],{"class":331,"line":264},[329,10460,10461],{"class":505}," log: [",[329,10463,10464],{"class":516},"'query'",[329,10466,1486],{"class":505},[329,10468,10469],{"class":516},"'warn'",[329,10471,1486],{"class":505},[329,10473,10474],{"class":516},"'error'",[329,10476,10477],{"class":505},"],\n",[329,10479,10480],{"class":331,"line":348},[329,10481,9966],{"class":505},[329,10483,10484],{"class":331,"line":354},[329,10485,340],{"emptyLinePlaceholder":284},[329,10487,10488,10491,10494,10496,10498,10500,10503,10505,10507],{"class":331,"line":360},[329,10489,10490],{"class":505},"Prisma.",[329,10492,10493],{"class":1172},"$on",[329,10495,1437],{"class":505},[329,10497,10464],{"class":516},[329,10499,2329],{"class":505},[329,10501,10502],{"class":1482},"e",[329,10504,1497],{"class":505},[329,10506,1500],{"class":1182},[329,10508,1503],{"class":505},[329,10510,10511,10513,10516,10519,10522],{"class":331,"line":286},[329,10512,2425],{"class":1182},[329,10514,10515],{"class":505}," (e.duration ",[329,10517,10518],{"class":1182},">",[329,10520,10521],{"class":585}," 1000",[329,10523,2105],{"class":505},[329,10525,10526,10529,10531,10533,10536,10538,10540,10543,10546],{"class":331,"line":370},[329,10527,10528],{"class":505}," console.",[329,10530,1933],{"class":1172},[329,10532,1437],{"class":505},[329,10534,10535],{"class":516},"`Slow query (${",[329,10537,10502],{"class":505},[329,10539,229],{"class":516},[329,10541,10542],{"class":505},"duration",[329,10544,10545],{"class":516},"}ms):`",[329,10547,10548],{"class":505},", e.query)\n",[329,10550,10551],{"class":331,"line":375},[329,10552,2466],{"class":505},[329,10554,10555],{"class":331,"line":381},[329,10556,9966],{"class":505},[20,10558,10559],{},"Connection pooling is infrastructure you configure once and rarely think about — until it breaks. Configuring it correctly from the start prevents the class of production incidents where the database is fine but the application cannot reach it because connections are exhausted.",[30,10561],{},[20,10563,10564,10565,229],{},"Dealing with connection pooling issues or scaling a Node.js application to handle more concurrent database connections? Book a call and let's solve it: ",[223,10566,228],{"href":225,"rel":10567},[227],[30,10569],{},[15,10571,235],{"id":234},[95,10573,10574,10578,10582,10586],{},[98,10575,10576],{},[223,10577,9817],{"href":9816},[98,10579,10580],{},[223,10581,9829],{"href":9828},[98,10583,10584],{},[223,10585,8173],{"href":9856},[98,10587,10588],{},[223,10589,9823],{"href":9822},[1301,10591,10592],{},"html pre.shiki code .snl16, html code.shiki .snl16{--shiki-default:#F97583}html pre.shiki code .sDLfK, html code.shiki .sDLfK{--shiki-default:#79B8FF}html pre.shiki code .svObZ, html code.shiki .svObZ{--shiki-default:#B392F0}html pre.shiki code .s95oV, html code.shiki .s95oV{--shiki-default:#E1E4E8}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html pre.shiki code .sAwPA, html code.shiki .sAwPA{--shiki-default:#6A737D}html pre.shiki code .sU2Wk, html code.shiki .sU2Wk{--shiki-default:#9ECBFF}html pre.shiki code .s9osk, html code.shiki .s9osk{--shiki-default:#FFAB70}",{"title":263,"searchDepth":264,"depth":264,"links":10594},[10595,10596,10597,10598,10599,10600,10601,10602],{"id":9875,"depth":267,"text":9876},{"id":9885,"depth":267,"text":9886},{"id":9914,"depth":267,"text":9915},{"id":10011,"depth":267,"text":10012},{"id":10127,"depth":267,"text":10128},{"id":10307,"depth":267,"text":10308},{"id":10410,"depth":267,"text":10411},{"id":234,"depth":267,"text":235},"A practical guide to database connection pooling — how pools work, right-sizing for your workload, configuring Prisma and PgBouncer, and fixing the most common pool problems.",[10605,10606],"database connection pooling","PostgreSQL connection",{},{"title":9835,"description":10603},"blog/database-connection-pooling",[8169,9860,4006],"uTlBDbKfxi2AQA0x4yxes1n1QBoG7x7q3As4sJDBHdM",{"id":10613,"title":10614,"author":10615,"body":10616,"category":1317,"date":275,"description":10917,"extension":277,"featured":278,"image":279,"keywords":10918,"meta":10921,"navigation":284,"path":10922,"readTime":286,"seo":10923,"stem":10924,"tags":10925,"__hash__":10927},"blog/blog/database-hosting-options.md","Database Hosting Options in 2026: Supabase vs RDS vs Self-Hosted",{"name":9,"bio":10},{"type":12,"value":10617,"toc":10907},[10618,10621,10624,10627,10631,10634,10666,10669,10672,10675,10678,10681,10695,10701,10707,10710,10713,10716,10718,10732,10737,10742,10745,10748,10751,10753,10767,10772,10777,10781,10784,10787,10790,10792,10806,10811,10816,10820,10823,10826,10829,10831,10845,10850,10855,10859,10862,10865,10868,10871,10873,10879,10881,10883],[301,10619,10614],{"id":10620},"database-hosting-options-in-2026-supabase-vs-rds-vs-self-hosted",[20,10622,10623],{},"The database hosting landscape has changed significantly over the past few years. In 2020, your options were essentially \"run it yourself or pay AWS a lot of money.\" In 2026, you have a range of managed services at different price points with genuinely different architectures. Choosing correctly for your scale and requirements matters — both for operational overhead and for your monthly bill.",[20,10625,10626],{},"Let me walk through the options I actually recommend to clients and the reasoning behind those recommendations.",[15,10628,10630],{"id":10629},"the-options-worth-considering","The Options Worth Considering",[20,10632,10633],{},"For PostgreSQL specifically (which is what I use for the vast majority of production applications), the realistic options in 2026 are:",[95,10635,10636,10642,10648,10654,10660],{},[98,10637,10638,10641],{},[42,10639,10640],{},"Supabase"," — managed Postgres with additional BaaS features",[98,10643,10644,10647],{},[42,10645,10646],{},"Neon"," — serverless Postgres with branching",[98,10649,10650,10653],{},[42,10651,10652],{},"Railway"," — simple managed Postgres with good DX",[98,10655,10656,10659],{},[42,10657,10658],{},"AWS RDS / Aurora"," — enterprise managed Postgres",[98,10661,10662,10665],{},[42,10663,10664],{},"Self-hosted"," — Postgres on your own VPS",[20,10667,10668],{},"There are others (PlanetScale for MySQL, Planetscale Postgres, Turso for SQLite at the edge) but these five cover the options for most production PostgreSQL workloads.",[15,10670,10640],{"id":10671},"supabase",[20,10673,10674],{},"Supabase has grown into a serious production platform. Beyond managed Postgres, it provides authentication, storage, edge functions, and a real-time layer built on Postgres logical replication. For projects that benefit from those features, the cost efficiency is compelling.",[20,10676,10677],{},"The database itself is a standard Postgres instance — you connect with any Postgres client, run any SQL, use any ORM. No proprietary query language or API lock-in at the database level.",[20,10679,10680],{},"What to know:",[95,10682,10683,10686,10689,10692],{},[98,10684,10685],{},"Free tier is genuinely useful for development (pauses after 1 week of inactivity — annoying but manageable)",[98,10687,10688],{},"Pro plan ($25/month) provides 8GB database, 100GB storage, no pausing — suitable for production",[98,10690,10691],{},"Connection pooling (PgBouncer) is included and necessary for serverless deployments",[98,10693,10694],{},"Auth, storage, and edge functions integrate tightly with the database — useful if you need them, overhead if you do not",[20,10696,10697,10700],{},[42,10698,10699],{},"Best for:"," Early to mid-stage startups, projects that benefit from the auth/storage/real-time features, teams that want to move quickly with minimal infrastructure.",[20,10702,10703,10706],{},[42,10704,10705],{},"Avoid if:"," You need to run custom Postgres extensions that Supabase does not support, or your workload is large enough that you are pricing out Supabase's higher tiers against RDS.",[15,10708,10646],{"id":10709},"neon",[20,10711,10712],{},"Neon's differentiating feature is serverless Postgres with a separation of storage and compute. You can scale compute to zero when the database is not in use, which makes the free tier essentially perpetually usable. Database branching — spinning up an isolated copy of your database for a feature branch or test run — is a genuinely novel feature.",[20,10714,10715],{},"The branching feature integrates well with CI workflows. Your CI pipeline can branch your production database, run tests against real data (anonymized, ideally), and discard the branch when done. This solves a real problem.",[20,10717,10680],{},[95,10719,10720,10723,10726,10729],{},[98,10721,10722],{},"Cold starts exist (compute scaling to zero means the first query after inactivity is slow)",[98,10724,10725],{},"Not appropriate for latency-sensitive workloads that cannot tolerate occasional cold start delays",[98,10727,10728],{},"Branching is the killer feature — if you want it, Neon is the only serious option",[98,10730,10731],{},"Connection pooling is included",[20,10733,10734,10736],{},[42,10735,10699],{}," Development environments, CI test databases, applications with variable traffic where scaling to zero saves meaningful money, teams that want database branching for their workflow.",[20,10738,10739,10741],{},[42,10740,10705],{}," Cold starts are unacceptable for your latency requirements (e-commerce, payment processing, real-time applications).",[15,10743,10652],{"id":10744},"railway",[20,10746,10747],{},"Railway is the simplest managed Postgres option with the best developer experience. Add a Postgres database to a Railway project in two clicks. It provisions instantly, gives you connection strings for all frameworks, and integrates with Railway's application deployment if you are using it.",[20,10749,10750],{},"The pricing is consumption-based: $0.000231/vCPU-hour, $0.000321/GB memory-hour, $0.25/GB storage-month. For a small database with moderate load, this works out to under $10/month. It scales with your usage, which is good for early-stage applications.",[20,10752,10680],{},[95,10754,10755,10758,10761,10764],{},[98,10756,10757],{},"No free tier for databases (pay-as-you-go from first use)",[98,10759,10760],{},"Performance is solid for the price point — standard managed Postgres, nothing exotic",[98,10762,10763],{},"Connection limits are lower than RDS at comparable specs",[98,10765,10766],{},"The Railway CLI and dashboard are genuinely excellent",[20,10768,10769,10771],{},[42,10770,10699],{}," Small to medium applications, teams already using Railway for application deployment, projects where simplicity and DX matter more than cost optimization at scale.",[20,10773,10774,10776],{},[42,10775,10705],{}," You need dedicated resources with guaranteed performance, or you are building something that will outgrow a simple managed Postgres quickly.",[15,10778,10780],{"id":10779},"aws-rds-aurora-postgresql","AWS RDS / Aurora PostgreSQL",[20,10782,10783],{},"RDS is the enterprise choice. It is more expensive than all the alternatives, the console is cumbersome, and the configuration options are overwhelming. It is also deeply integrated with the rest of AWS, battle-tested at enormous scale, and available in every region globally.",[20,10785,10786],{},"For applications already in AWS — using ECS, Lambda, or EC2 — RDS makes sense because the networking integration is smooth, IAM authentication is available, and you are not adding a third-party dependency to your AWS-native stack.",[20,10788,10789],{},"Aurora PostgreSQL is worth considering at higher traffic levels. Aurora's shared storage layer allows for very fast read replica promotion and scales storage automatically. The performance at scale is superior to RDS. The cost is higher and the minimum instance size makes it uneconomical for small databases.",[20,10791,10680],{},[95,10793,10794,10797,10800,10803],{},[98,10795,10796],{},"RDS db.t3.micro (~$15/month) works for small development databases but is insufficient for most production workloads",[98,10798,10799],{},"db.t3.small ($30/month) or db.t3.medium ($60/month) are realistic production starting points",[98,10801,10802],{},"Multi-AZ deployment (recommended for production) roughly doubles the cost — failover to a standby replica automatically",[98,10804,10805],{},"Automated backups, point-in-time recovery, and parameter groups are all configurable",[20,10807,10808,10810],{},[42,10809,10699],{}," AWS-native architectures, enterprise applications requiring SLA guarantees, compliance-sensitive industries that need the backing of AWS.",[20,10812,10813,10815],{},[42,10814,10705],{}," You are not already committed to AWS and the cost difference matters at your scale.",[15,10817,10819],{"id":10818},"self-hosted-postgres","Self-Hosted Postgres",[20,10821,10822],{},"Running Postgres on a VPS is the cheapest option and the highest operational overhead. A DigitalOcean Droplet ($24/month for 2 vCPU/4GB RAM) running Postgres can handle a substantial production workload. The savings compared to RDS are real.",[20,10824,10825],{},"The cost is your time. You manage backups (use WAL-G or pgBackRest and test restores regularly). You manage updates (Postgres major version upgrades require careful planning). You manage high availability (streaming replication to a standby, Patroni for automatic failover). You manage disk space, connection pooling (PgBouncer), and query performance.",[20,10827,10828],{},"None of these are insurmountable, but they take time. The managed service premium buys you that time back.",[20,10830,10680],{},[95,10832,10833,10836,10839,10842],{},[98,10834,10835],{},"Use Postgres 16 or 17 — do not run unsupported versions",[98,10837,10838],{},"Set up automated backups to object storage (S3-compatible) from day one",[98,10840,10841],{},"Run PgBouncer as a connection pooler — unbounded connections to Postgres will cause problems",[98,10843,10844],{},"Use a separate VPS for the database, not the same machine as your application server",[20,10846,10847,10849],{},[42,10848,10699],{}," Cost-sensitive projects, applications at scale where the managed service cost is significant, teams with the operational expertise to manage it correctly.",[20,10851,10852,10854],{},[42,10853,10705],{}," You do not have someone who will own database operations. An unmanaged database that nobody is watching is a production incident waiting to happen.",[15,10856,10858],{"id":10857},"my-default-recommendation","My Default Recommendation",[20,10860,10861],{},"For most early-stage projects: Neon or Supabase. They handle the operational burden, they are affordable at small scale, and you can migrate to RDS or self-hosted when your requirements outgrow them.",[20,10863,10864],{},"For enterprise applications with AWS as the deployment target: RDS with Multi-AZ, Aurora for high-traffic read-heavy workloads.",[20,10866,10867],{},"For cost-sensitive teams with operational capability: self-hosted Postgres on a VPS with proper backup and monitoring.",[20,10869,10870],{},"The worst choice is not choosing — running Postgres in a Docker container with a local volume on your application server, losing all your data when the container is replaced. That is the zero-budget option that actually has no budget because there is no safety net when it fails.",[30,10872],{},[20,10874,10875,10876,229],{},"Trying to choose the right database architecture for your application? Let's talk through your requirements. Book a session at ",[223,10877,225],{"href":225,"rel":10878},[227],[30,10880],{},[15,10882,235],{"id":234},[95,10884,10885,10891,10897,10903],{},[98,10886,10887],{},[223,10888,10890],{"href":10889},"/blog/cloud-cost-optimization","Cloud Cost Optimization: Cutting the Bill Without Cutting Corners",[98,10892,10893],{},[223,10894,10896],{"href":10895},"/blog/performance-monitoring-guide","Application Performance Monitoring: Beyond the Health Check Endpoint",[98,10898,10899],{},[223,10900,10902],{"href":10901},"/blog/cdn-configuration-guide","CDN Configuration: Making Your Static Assets Load Instantly Everywhere",[98,10904,10905],{},[223,10906,296],{"href":1323},{"title":263,"searchDepth":264,"depth":264,"links":10908},[10909,10910,10911,10912,10913,10914,10915,10916],{"id":10629,"depth":267,"text":10630},{"id":10671,"depth":267,"text":10640},{"id":10709,"depth":267,"text":10646},{"id":10744,"depth":267,"text":10652},{"id":10779,"depth":267,"text":10780},{"id":10818,"depth":267,"text":10819},{"id":10857,"depth":267,"text":10858},{"id":234,"depth":267,"text":235},"A practical comparison of PostgreSQL hosting options in 2026 — Supabase, AWS RDS, Neon, Railway, and self-hosted — with honest tradeoffs for each approach.",[10919,10920],"database hosting","PostgreSQL hosting",{},"/blog/database-hosting-options",{"title":10614,"description":10917},"blog/database-hosting-options",[8169,9860,1317,10926],"Infrastructure","AwhMv405q0Cpjd65Od63C4OQH9lQt_IlYQeoS-V5S-o",[10929,10931,10932,10934,10935,10937,10938,10939,10940,10941,10942,10943,10944,10945,10946,10947,10948,10949,10950,10951,10952,10953,10954,10955,10956,10957,10958,10959,10960,10961,10962,10963,10964,10965,10966,10967,10968,10969,10970,10971,10972,10973,10974,10975,10976,10977,10978,10979,10980,10981,10982,10983,10984,10985,10986,10987,10988,10989,10990,10991,10992,10993,10994,10995,10996,10997,10998,10999,11000,11001,11002,11003,11004,11005,11006,11007,11008,11009,11010,11011,11012,11013,11015,11016,11017,11018,11019,11020,11021,11022,11023,11024,11025,11026,11027,11028,11029,11030,11031,11032,11033,11034,11035,11036,11037,11038,11039,11040,11041,11042,11043,11044,11045,11046,11047,11048,11049,11050,11051,11052,11053,11054,11055,11056,11057,11058,11059,11060,11061,11062,11063,11064,11065,11066,11067,11068,11069,11070,11071,11072,11073,11074,11075,11076,11077,11078,11079,11080,11081,11082,11083,11084,11085,11086,11087,11088,11089,11090,11091,11092,11093,11094,11095,11096,11097,11098,11099,11100,11101,11102,11103,11104,11105,11106,11107,11108,11109,11110,11111,11112,11113,11114,11115,11116,11117,11118,11119,11120,11121,11122,11123,11124,11125,11126,11127,11128,11129,11130,11131,11132,11133,11134,11135,11136,11137,11138,11139,11140,11141,11142,11143,11144,11145,11146,11147,11148,11149,11150,11151,11152,11153,11154,11155,11156,11157,11158,11159,11160,11161,11162,11163,11164,11165,11166,11167,11168,11169,11170,11171,11172,11173,11174,11175,11176,11177,11178,11179,11180,11181,11182,11183,11184,11185,11186,11187,11188,11189,11190,11191,11192,11193,11194,11195,11196,11197,11198,11199,11200,11201,11202,11203,11204,11205,11206,11207,11208,11209,11210,11211,11212,11213,11214,11215,11216,11217,11218,11219,11220,11221,11222,11223,11224,11225,11226,11227,11228,11229,11230,11231,11232,11233,11234,11235,11236,11237,11238,11239,11240,11241,11242,11243,11244,11245,11246,11247,11248,11249,11250,11251,11252,11253,11254,11255,11256,11257,11258,11259,11260,11261,11262,11263,11264,11265,11266,11267,11268,11269,11270,11271,11272,11273,11274,11275,11276,11277,11278,11279,11280,11281,11282,11283,11284,11285,11286,11287,11288,11289,11290,11291,11292,11293,11294,11295,11296,11297,11298,11299,11300,11301,11302,11303,11304,11305,11306,11307,11308,11309,11310,11311,11312,11313,11314,11315,11316,11317,11318,11319,11320,11321,11322,11323,11324,11325,11326,11327,11328,11329,11330,11331,11332,11333,11334,11335,11336,11337,11338,11339,11340,11341,11342,11343,11344,11345,11346,11347,11348,11349,11350,11351,11352,11353,11354,11355,11356,11357,11358,11359,11360,11361,11362,11363,11364,11365,11366,11367,11368,11369,11370,11371,11372,11373,11374,11375,11376,11377,11378,11379,11380,11381,11382,11383,11384,11385,11386,11387,11388,11389,11390,11391,11392,11393,11394,11395,11396,11397,11398,11399,11400,11401,11402,11403,11405,11406,11407,11408,11409,11410,11411,11412,11413,11414,11415,11416,11417,11418,11419,11420,11421,11422,11423,11424,11425,11426,11427,11428,11429,11430,11431,11432,11433,11434,11435,11436,11437,11438,11439,11440,11441,11442,11443,11444,11445,11446,11447,11448,11449,11450,11451,11452,11453,11454,11455,11456,11457,11458,11459,11460,11461,11462,11463,11464,11465,11466,11467,11468,11469,11470,11471,11472,11473,11474,11475,11476,11477,11478,11479,11480,11481,11482,11483,11484,11485,11486,11487,11488,11489,11490,11491,11492,11493,11494,11495,11496,11497,11498,11499,11500,11501,11502,11503,11504,11505,11506,11507,11508,11509,11510,11511,11512,11513,11514,11515,11516,11517,11518,11519,11520,11521,11522,11523,11524,11525,11526,11527,11528,11529,11530,11531,11532,11533,11534,11535,11536,11537,11538,11539,11540,11541,11542,11543,11544,11545,11546,11547,11548,11549,11550,11551,11552,11553,11554,11555,11556,11557,11558,11559,11560,11561,11562,11563,11564,11565,11566,11567,11568,11569,11570,11571,11572,11573],{"category":10930},"Frontend",{"category":6849},{"category":10933},"AI",{"category":274},{"category":10936},"Business",{"category":10933},{"category":10933},{"category":10933},{"category":10933},{"category":10933},{"category":10933},{"category":10933},{"category":10933},{"category":10933},{"category":10933},{"category":10933},{"category":10933},{"category":10933},{"category":10933},{"category":10933},{"category":10933},{"category":10933},{"category":10933},{"category":10933},{"category":10933},{"category":6849},{"category":6849},{"category":6849},{"category":6849},{"category":6849},{"category":6849},{"category":4293},{"category":4293},{"category":274},{"category":274},{"category":4293},{"category":274},{"category":274},{"category":1329},{"category":1329},{"category":10936},{"category":10936},{"category":6849},{"category":1329},{"category":6849},{"category":4293},{"category":1329},{"category":274},{"category":10936},{"category":1317},{"category":10933},{"category":6849},{"category":274},{"category":4293},{"category":274},{"category":6849},{"category":6849},{"category":6849},{"category":4293},{"category":274},{"category":4293},{"category":274},{"category":274},{"category":4293},{"category":6849},{"category":6849},{"category":6849},{"category":6849},{"category":6849},{"category":6849},{"category":1317},{"category":6849},{"category":6849},{"category":6849},{"category":6849},{"category":6849},{"category":6849},{"category":6849},{"category":6849},{"category":6849},{"category":274},{"category":11014},"Career",{"category":10933},{"category":10933},{"category":10936},{"category":4293},{"category":10936},{"category":274},{"category":274},{"category":10936},{"category":274},{"category":4293},{"category":274},{"category":1317},{"category":1317},{"category":6849},{"category":6849},{"category":6849},{"category":6849},{"category":6849},{"category":6849},{"category":6849},{"category":6849},{"category":6849},{"category":6849},{"category":6849},{"category":6849},{"category":6849},{"category":6849},{"category":6849},{"category":6849},{"category":6849},{"category":6849},{"category":6849},{"category":6849},{"category":6849},{"category":4293},{"category":4293},{"category":6849},{"category":6849},{"category":6849},{"category":6849},{"category":6849},{"category":6849},{"category":10933},{"category":4293},{"category":10936},{"category":1317},{"category":1317},{"category":1317},{"category":6849},{"category":274},{"category":274},{"category":6849},{"category":10930},{"category":10933},{"category":1317},{"category":1317},{"category":1329},{"category":1317},{"category":10936},{"category":10933},{"category":6849},{"category":274},{"category":6849},{"category":4293},{"category":6849},{"category":4293},{"category":1329},{"category":6849},{"category":6849},{"category":274},{"category":10936},{"category":274},{"category":10930},{"category":274},{"category":274},{"category":274},{"category":274},{"category":10936},{"category":10936},{"category":6849},{"category":10930},{"category":1329},{"category":4293},{"category":1329},{"category":10930},{"category":274},{"category":274},{"category":1317},{"category":274},{"category":274},{"category":4293},{"category":274},{"category":1317},{"category":274},{"category":274},{"category":6849},{"category":6849},{"category":1329},{"category":4293},{"category":4293},{"category":11014},{"category":11014},{"category":11014},{"category":10936},{"category":274},{"category":1317},{"category":4293},{"category":6849},{"category":6849},{"category":1317},{"category":4293},{"category":4293},{"category":10930},{"category":274},{"category":6849},{"category":6849},{"category":274},{"category":6849},{"category":1317},{"category":1317},{"category":6849},{"category":1329},{"category":6849},{"category":4293},{"category":1329},{"category":4293},{"category":274},{"category":4293},{"category":274},{"category":274},{"category":274},{"category":274},{"category":274},{"category":274},{"category":274},{"category":274},{"category":4293},{"category":274},{"category":274},{"category":1329},{"category":274},{"category":1317},{"category":1317},{"category":10936},{"category":274},{"category":274},{"category":274},{"category":4293},{"category":274},{"category":274},{"category":274},{"category":274},{"category":274},{"category":274},{"category":4293},{"category":4293},{"category":4293},{"category":274},{"category":6849},{"category":6849},{"category":6849},{"category":1317},{"category":10936},{"category":6849},{"category":6849},{"category":274},{"category":6849},{"category":274},{"category":10930},{"category":6849},{"category":10936},{"category":10936},{"category":274},{"category":274},{"category":10933},{"category":6849},{"category":6849},{"category":6849},{"category":6849},{"category":6849},{"category":6849},{"category":6849},{"category":6849},{"category":274},{"category":1317},{"category":1317},{"category":1317},{"category":4293},{"category":6849},{"category":6849},{"category":6849},{"category":6849},{"category":4293},{"category":6849},{"category":4293},{"category":6849},{"category":6849},{"category":6849},{"category":6849},{"category":6849},{"category":6849},{"category":10936},{"category":10936},{"category":6849},{"category":274},{"category":10930},{"category":4293},{"category":11014},{"category":6849},{"category":6849},{"category":1329},{"category":274},{"category":6849},{"category":6849},{"category":1317},{"category":6849},{"category":10930},{"category":1317},{"category":1317},{"category":1329},{"category":274},{"category":274},{"category":4293},{"category":6849},{"category":6849},{"category":6849},{"category":6849},{"category":6849},{"category":6849},{"category":11014},{"category":6849},{"category":4293},{"category":274},{"category":274},{"category":6849},{"category":1317},{"category":6849},{"category":6849},{"category":6849},{"category":10930},{"category":6849},{"category":6849},{"category":274},{"category":6849},{"category":274},{"category":4293},{"category":6849},{"category":6849},{"category":6849},{"category":10933},{"category":10933},{"category":274},{"category":6849},{"category":1317},{"category":1317},{"category":6849},{"category":274},{"category":6849},{"category":6849},{"category":10933},{"category":6849},{"category":6849},{"category":6849},{"category":4293},{"category":6849},{"category":6849},{"category":6849},{"category":274},{"category":274},{"category":274},{"category":1329},{"category":274},{"category":274},{"category":10930},{"category":274},{"category":10930},{"category":10930},{"category":1329},{"category":4293},{"category":274},{"category":4293},{"category":6849},{"category":6849},{"category":274},{"category":274},{"category":274},{"category":10936},{"category":274},{"category":274},{"category":6849},{"category":4293},{"category":10933},{"category":10933},{"category":6849},{"category":6849},{"category":6849},{"category":6849},{"category":10936},{"category":274},{"category":6849},{"category":6849},{"category":274},{"category":274},{"category":10930},{"category":274},{"category":274},{"category":274},{"category":274},{"category":274},{"category":274},{"category":274},{"category":274},{"category":274},{"category":274},{"category":274},{"category":274},{"category":4293},{"category":274},{"category":274},{"category":274},{"category":4293},{"category":6849},{"category":10936},{"category":10933},{"category":6849},{"category":10936},{"category":1329},{"category":6849},{"category":1329},{"category":274},{"category":1317},{"category":6849},{"category":6849},{"category":274},{"category":6849},{"category":4293},{"category":6849},{"category":6849},{"category":274},{"category":10936},{"category":274},{"category":274},{"category":274},{"category":274},{"category":10936},{"category":274},{"category":274},{"category":10936},{"category":1317},{"category":274},{"category":10933},{"category":6849},{"category":6849},{"category":274},{"category":274},{"category":6849},{"category":6849},{"category":6849},{"category":10933},{"category":274},{"category":274},{"category":4293},{"category":10930},{"category":274},{"category":6849},{"category":274},{"category":4293},{"category":10936},{"category":10936},{"category":10930},{"category":10930},{"category":6849},{"category":10936},{"category":1329},{"category":6849},{"category":6849},{"category":6849},{"category":6849},{"category":6849},{"category":6849},{"category":6849},{"category":4293},{"category":274},{"category":274},{"category":4293},{"category":274},{"category":274},{"category":274},{"category":11404},"Programming",{"category":274},{"category":274},{"category":4293},{"category":4293},{"category":274},{"category":274},{"category":10936},{"category":1329},{"category":274},{"category":10936},{"category":274},{"category":274},{"category":274},{"category":274},{"category":1317},{"category":4293},{"category":10936},{"category":10936},{"category":274},{"category":274},{"category":10936},{"category":274},{"category":1329},{"category":10936},{"category":274},{"category":274},{"category":4293},{"category":4293},{"category":6849},{"category":10936},{"category":6849},{"category":6849},{"category":6849},{"category":6849},{"category":6849},{"category":6849},{"category":6849},{"category":6849},{"category":6849},{"category":6849},{"category":6849},{"category":6849},{"category":6849},{"category":6849},{"category":6849},{"category":6849},{"category":6849},{"category":6849},{"category":6849},{"category":6849},{"category":6849},{"category":6849},{"category":6849},{"category":6849},{"category":6849},{"category":6849},{"category":6849},{"category":6849},{"category":10930},{"category":6849},{"category":1317},{"category":1329},{"category":1329},{"category":1329},{"category":1329},{"category":1329},{"category":1329},{"category":6849},{"category":274},{"category":1317},{"category":4293},{"category":1317},{"category":4293},{"category":274},{"category":10930},{"category":6849},{"category":4293},{"category":10930},{"category":6849},{"category":6849},{"category":6849},{"category":4293},{"category":4293},{"category":4293},{"category":10936},{"category":10936},{"category":10936},{"category":4293},{"category":4293},{"category":10936},{"category":10936},{"category":10936},{"category":6849},{"category":1329},{"category":274},{"category":1317},{"category":274},{"category":6849},{"category":10936},{"category":10936},{"category":6849},{"category":6849},{"category":4293},{"category":274},{"category":4293},{"category":4293},{"category":4293},{"category":10930},{"category":274},{"category":6849},{"category":6849},{"category":10936},{"category":10936},{"category":4293},{"category":274},{"category":11014},{"category":4293},{"category":11014},{"category":10936},{"category":6849},{"category":4293},{"category":6849},{"category":6849},{"category":6849},{"category":274},{"category":274},{"category":6849},{"category":10933},{"category":10933},{"category":1317},{"category":6849},{"category":6849},{"category":6849},{"category":6849},{"category":274},{"category":274},{"category":10930},{"category":274},{"category":1329},{"category":4293},{"category":10930},{"category":10930},{"category":274},{"category":274},{"category":10930},{"category":10930},{"category":10930},{"category":1329},{"category":274},{"category":274},{"category":10936},{"category":274},{"category":4293},{"category":6849},{"category":6849},{"category":4293},{"category":6849},{"category":6849},{"category":4293},{"category":6849},{"category":274},{"category":6849},{"category":1329},{"category":6849},{"category":6849},{"category":6849},{"category":1317},{"category":1317},{"category":1329},1772951194499]