builder: installs all deps, compiles TypeScript → dist/.
runner: fresh image, production deps only, copies just dist/, runs as a non-root user.
The final image ships no compiler, no devDeps, no source, so it’s smaller and has a lower attack surface. Both compose files target the runner stage (a mismatch here was a real bug, see Issues).
docker-compose.dev.yml runs the app plus a Neon Local proxy that forks a fresh ephemeral Neon branch on up and deletes it on down. scripts/dev.sh (via npm run dev:docker) validates env, waits for the proxy to be healthy, runs migrations, then tails logs.
npm run dev:docker # start the dev stackdocker compose -f docker-compose.dev.yml down # stop + delete ephemeral branch
This project has two compose files and no default docker-compose.yml, so the -f flag is always required. A handy alias: alias dc-dev='docker compose -f docker-compose.dev.yml'.
The frontend has its own pipeline, mirroring the backend’s conventions.CI: two GitHub Actions workflows:
lint-and-format.yml: ESLint + Prettier, both reported together with a gate (same pattern as the backend). Node 22 (matches the frontend’s Dockerfile and Next 16).
tests.yml: two parallel jobs: Typecheck (tsc --noEmit) and E2E (Playwright) (builds a production server on :3100 and runs the mocked suite + axe a11y scan). No database/service container: the e2e suite mocks REST/WS at the network boundary.
CD: Vercel (Git integration): Vercel builds and deploys on every push to main, with a preview deployment per PR. This is the CD; there’s no separate deploy workflow. NEXT_PUBLIC_* env vars are set in Vercel’s project settings (they’re baked at build time, so changing one requires a redeploy). CORS_ORIGIN on the Render backend must include the Vercel origin or the live site is blocked (ADR-009).Branch protection connects CI to CD.main requires the three checks (Typecheck, E2E (Playwright), Lint and Format Check) to pass and a pull request before merge, with admin bypass disabled. Since Vercel’s production deploy comes from main, gating main effectively gates production, even though Vercel itself doesn’t read CI. PR preview deploys still happen regardless (you want to preview WIP).
feature branch → push → PR → CI checks + Vercel PREVIEW deploy → (merge blocked until checks green) → merge to main → Vercel PRODUCTION deploy
Dockerized too (optional). The frontend has a standalone Dockerfile (gated behind DOCKER_BUILD=1 so next start and Vercel are unaffected) for own-infra/learning use, but Vercel is the live deploy. Vercel fits a client-rendered app better (managed CDN, image optimization, preview URLs) than self-hosting a container would (ADR-009).
Rollback automation: Render keeps prior deploys (manual rollback in dashboard); no scripted rollback.
Graceful shutdown on deploy: SIGTERM currently drops WS connections uncleanly (close() exists as the building block; see Backend).
Automated post-deploy smoke: the deployed smoke test (Testing) is run manually (npm run test:e2e:deployed); a workflow that runs it automatically after a Vercel deploy is a logical next step.
Pre-commit enforcement: lint/format are enforced today by the npm scripts (local, manual) and by CI as the merge gate. A husky + lint-staged pre-commit hook would auto-run eslint --fix + prettier --write on staged files so unformatted code can’t even be committed, and commitlint would enforce a commit-message convention (Conventional Commits). Planned tooling, not yet wired.
{"docsConfig":{"theme":"mint","$schema":"https://mintlify.com/docs.json","name":"Sportz Docs","colors":{"primary":"#EAB308","light":"#F4C542","dark":"#EAB308"},"logo":{"light":"https://mintcdn.com/sportzdocs/ZqapXRecIQ7JtK1M/logo/light.svg?fit=max\u0026auto=format\u0026n=ZqapXRecIQ7JtK1M\u0026q=85\u0026s=654dae11c1c4160e24667a72a6fa2fe7","dark":"https://mintcdn.com/sportzdocs/ZqapXRecIQ7JtK1M/logo/dark.svg?fit=max\u0026auto=format\u0026n=ZqapXRecIQ7JtK1M\u0026q=85\u0026s=ddc4c9ade6959cf37bcb93e2c3e1f320"},"favicon":"https://mintcdn.com/sportzdocs/ZqapXRecIQ7JtK1M/favicon.svg?fit=max\u0026auto=format\u0026n=ZqapXRecIQ7JtK1M\u0026q=85\u0026s=88b93ba67882ed2c861fa42d6666d149","navbar":{"primary":{"type":"github","href":"https://github.com/Jheff4/sportz-websockets"}},"footer":{"socials":{"github":"https://github.com/Jheff4"}},"contextual":{"options":["copy","view","chatgpt","claude"],"display":"header"},"styling":{"latex":false},"navigation":{"global":{"anchors":[{"anchor":"Live Demo","icon":"rocket","href":"https://sportz-ui.vercel.app"},{"anchor":"Backend Repo","icon":"server","href":"https://github.com/Jheff4/sportz-websockets"},{"anchor":"Frontend Repo","icon":"browser","href":"https://github.com/Jheff4/sportz-ui"}]},"pages":[]}},"deployment":{"deploymentMetadataValue":{"subdomain":"sportzdocs","hasAudioNarration":false,"actualSubdomain":"sportzdocs","customDomains":[],"gitSource":{"type":"github","owner":"jheff4","repo":"sportz-docs","deployBranch":"main","contentDirectory":"","isPrivate":false,"mountPath":"","label":""},"chroma":{"latestBuiltDeploymentHistoryId":"6a91a79bb34810eb4c786144","collectionId":"1d2ebbb9-3581-4408-b4ed-1155a1c227d2","liveDeploymentHistoryId":"6a91a79bb34810eb4c786144","schemaVersion":"v3"},"entitlements":{"AI_CHAT":{"status":"DISABLED"}},"buildId":"6a91a79bb34810eb4c786144","clientVersion":"0.0.4039","searchFilterCounts":[{"language":"default","tag":"Handbook","count":14},{"language":"default","tag":"Learn \u0026 Interview","count":5},{"language":"default","tag":"Architecture","count":4},{"language":"default","tag":"Operations","count":4},{"language":"default","tag":"Overview","count":3},{"language":"default","tag":"Learning","count":3},{"language":"default","tag":"Engineering Stories","count":2},{"language":"default","tag":"Interview Prep","count":2},{"language":"default","tag":"Reference","count":1}]},"assistantConfig":{"enableAskAiButton":false,"enableStarterQuestions":false,"enableContextualStarterQuestions":false,"enableAskAiSearchBar":true},"basePath":"","hasChatPermissions":false,"hasWebMcpPermissions":false,"toggles":[{"name":"kill-isr","enabled":true,"variant":{"name":"disabled","enabled":false,"feature_enabled":true},"impressionData":false},{"name":"agent-mintlify-slack","enabled":true,"variant":{"name":"disabled","enabled":false,"feature_enabled":true},"impressionData":false},{"name":"agent-integrations-settings","enabled":true,"variant":{"name":"disabled","enabled":false,"feature_enabled":true},"impressionData":false},{"name":"automations-pr-grouping","enabled":true,"variant":{"name":"disabled","enabled":false,"feature_enabled":true},"impressionData":false},{"name":"custom-prefix","enabled":true,"variant":{"name":"disabled","enabled":false,"feature_enabled":true},"impressionData":false},{"name":"analytics-v7","enabled":true,"variant":{"name":"disabled","enabled":false,"feature_enabled":true},"impressionData":false},{"name":"user-flows-analytics","enabled":true,"variant":{"name":"disabled","enabled":false,"feature_enabled":true},"impressionData":false},{"name":"no-publish-flow","enabled":true,"variant":{"name":"disabled","enabled":false,"feature_enabled":true},"impressionData":false},{"name":"workos-onboarding","enabled":true,"variant":{"name":"disabled","enabled":false,"feature_enabled":true},"impressionData":false},{"name":"llms-txt-v2","enabled":true,"variant":{"name":"disabled","enabled":false,"feature_enabled":true},"impressionData":false},{"name":"snapshot-serving-live","enabled":true,"variant":{"name":"disabled","enabled":false,"feature_enabled":true},"impressionData":false},{"name":"stripTemperatureMiddleware","enabled":true,"variant":{"name":"disabled","enabled":false,"feature_enabled":true},"impressionData":false},{"name":"support-platform-pylon-zendesk","enabled":true,"variant":{"name":"disabled","enabled":false,"feature_enabled":true},"impressionData":false},{"name":"snapshot-serving","enabled":true,"variant":{"name":"disabled","enabled":false,"feature_enabled":true},"impressionData":false},{"name":"instant-rollback","enabled":true,"variant":{"name":"disabled","enabled":false,"feature_enabled":true},"impressionData":false},{"name":"granular-notifications-server","enabled":true,"variant":{"name":"disabled","enabled":false,"feature_enabled":true},"impressionData":false},{"name":"granular-notifications","enabled":true,"variant":{"name":"disabled","enabled":false,"feature_enabled":true},"impressionData":false},{"name":"automations-fable","enabled":true,"variant":{"name":"disabled","enabled":false,"feature_enabled":true},"impressionData":false},{"name":"fixed-ai-action-pricing","enabled":true,"variant":{"name":"disabled","enabled":false,"feature_enabled":true},"impressionData":false},{"name":"automation-resolve-merge-conflict","enabled":true,"variant":{"name":"disabled","enabled":false,"feature_enabled":true},"impressionData":false},{"name":"scoped-publishing","enabled":true,"variant":{"name":"disabled","enabled":false,"feature_enabled":true},"impressionData":false},{"name":"mintql","enabled":true,"variant":{"name":"disabled","enabled":false,"feature_enabled":true},"impressionData":false},{"name":"page-level-analytics","enabled":true,"variant":{"name":"disabled","enabled":false,"feature_enabled":true},"impressionData":false},{"name":"editor-version-history-v2","enabled":true,"variant":{"name":"disabled","enabled":false,"feature_enabled":true},"impressionData":false},{"name":"billing-page-v2","enabled":true,"variant":{"name":"disabled","enabled":false,"feature_enabled":true},"impressionData":false},{"name":"full-page-search","enabled":true,"variant":{"name":"disabled","enabled":false,"feature_enabled":true},"impressionData":false},{"name":"workspace-projects","enabled":true,"variant":{"name":"disabled","enabled":false,"feature_enabled":true},"impressionData":false},{"name":"assistant-v5","enabled":true,"variant":{"name":"disabled","enabled":false,"feature_enabled":true},"impressionData":false},{"name":"byok-sep-25","enabled":true,"variant":{"name":"disabled","enabled":false,"feature_enabled":true},"impressionData":false},{"name":"fork-pr-previews","enabled":true,"variant":{"name":"disabled","enabled":false,"feature_enabled":true},"impressionData":false},{"name":"revalidation-jwt","enabled":true,"variant":{"name":"disabled","enabled":false,"feature_enabled":true},"impressionData":false},{"name":"vite-origin","enabled":true,"variant":{"name":"disabled","enabled":false,"feature_enabled":true},"impressionData":false},{"name":"billing-updated-9-15","enabled":true,"variant":{"name":"disabled","enabled":false,"feature_enabled":true},"impressionData":false},{"name":"workos-sso-scim","enabled":true,"variant":{"name":"disabled","enabled":false,"feature_enabled":true},"impressionData":false},{"name":"hobby-abuse-scan","enabled":true,"variant":{"name":"disabled","enabled":false,"feature_enabled":true},"impressionData":false},{"name":"dashboard-inbox","enabled":true,"variant":{"name":"disabled","enabled":false,"feature_enabled":true},"impressionData":false},{"name":"workos-signup","enabled":true,"variant":{"name":"disabled","enabled":false,"feature_enabled":true},"impressionData":false},{"name":"turbopuffer-dual-write","enabled":true,"variant":{"name":"disabled","enabled":false,"feature_enabled":true},"impressionData":false},{"name":"workos-enabled","enabled":true,"variant":{"name":"disabled","enabled":false,"feature_enabled":true},"impressionData":false},{"name":"approve-review-flow","enabled":true,"variant":{"name":"disabled","enabled":false,"feature_enabled":true},"impressionData":false}],"googleFontsInlined":false},"customCss":[{"filePath":"style.css","content":"#navbar .flex-1.border-b,#navbar-transition{border-bottom:none!important}#navbar div.h-16{position:relative}#navbar div.h-16::after{content:\"\";position:absolute;bottom:0;left:50%;transform:translateX(-50%);width:100vw;border-bottom:1px solid rgb(107 114 128/.12);pointer-events:none}.dark #navbar div.h-16::after{border-bottom-color:rgb(209 213 219/.08)}#navbar{overflow-x:clip}"}],"page":{"pagePath":"operations/devops","slug":"/operations/devops","pageMetadata":{"title":"DevOps \u0026 CI/CD","description":"How code moves from a laptop to production: Docker, GitHub Actions, and Render.","href":"/operations/devops"},"description":{"compiledSource":"\"use strict\";\nconst {jsx: _jsx} = arguments[0];\nconst {useMDXComponents: _provideComponents} = arguments[0];\nfunction _createMdxContent(props) {\n const _components = {\n p: \"p\",\n ..._provideComponents(),\n ...props.components\n };\n return _jsx(_components.p, {\n children: \"How code moves from a laptop to production: Docker, GitHub Actions, and Render.\"\n });\n}\nfunction MDXContent(props = {}) {\n const {wrapper: MDXLayout} = {\n ..._provideComponents(),\n ...props.components\n };\n return MDXLayout ? _jsx(MDXLayout, {\n ...props,\n children: _jsx(_createMdxContent, {\n ...props\n })\n }) : _createMdxContent(props);\n}\nreturn {\n default: MDXContent\n};\n","frontmatter":{},"scope":{}},"pageType":"default","mdx":{"mdxSource":{"compiledSource":"\"use strict\";const{Fragment:_Fragment,jsx:_jsx,jsxs:_jsxs}=arguments[0];const{useMDXComponents:_provideComponents}=arguments[0];function _createMdxContent(props){const _components={a:`a`,code:`code`,em:`em`,li:`li`,ol:`ol`,p:`p`,pre:`pre`,span:`span`,strong:`strong`,tbody:`tbody`,td:`td`,th:`th`,thead:`thead`,tr:`tr`,ul:`ul`,..._provideComponents(),...props.components},{CodeBlock,Heading,Mermaid,Table,Tip}=_components;if(!CodeBlock)_missingMdxReference(`CodeBlock`,true);if(!Heading)_missingMdxReference(`Heading`,true);if(!Mermaid)_missingMdxReference(`Mermaid`,true);if(!Table)_missingMdxReference(`Table`,true);if(!Tip)_missingMdxReference(`Tip`,true);return _jsxs(_Fragment,{children:[_jsx(Heading,{level:`2`,id:`environments`,children:`Environments`}),`\n`,_jsxs(Table,{children:[_jsx(_components.thead,{children:_jsxs(_components.tr,{children:[_jsx(_components.th,{children:`Environment`}),_jsx(_components.th,{children:`Database`}),_jsx(_components.th,{children:`How it runs`})]})}),_jsxs(_components.tbody,{children:[_jsxs(_components.tr,{children:[_jsx(_components.td,{children:_jsx(_components.strong,{children:`Local (direct)`})}),_jsx(_components.td,{children:`plain Postgres or Neon Local`}),_jsxs(_components.td,{children:[_jsx(_components.code,{children:`npm run dev`}),` (tsx watch)`]})]}),_jsxs(_components.tr,{children:[_jsx(_components.td,{children:_jsx(_components.strong,{children:`Local (Docker)`})}),_jsx(_components.td,{children:`Neon Local (ephemeral branch)`}),_jsxs(_components.td,{children:[_jsx(_components.code,{children:`npm run dev:docker`}),` → `,_jsx(_components.code,{children:`docker-compose.dev.yml`})]})]}),_jsxs(_components.tr,{children:[_jsx(_components.td,{children:_jsx(_components.strong,{children:`Test / CI`})}),_jsxs(_components.td,{children:[_jsx(_components.code,{children:`postgres:16-alpine`}),` container`]}),_jsx(_components.td,{children:_jsx(_components.code,{children:`npm test`})})]}),_jsxs(_components.tr,{children:[_jsx(_components.td,{children:_jsx(_components.strong,{children:`Production`})}),_jsx(_components.td,{children:`Neon Cloud (pooled)`}),_jsx(_components.td,{children:`Docker image on Render`})]})]})]}),`\n`,_jsx(Heading,{level:`2`,id:`the-docker-image`,children:`The Docker image`}),`\n`,_jsxs(_components.p,{children:[`A `,_jsx(_components.strong,{children:`multi-stage`}),` build (full detail in `,_jsx(_components.a,{href:`/architecture/backend`,children:`Backend`}),`):`]}),`\n`,_jsxs(_components.ul,{children:[`\n`,_jsxs(_components.li,{children:[_jsx(_components.strong,{children:_jsx(_components.code,{children:`builder`})}),`: installs all deps, compiles TypeScript → `,_jsx(_components.code,{children:`dist/`}),`.`]}),`\n`,_jsxs(_components.li,{children:[_jsx(_components.strong,{children:_jsx(_components.code,{children:`runner`})}),`: fresh image, production deps only, copies just `,_jsx(_components.code,{children:`dist/`}),`, runs as a non-root user.`]}),`\n`]}),`\n`,_jsxs(_components.p,{children:[`The final image ships no compiler, no devDeps, no source, so it’s smaller and has a lower attack surface. Both compose files target the `,_jsx(_components.code,{children:`runner`}),` stage (a mismatch here was a real bug, see `,_jsx(_components.a,{href:`/issues`,children:`Issues`}),`).`]}),`\n`,_jsx(Heading,{level:`2`,id:`local-dev-with-neon-local`,children:`Local dev with Neon Local`}),`\n`,_jsxs(_components.p,{children:[_jsx(_components.code,{children:`docker-compose.dev.yml`}),` runs the app plus a `,_jsx(_components.strong,{children:`Neon Local`}),` proxy that forks a fresh ephemeral Neon branch on `,_jsx(_components.code,{children:`up`}),` and deletes it on `,_jsx(_components.code,{children:`down`}),`. `,_jsx(_components.code,{children:`scripts/dev.sh`}),` (via `,_jsx(_components.code,{children:`npm run dev:docker`}),`) validates env, waits for the proxy to be healthy, runs migrations, then tails logs.`]}),`\n`,_jsx(CodeBlock,{filename:``,numberOfLines:`2`,language:`shellscript`,children:_jsx(_components.pre,{className:`shiki shiki-themes github-light-default dark-plus`,style:{backgroundColor:`#ffffff`,\"--shiki-dark-bg\":`#0B0C0E`,color:`#1f2328`,\"--shiki-dark\":`#D4D4D4`},language:`shellscript`,children:_jsxs(_components.code,{language:`shellscript`,numberOfLines:`2`,children:[_jsxs(_components.span,{className:`line`,children:[_jsx(_components.span,{style:{color:`#953800`,\"--shiki-dark\":`#DCDCAA`},children:`npm`}),_jsx(_components.span,{style:{color:`#0A3069`,\"--shiki-dark\":`#CE9178`},children:` run`}),_jsx(_components.span,{style:{color:`#0A3069`,\"--shiki-dark\":`#CE9178`},children:` dev:docker`}),_jsx(_components.span,{style:{color:`#6E7781`,\"--shiki-dark\":`#6A9955`},children:` # start the dev stack`})]}),`\n`,_jsxs(_components.span,{className:`line`,children:[_jsx(_components.span,{style:{color:`#953800`,\"--shiki-dark\":`#DCDCAA`},children:`docker`}),_jsx(_components.span,{style:{color:`#0A3069`,\"--shiki-dark\":`#CE9178`},children:` compose`}),_jsx(_components.span,{style:{color:`#0550AE`,\"--shiki-dark\":`#569CD6`},children:` -f`}),_jsx(_components.span,{style:{color:`#0A3069`,\"--shiki-dark\":`#CE9178`},children:` docker-compose.dev.yml`}),_jsx(_components.span,{style:{color:`#0A3069`,\"--shiki-dark\":`#CE9178`},children:` down`}),_jsx(_components.span,{style:{color:`#6E7781`,\"--shiki-dark\":`#6A9955`},children:` # stop + delete ephemeral branch`})]}),`\n`]})})}),`\n`,_jsx(Tip,{children:_jsxs(_components.p,{children:[`This project has `,_jsx(_components.strong,{children:`two`}),` compose files and no default `,_jsx(_components.code,{children:`docker-compose.yml`}),`, so the `,_jsx(_components.code,{children:`-f`}),` flag is always required. A handy alias: `,_jsx(_components.code,{children:`alias dc-dev='docker compose -f docker-compose.dev.yml'`}),`.`]})}),`\n`,_jsx(Heading,{level:`2`,id:`ci/cd-three-github-actions-workflows`,children:`CI/CD: three GitHub Actions workflows`}),`\n`,_jsx(Mermaid,{chart:`flowchart LR\n push[\"push / PR\\\\nto main or staging\"] --> lint[\"lint-and-format.yml\\\\nESLint + Prettier\"]\n push --> tests[\"tests.yml\\\\nVitest vs postgres service\"]\n merge[\"push to main\"] --> docker[\"docker-build-and-push.yml\\\\nmulti-arch image → Docker Hub\"]`}),`\n`,_jsxs(_components.ol,{children:[`\n`,_jsxs(_components.li,{children:[_jsx(_components.strong,{children:_jsx(_components.code,{children:`lint-and-format.yml`})}),`: runs ESLint and Prettier; reports `,_jsx(_components.em,{children:`both`}),` failures together (not one-at-a-time) with fix commands as annotations.`]}),`\n`,_jsxs(_components.li,{children:[_jsx(_components.strong,{children:_jsx(_components.code,{children:`tests.yml`})}),`: spins up a `,_jsx(_components.code,{children:`postgres:16-alpine`}),` service container, runs migrations, runs `,_jsx(_components.code,{children:`npm test`}),`, uploads coverage (30-day retention), writes a summary.`]}),`\n`,_jsxs(_components.li,{children:[_jsx(_components.strong,{children:_jsx(_components.code,{children:`docker-build-and-push.yml`})}),`: on merge to main (or manual), builds `,_jsx(_components.code,{children:`linux/amd64`}),` + `,_jsx(_components.code,{children:`linux/arm64`}),`, tags (branch, SHA, `,_jsx(_components.code,{children:`latest`}),`, `,_jsx(_components.code,{children:`prod-YYYYMMDD-HHmmss`}),`), pushes to Docker Hub. Requires `,_jsx(_components.code,{children:`DOCKER_USERNAME`}),` / `,_jsx(_components.code,{children:`DOCKER_PASSWORD`}),` repo secrets.`]}),`\n`]}),`\n`,_jsx(Heading,{level:`2`,id:`deploy-to-render`,children:`Deploy to Render`}),`\n`,_jsxs(_components.p,{children:[`Production deploys via a `,_jsx(_components.strong,{children:`Render Blueprint`}),` (`,_jsx(_components.code,{children:`render.yaml`}),`) using Render’s `,_jsx(_components.strong,{children:`Docker runtime`}),`: Render builds the same Dockerfile you tested locally.`]}),`\n`,_jsx(CodeBlock,{filename:``,numberOfLines:`13`,language:`yaml`,children:_jsx(_components.pre,{className:`shiki shiki-themes github-light-default dark-plus`,style:{backgroundColor:`#ffffff`,\"--shiki-dark-bg\":`#0B0C0E`,color:`#1f2328`,\"--shiki-dark\":`#D4D4D4`},language:`yaml`,children:_jsxs(_components.code,{language:`yaml`,numberOfLines:`13`,children:[_jsxs(_components.span,{className:`line`,children:[_jsx(_components.span,{style:{color:`#116329`,\"--shiki-dark\":`#569CD6`},children:`services`}),_jsx(_components.span,{style:{color:`#1F2328`,\"--shiki-dark\":`#D4D4D4`},children:`:`})]}),`\n`,_jsxs(_components.span,{className:`line`,children:[_jsx(_components.span,{style:{color:`#1F2328`,\"--shiki-dark\":`#D4D4D4`},children:` - `}),_jsx(_components.span,{style:{color:`#116329`,\"--shiki-dark\":`#569CD6`},children:`type`}),_jsx(_components.span,{style:{color:`#1F2328`,\"--shiki-dark\":`#D4D4D4`},children:`: `}),_jsx(_components.span,{style:{color:`#0A3069`,\"--shiki-dark\":`#CE9178`},children:`web`})]}),`\n`,_jsxs(_components.span,{className:`line`,children:[_jsx(_components.span,{style:{color:`#116329`,\"--shiki-dark\":`#569CD6`},children:` name`}),_jsx(_components.span,{style:{color:`#1F2328`,\"--shiki-dark\":`#D4D4D4`},children:`: `}),_jsx(_components.span,{style:{color:`#0A3069`,\"--shiki-dark\":`#CE9178`},children:`sportz-api`})]}),`\n`,_jsxs(_components.span,{className:`line`,children:[_jsx(_components.span,{style:{color:`#116329`,\"--shiki-dark\":`#569CD6`},children:` runtime`}),_jsx(_components.span,{style:{color:`#1F2328`,\"--shiki-dark\":`#D4D4D4`},children:`: `}),_jsx(_components.span,{style:{color:`#0A3069`,\"--shiki-dark\":`#CE9178`},children:`docker`})]}),`\n`,_jsxs(_components.span,{className:`line`,children:[_jsx(_components.span,{style:{color:`#116329`,\"--shiki-dark\":`#569CD6`},children:` dockerfilePath`}),_jsx(_components.span,{style:{color:`#1F2328`,\"--shiki-dark\":`#D4D4D4`},children:`: `}),_jsx(_components.span,{style:{color:`#0A3069`,\"--shiki-dark\":`#CE9178`},children:`./Dockerfile`})]}),`\n`,_jsxs(_components.span,{className:`line`,children:[_jsx(_components.span,{style:{color:`#116329`,\"--shiki-dark\":`#569CD6`},children:` healthCheckPath`}),_jsx(_components.span,{style:{color:`#1F2328`,\"--shiki-dark\":`#D4D4D4`},children:`: `}),_jsx(_components.span,{style:{color:`#0A3069`,\"--shiki-dark\":`#CE9178`},children:`/`}),_jsx(_components.span,{style:{color:`#6E7781`,\"--shiki-dark\":`#6A9955`},children:` # GET / is registered BEFORE Arcjet — never rate-limited`})]}),`\n`,_jsxs(_components.span,{className:`line`,children:[_jsx(_components.span,{style:{color:`#116329`,\"--shiki-dark\":`#569CD6`},children:` envVars`}),_jsx(_components.span,{style:{color:`#1F2328`,\"--shiki-dark\":`#D4D4D4`},children:`:`})]}),`\n`,_jsxs(_components.span,{className:`line`,children:[_jsx(_components.span,{style:{color:`#1F2328`,\"--shiki-dark\":`#D4D4D4`},children:` - { `}),_jsx(_components.span,{style:{color:`#116329`,\"--shiki-dark\":`#569CD6`},children:`key`}),_jsx(_components.span,{style:{color:`#1F2328`,\"--shiki-dark\":`#D4D4D4`},children:`: `}),_jsx(_components.span,{style:{color:`#0A3069`,\"--shiki-dark\":`#CE9178`},children:`DATABASE_URL`}),_jsx(_components.span,{style:{color:`#1F2328`,\"--shiki-dark\":`#D4D4D4`},children:`, `}),_jsx(_components.span,{style:{color:`#116329`,\"--shiki-dark\":`#569CD6`},children:`sync`}),_jsx(_components.span,{style:{color:`#1F2328`,\"--shiki-dark\":`#D4D4D4`},children:`: `}),_jsx(_components.span,{style:{color:`#0550AE`,\"--shiki-dark\":`#569CD6`},children:`false`}),_jsx(_components.span,{style:{color:`#1F2328`,\"--shiki-dark\":`#D4D4D4`},children:` } `}),_jsx(_components.span,{style:{color:`#6E7781`,\"--shiki-dark\":`#6A9955`},children:`# Neon Cloud pooled URL, set in dashboard`})]}),`\n`,_jsxs(_components.span,{className:`line`,children:[_jsx(_components.span,{style:{color:`#1F2328`,\"--shiki-dark\":`#D4D4D4`},children:` - { `}),_jsx(_components.span,{style:{color:`#116329`,\"--shiki-dark\":`#569CD6`},children:`key`}),_jsx(_components.span,{style:{color:`#1F2328`,\"--shiki-dark\":`#D4D4D4`},children:`: `}),_jsx(_components.span,{style:{color:`#0A3069`,\"--shiki-dark\":`#CE9178`},children:`ARCJET_KEY`}),_jsx(_components.span,{style:{color:`#1F2328`,\"--shiki-dark\":`#D4D4D4`},children:`, `}),_jsx(_components.span,{style:{color:`#116329`,\"--shiki-dark\":`#569CD6`},children:`sync`}),_jsx(_components.span,{style:{color:`#1F2328`,\"--shiki-dark\":`#D4D4D4`},children:`: `}),_jsx(_components.span,{style:{color:`#0550AE`,\"--shiki-dark\":`#569CD6`},children:`false`}),_jsx(_components.span,{style:{color:`#1F2328`,\"--shiki-dark\":`#D4D4D4`},children:` }`})]}),`\n`,_jsxs(_components.span,{className:`line`,children:[_jsx(_components.span,{style:{color:`#1F2328`,\"--shiki-dark\":`#D4D4D4`},children:` - { `}),_jsx(_components.span,{style:{color:`#116329`,\"--shiki-dark\":`#569CD6`},children:`key`}),_jsx(_components.span,{style:{color:`#1F2328`,\"--shiki-dark\":`#D4D4D4`},children:`: `}),_jsx(_components.span,{style:{color:`#0A3069`,\"--shiki-dark\":`#CE9178`},children:`CORS_ORIGIN`}),_jsx(_components.span,{style:{color:`#1F2328`,\"--shiki-dark\":`#D4D4D4`},children:`, `}),_jsx(_components.span,{style:{color:`#116329`,\"--shiki-dark\":`#569CD6`},children:`sync`}),_jsx(_components.span,{style:{color:`#1F2328`,\"--shiki-dark\":`#D4D4D4`},children:`: `}),_jsx(_components.span,{style:{color:`#0550AE`,\"--shiki-dark\":`#569CD6`},children:`false`}),_jsx(_components.span,{style:{color:`#1F2328`,\"--shiki-dark\":`#D4D4D4`},children:` }`})]}),`\n`,_jsxs(_components.span,{className:`line`,children:[_jsx(_components.span,{style:{color:`#1F2328`,\"--shiki-dark\":`#D4D4D4`},children:` - { `}),_jsx(_components.span,{style:{color:`#116329`,\"--shiki-dark\":`#569CD6`},children:`key`}),_jsx(_components.span,{style:{color:`#1F2328`,\"--shiki-dark\":`#D4D4D4`},children:`: `}),_jsx(_components.span,{style:{color:`#0A3069`,\"--shiki-dark\":`#CE9178`},children:`NODE_ENV`}),_jsx(_components.span,{style:{color:`#1F2328`,\"--shiki-dark\":`#D4D4D4`},children:`, `}),_jsx(_components.span,{style:{color:`#116329`,\"--shiki-dark\":`#569CD6`},children:`value`}),_jsx(_components.span,{style:{color:`#1F2328`,\"--shiki-dark\":`#D4D4D4`},children:`: `}),_jsx(_components.span,{style:{color:`#0A3069`,\"--shiki-dark\":`#CE9178`},children:`production`}),_jsx(_components.span,{style:{color:`#1F2328`,\"--shiki-dark\":`#D4D4D4`},children:` }`})]}),`\n`,_jsxs(_components.span,{className:`line`,children:[_jsx(_components.span,{style:{color:`#1F2328`,\"--shiki-dark\":`#D4D4D4`},children:` - { `}),_jsx(_components.span,{style:{color:`#116329`,\"--shiki-dark\":`#569CD6`},children:`key`}),_jsx(_components.span,{style:{color:`#1F2328`,\"--shiki-dark\":`#D4D4D4`},children:`: `}),_jsx(_components.span,{style:{color:`#0A3069`,\"--shiki-dark\":`#CE9178`},children:`PORT`}),_jsx(_components.span,{style:{color:`#1F2328`,\"--shiki-dark\":`#D4D4D4`},children:`, `}),_jsx(_components.span,{style:{color:`#116329`,\"--shiki-dark\":`#569CD6`},children:`value`}),_jsx(_components.span,{style:{color:`#1F2328`,\"--shiki-dark\":`#D4D4D4`},children:`: `}),_jsx(_components.span,{style:{color:`#0A3069`,\"--shiki-dark\":`#CE9178`},children:`\"8000\"`}),_jsx(_components.span,{style:{color:`#1F2328`,\"--shiki-dark\":`#D4D4D4`},children:` }`})]}),`\n`,_jsxs(_components.span,{className:`line`,children:[_jsx(_components.span,{style:{color:`#1F2328`,\"--shiki-dark\":`#D4D4D4`},children:` - { `}),_jsx(_components.span,{style:{color:`#116329`,\"--shiki-dark\":`#569CD6`},children:`key`}),_jsx(_components.span,{style:{color:`#1F2328`,\"--shiki-dark\":`#D4D4D4`},children:`: `}),_jsx(_components.span,{style:{color:`#0A3069`,\"--shiki-dark\":`#CE9178`},children:`HOST`}),_jsx(_components.span,{style:{color:`#1F2328`,\"--shiki-dark\":`#D4D4D4`},children:`, `}),_jsx(_components.span,{style:{color:`#116329`,\"--shiki-dark\":`#569CD6`},children:`value`}),_jsx(_components.span,{style:{color:`#1F2328`,\"--shiki-dark\":`#D4D4D4`},children:`: `}),_jsx(_components.span,{style:{color:`#0A3069`,\"--shiki-dark\":`#CE9178`},children:`\"0.0.0.0\"`}),_jsx(_components.span,{style:{color:`#1F2328`,\"--shiki-dark\":`#D4D4D4`},children:` } `}),_jsx(_components.span,{style:{color:`#6E7781`,\"--shiki-dark\":`#6A9955`},children:`# bind all interfaces so Render can route in`})]}),`\n`]})})}),`\n`,_jsxs(_components.p,{children:[_jsx(_components.strong,{children:`Migrations are not automatic`}),`: Render runs the app, not migrations. Run once against the prod DB before/after first deploy:`]}),`\n`,_jsx(CodeBlock,{filename:``,numberOfLines:`1`,language:`shellscript`,children:_jsx(_components.pre,{className:`shiki shiki-themes github-light-default dark-plus`,style:{backgroundColor:`#ffffff`,\"--shiki-dark-bg\":`#0B0C0E`,color:`#1f2328`,\"--shiki-dark\":`#D4D4D4`},language:`shellscript`,children:_jsxs(_components.code,{language:`shellscript`,numberOfLines:`1`,children:[_jsxs(_components.span,{className:`line`,children:[_jsx(_components.span,{style:{color:`#1F2328`,\"--shiki-dark\":`#9CDCFE`},children:`DATABASE_URL`}),_jsx(_components.span,{style:{color:`#CF222E`,\"--shiki-dark\":`#D4D4D4`},children:`=`}),_jsx(_components.span,{style:{color:`#0A3069`,\"--shiki-dark\":`#CE9178`},children:`\"\u003cneon-cloud-pooled-url>\"`}),_jsx(_components.span,{style:{color:`#953800`,\"--shiki-dark\":`#DCDCAA`},children:` npm`}),_jsx(_components.span,{style:{color:`#0A3069`,\"--shiki-dark\":`#CE9178`},children:` run`}),_jsx(_components.span,{style:{color:`#0A3069`,\"--shiki-dark\":`#CE9178`},children:` db:migrate`})]}),`\n`]})})}),`\n`,_jsx(Heading,{level:`3`,id:`the-flow-end-to-end`,children:`The flow, end to end`}),`\n`,_jsx(CodeBlock,{numberOfLines:`3`,language:`text`,children:_jsx(_components.pre,{className:`shiki shiki-themes github-light-default dark-plus`,style:{backgroundColor:`#ffffff`,\"--shiki-dark-bg\":`#0B0C0E`,color:`#1f2328`,\"--shiki-dark\":`#D4D4D4`},language:`text`,children:_jsxs(_components.code,{language:`text`,numberOfLines:`3`,children:[_jsx(_components.span,{className:`line`,children:_jsx(_components.span,{children:`laptop → git push → PR → CI (lint + tests) → review → merge to main`})}),`\n`,_jsx(_components.span,{className:`line`,children:_jsx(_components.span,{children:` → docker-build-and-push (image) + Render auto-deploy from main → production`})}),`\n`,_jsx(_components.span,{className:`line`,children:_jsx(_components.span,{})})]})})}),`\n`,_jsx(Heading,{level:`2`,id:`frontend-ci/cd-sportz-ui`,children:`Frontend CI/CD (sportz-ui)`}),`\n`,_jsx(_components.p,{children:`The frontend has its own pipeline, mirroring the backend’s conventions.`}),`\n`,_jsx(_components.p,{children:_jsx(_components.strong,{children:`CI: two GitHub Actions workflows:`})}),`\n`,_jsxs(_components.ol,{children:[`\n`,_jsxs(_components.li,{children:[_jsx(_components.strong,{children:_jsx(_components.code,{children:`lint-and-format.yml`})}),`: ESLint + Prettier, both reported together with a gate (same pattern as the backend). Node 22 (matches the frontend’s Dockerfile and Next 16).`]}),`\n`,_jsxs(_components.li,{children:[_jsx(_components.strong,{children:_jsx(_components.code,{children:`tests.yml`})}),`: two parallel jobs: `,_jsx(_components.strong,{children:`Typecheck`}),` (`,_jsx(_components.code,{children:`tsc --noEmit`}),`) and `,_jsx(_components.strong,{children:`E2E (Playwright)`}),` (builds a production server on :3100 and runs the mocked suite + axe a11y scan). No database/service container: the e2e suite mocks REST/WS at the network boundary.`]}),`\n`]}),`\n`,_jsxs(_components.p,{children:[_jsx(_components.strong,{children:`CD: Vercel (Git integration):`}),` Vercel builds and deploys on every push to `,_jsx(_components.code,{children:`main`}),`, with a `,_jsx(_components.strong,{children:`preview deployment per PR`}),`. This `,_jsx(_components.em,{children:`is`}),` the CD; there’s no separate deploy workflow. `,_jsx(_components.code,{children:`NEXT_PUBLIC_*`}),` env vars are set in Vercel’s project settings (they’re baked at build time, so changing one requires a redeploy). `,_jsx(_components.code,{children:`CORS_ORIGIN`}),` on the Render backend must include the Vercel origin or the live site is blocked (`,_jsx(_components.a,{href:`/decisions`,children:`ADR-009`}),`).`]}),`\n`,_jsxs(_components.p,{children:[_jsx(_components.strong,{children:`Branch protection connects CI to CD.`}),` `,_jsx(_components.code,{children:`main`}),` requires the three checks (`,_jsx(_components.code,{children:`Typecheck`}),`, `,_jsx(_components.code,{children:`E2E (Playwright)`}),`, `,_jsx(_components.code,{children:`Lint and Format Check`}),`) to pass `,_jsx(_components.strong,{children:`and`}),` a pull request before merge, with admin bypass disabled. Since Vercel’s `,_jsx(_components.em,{children:`production`}),` deploy comes from `,_jsx(_components.code,{children:`main`}),`, gating `,_jsx(_components.code,{children:`main`}),` effectively gates production, even though Vercel itself doesn’t read CI. PR `,_jsx(_components.strong,{children:`preview`}),` deploys still happen regardless (you want to preview WIP).`]}),`\n`,_jsx(CodeBlock,{numberOfLines:`4`,language:`text`,children:_jsx(_components.pre,{className:`shiki shiki-themes github-light-default dark-plus`,style:{backgroundColor:`#ffffff`,\"--shiki-dark-bg\":`#0B0C0E`,color:`#1f2328`,\"--shiki-dark\":`#D4D4D4`},language:`text`,children:_jsxs(_components.code,{language:`text`,numberOfLines:`4`,children:[_jsx(_components.span,{className:`line`,children:_jsx(_components.span,{children:`feature branch → push → PR → CI checks + Vercel PREVIEW deploy`})}),`\n`,_jsx(_components.span,{className:`line`,children:_jsx(_components.span,{children:` → (merge blocked until checks green) → merge to main`})}),`\n`,_jsx(_components.span,{className:`line`,children:_jsx(_components.span,{children:` → Vercel PRODUCTION deploy`})}),`\n`,_jsx(_components.span,{className:`line`,children:_jsx(_components.span,{})})]})})}),`\n`,_jsxs(_components.p,{children:[_jsx(_components.strong,{children:`Dockerized too (optional).`}),` The frontend has a `,_jsx(_components.code,{children:`standalone`}),` Dockerfile (gated behind `,_jsx(_components.code,{children:`DOCKER_BUILD=1`}),` so `,_jsx(_components.code,{children:`next start`}),` and Vercel are unaffected) for own-infra/learning use, but Vercel is the live deploy. Vercel fits a client-rendered app better (managed CDN, image optimization, preview URLs) than self-hosting a container would (`,_jsx(_components.a,{href:`/decisions`,children:`ADR-009`}),`).`]}),`\n`,_jsx(Heading,{level:`2`,id:`not-yet-built`,children:`Not yet built`}),`\n`,_jsxs(_components.ul,{children:[`\n`,_jsxs(_components.li,{children:[_jsx(_components.strong,{children:`Rollback automation`}),`: Render keeps prior deploys (manual rollback in dashboard); no scripted rollback.`]}),`\n`,_jsxs(_components.li,{children:[_jsx(_components.strong,{children:`Graceful shutdown`}),` on deploy: SIGTERM currently drops WS connections uncleanly (`,_jsx(_components.code,{children:`close()`}),` exists as the building block; see `,_jsx(_components.a,{href:`/architecture/backend`,children:`Backend`}),`).`]}),`\n`,_jsxs(_components.li,{children:[_jsx(_components.strong,{children:`Automated post-deploy smoke`}),`: the deployed smoke test (`,_jsx(_components.a,{href:`/operations/testing`,children:`Testing`}),`) is run manually (`,_jsx(_components.code,{children:`npm run test:e2e:deployed`}),`); a workflow that runs it automatically `,_jsx(_components.em,{children:`after`}),` a Vercel deploy is a logical next step.`]}),`\n`,_jsxs(_components.li,{children:[_jsx(_components.strong,{children:`Pre-commit enforcement`}),`: lint/format are enforced today by the npm scripts (local, manual) and by CI as the merge gate. A `,_jsx(_components.strong,{children:`husky + lint-staged`}),` pre-commit hook would auto-run `,_jsx(_components.code,{children:`eslint --fix`}),` + `,_jsx(_components.code,{children:`prettier --write`}),` on staged files so unformatted code can’t even be committed, and `,_jsx(_components.strong,{children:`commitlint`}),` would enforce a commit-message convention (Conventional Commits). Planned tooling, not yet wired.`]}),`\n`]})]})}function MDXContent(props={}){const{wrapper:MDXLayout}={..._provideComponents(),...props.components};return MDXLayout?_jsx(MDXLayout,{...props,children:_jsx(_createMdxContent,{...props})}):_createMdxContent(props)}return{default:MDXContent};function _missingMdxReference(id,component){throw new Error(`Expected `+(component?`component`:`object`)+\" `\"+id+\"` to be defined: you likely forgot to import, pass, or provide it.\")}","frontmatter":{},"scope":{"config":{},"pageMetadata":{"title":"DevOps \u0026 CI/CD","description":"How code moves from a laptop to production: Docker, GitHub Actions, and Render.","href":"/operations/devops"}}},"mdxSourceWithNoJs":null},"mdxExtracts":{"tableOfContents":[{"title":"Environments","slug":"environments","depth":2,"children":[]},{"title":"The Docker image","slug":"the-docker-image","depth":2,"children":[]},{"title":"Local dev with Neon Local","slug":"local-dev-with-neon-local","depth":2,"children":[]},{"title":"CI/CD: three GitHub Actions workflows","slug":"ci/cd-three-github-actions-workflows","depth":2,"children":[]},{"title":"Deploy to Render","slug":"deploy-to-render","depth":2,"children":[{"title":"The flow, end to end","slug":"the-flow-end-to-end","depth":3,"children":[]}]},{"title":"Frontend CI/CD (sportz-ui)","slug":"frontend-ci/cd-sportz-ui","depth":2,"children":[]},{"title":"Not yet built","slug":"not-yet-built","depth":2,"children":[]}],"codeExamples":{}},"jsLinks":[],"subdomain":"sportzdocs","siteName":"Sportz Docs","title":"DevOps \u0026 CI/CD - Sportz Docs","navMaps":{"firstHrefInVersion":{},"firstHrefInLanguage":{}},"lastModified":"2026-08-28T14:51:30.767Z","originalFileLocation":"operations/devops.mdx"},"headHtml":"\u003cscript data-mint-head>(function(){var d=document.documentElement,p=null;if(!p){try{p=localStorage.getItem(\"isDarkMode\")}catch(e){}if(p==='true')p='dark';else if(p==='false')p='light';if(p!=='light'\u0026\u0026p!=='dark'\u0026\u0026p!=='system')p=\"system\"}var r=p==='system'?(matchMedia(\"(prefers-color-scheme: dark)\").matches?'dark':'light'):p;d.classList.remove(r==='dark'?'light':'dark');d.classList.add(r);d.setAttribute(\"data-theme-preference\",p)})();\u003c/script>\u003cmeta data-mint-head name=\"description\" content=\"How code moves from a laptop to production: Docker, GitHub Actions, and Render.\">\u003cmeta data-mint-head name=\"application-name\" content=\"Sportz Docs\">\u003cmeta data-mint-head name=\"generator\" content=\"Mintlify\">\u003clink data-mint-head rel=\"icon\" type=\"image/png\" sizes=\"192x192\" href=\"/mintlify-assets/_mintlify/favicons/sportzdocs/PXW7TdvbuE1uPR7j/_generated/favicon/android-chrome-192x192.png\" media=\"(prefers-color-scheme: light)\">\u003clink data-mint-head rel=\"apple-touch-icon\" type=\"image/png\" sizes=\"180x180\" href=\"/mintlify-assets/_mintlify/favicons/sportzdocs/PXW7TdvbuE1uPR7j/_generated/favicon/apple-touch-icon.png\">\u003clink data-mint-head rel=\"icon\" type=\"image/png\" sizes=\"16x16\" href=\"/mintlify-assets/_mintlify/favicons/sportzdocs/PXW7TdvbuE1uPR7j/_generated/favicon/favicon-16x16.png\" media=\"(prefers-color-scheme: light)\">\u003clink data-mint-head rel=\"icon\" type=\"image/png\" sizes=\"32x32\" href=\"/mintlify-assets/_mintlify/favicons/sportzdocs/PXW7TdvbuE1uPR7j/_generated/favicon/favicon-32x32.png\" media=\"(prefers-color-scheme: light)\">\u003clink data-mint-head rel=\"shortcut icon\" type=\"image/x-icon\" href=\"/mintlify-assets/_mintlify/favicons/sportzdocs/PXW7TdvbuE1uPR7j/_generated/favicon/favicon.ico\" media=\"(prefers-color-scheme: light)\">\u003clink data-mint-head rel=\"icon\" type=\"image/png\" sizes=\"192x192\" href=\"/mintlify-assets/_mintlify/favicons/sportzdocs/PXW7TdvbuE1uPR7j/_generated/favicon-dark/android-chrome-192x192.png\" media=\"(prefers-color-scheme: dark)\">\u003clink data-mint-head rel=\"icon\" type=\"image/png\" sizes=\"16x16\" href=\"/mintlify-assets/_mintlify/favicons/sportzdocs/PXW7TdvbuE1uPR7j/_generated/favicon-dark/favicon-16x16.png\" media=\"(prefers-color-scheme: dark)\">\u003clink data-mint-head rel=\"icon\" type=\"image/png\" sizes=\"32x32\" href=\"/mintlify-assets/_mintlify/favicons/sportzdocs/PXW7TdvbuE1uPR7j/_generated/favicon-dark/favicon-32x32.png\" media=\"(prefers-color-scheme: dark)\">\u003clink data-mint-head rel=\"shortcut icon\" type=\"image/x-icon\" href=\"/mintlify-assets/_mintlify/favicons/sportzdocs/PXW7TdvbuE1uPR7j/_generated/favicon-dark/favicon.ico\" media=\"(prefers-color-scheme: dark)\">\u003cmeta data-mint-head property=\"og:title\" content=\"DevOps \u0026amp; CI/CD - Sportz Docs\">\u003cmeta data-mint-head property=\"og:description\" content=\"How code moves from a laptop to production: Docker, GitHub Actions, and Render.\">\u003cmeta data-mint-head property=\"og:site_name\" content=\"Sportz Docs\">\u003cmeta data-mint-head property=\"og:url\" content=\"https://sportzdocs.mintlify.app/operations/devops\">\u003cmeta data-mint-head property=\"og:type\" content=\"website\">\u003cmeta data-mint-head property=\"og:image\" content=\"https://sportzdocs.mintlify.app/_mintlify/api/og/v2?division=Operations\u0026amp;title=DevOps+%26+CI%2FCD\u0026amp;description=How+code+moves+from+a+laptop+to+production%3A+Docker%2C+GitHub+Actions%2C+and+Render.\u0026amp;theme=613b975bd84dd49fe79bfd9d\u0026amp;signature=67246f69031eb664aa7c5e7bf94b3bd066666435624beffe9717d34c60d81894\">\u003cmeta data-mint-head property=\"og:image:width\" content=\"1200\">\u003cmeta data-mint-head property=\"og:image:height\" content=\"630\">\u003cmeta data-mint-head name=\"twitter:card\" content=\"summary_large_image\">\u003cmeta data-mint-head name=\"twitter:title\" content=\"DevOps \u0026amp; CI/CD - Sportz Docs\">\u003cmeta data-mint-head name=\"twitter:description\" content=\"How code moves from a laptop to production: Docker, GitHub Actions, and Render.\">\u003cmeta data-mint-head name=\"twitter:image\" content=\"https://sportzdocs.mintlify.app/_mintlify/api/og/v2?division=Operations\u0026amp;title=DevOps+%26+CI%2FCD\u0026amp;description=How+code+moves+from+a+laptop+to+production%3A+Docker%2C+GitHub+Actions%2C+and+Render.\u0026amp;theme=613b975bd84dd49fe79bfd9d\u0026amp;signature=67246f69031eb664aa7c5e7bf94b3bd066666435624beffe9717d34c60d81894\">\u003cmeta data-mint-head name=\"twitter:image:width\" content=\"1200\">\u003cmeta data-mint-head name=\"twitter:image:height\" content=\"630\">\u003cmeta data-mint-head name=\"msapplication-config\" content=\"/mintlify-assets/_mintlify/favicons/sportzdocs/PXW7TdvbuE1uPR7j/_generated/favicon/browserconfig.xml\">\u003cmeta data-mint-head name=\"apple-mobile-web-app-title\" content=\"Sportz Docs\">\u003cmeta data-mint-head name=\"msapplication-TileColor\" content=\"#EAB308\">\u003clink data-mint-head rel=\"canonical\" href=\"https://sportzdocs.mintlify.app/operations/devops\">\u003clink data-mint-head rel=\"alternate\" type=\"application/xml\" href=\"/sitemap.xml\">\u003clink data-mint-head rel=\"alternate\" type=\"text/markdown\" href=\"/operations/devops.md\">\u003cscript data-mint-head type=\"application/ld+json\">{\"@context\":\"https://schema.org\",\"@graph\":[{\"@type\":\"Organization\",\"@id\":\"https://sportzdocs.mintlify.app/#organization\",\"name\":\"Sportz Docs\",\"url\":\"https://sportzdocs.mintlify.app\",\"logo\":{\"@type\":\"ImageObject\",\"url\":\"https://mintcdn.com/sportzdocs/ZqapXRecIQ7JtK1M/logo/light.svg?fit=max\u0026auto=format\u0026n=ZqapXRecIQ7JtK1M\u0026q=85\u0026s=654dae11c1c4160e24667a72a6fa2fe7\"}},{\"@type\":\"WebSite\",\"@id\":\"https://sportzdocs.mintlify.app#website\",\"name\":\"Sportz Docs\",\"url\":\"https://sportzdocs.mintlify.app\",\"publisher\":{\"@id\":\"https://sportzdocs.mintlify.app/#organization\"}},{\"@type\":\"WebPage\",\"@id\":\"https://sportzdocs.mintlify.app/operations/devops#webpage\",\"url\":\"https://sportzdocs.mintlify.app/operations/devops\",\"name\":\"DevOps \u0026 CI/CD\",\"description\":\"How code moves from a laptop to production: Docker, GitHub Actions, and Render.\",\"dateModified\":\"2026-08-28T14:51:30.767Z\",\"isPartOf\":{\"@id\":\"https://sportzdocs.mintlify.app#website\"},\"breadcrumb\":{\"@id\":\"https://sportzdocs.mintlify.app/operations/devops#breadcrumb\"}},{\"@type\":\"BreadcrumbList\",\"@id\":\"https://sportzdocs.mintlify.app/operations/devops#breadcrumb\",\"itemListElement\":[{\"@type\":\"ListItem\",\"position\":1,\"name\":\"Operations\",\"item\":\"https://sportzdocs.mintlify.app/operations/testing\"},{\"@type\":\"ListItem\",\"position\":2,\"name\":\"DevOps \u0026 CI/CD\",\"item\":\"https://sportzdocs.mintlify.app/operations/devops\"}]},{\"@type\":[\"Article\",\"TechArticle\"],\"@id\":\"https://sportzdocs.mintlify.app/operations/devops#article\",\"headline\":\"DevOps \u0026 CI/CD\",\"name\":\"DevOps \u0026 CI/CD\",\"description\":\"How code moves from a laptop to production: Docker, GitHub Actions, and Render.\",\"url\":\"https://sportzdocs.mintlify.app/operations/devops\",\"mainEntityOfPage\":{\"@id\":\"https://sportzdocs.mintlify.app/operations/devops#webpage\"},\"dateModified\":\"2026-08-28T14:51:30.767Z\",\"publisher\":{\"@id\":\"https://sportzdocs.mintlify.app/#organization\"},\"isPartOf\":{\"@id\":\"https://sportzdocs.mintlify.app#website\"}}]}\u003c/script>\u003cscript data-mint-head type=\"application/ld+json\">{\"@context\":\"https://schema.org\",\"@type\":\"WebSite\",\"name\":\"Sportz Docs\",\"creator\":{\"@type\":\"Organization\",\"name\":\"Mintlify\",\"url\":\"https://mintlify.com\"}}\u003c/script>","siteName":"Sportz Docs","nav":{"global":{"anchors":[{"anchor":"Live Demo","icon":"rocket","href":"https://sportz-ui.vercel.app"},{"anchor":"Backend Repo","icon":"server","href":"https://github.com/Jheff4/sportz-websockets"},{"anchor":"Frontend Repo","icon":"browser","href":"https://github.com/Jheff4/sportz-ui"}]},"tabs":[{"tab":"Handbook","groups":[{"group":"Overview","pages":[{"title":"Sportz Engineering Handbook","href":""},{"title":"Project Status \u0026 Scope","href":"/project-status"},{"title":"User Stories","href":"/stories"}]},{"group":"Architecture","pages":[{"title":"System Architecture (C4)","href":"/architecture/system"},{"title":"Frontend Architecture","href":"/architecture/frontend"},{"title":"Backend Architecture","href":"/architecture/backend"},{"title":"Real-Time System Deep Dive","href":"/architecture/realtime"}]},{"group":"Engineering Stories","pages":[{"title":"Architectural Decision Records","href":"/decisions"},{"title":"Engineering Log: Issues \u0026 Post-Mortems","href":"/issues"}]},{"group":"Operations","pages":[{"title":"Testing Strategy","description":"What we test, how, and why: 95 Vitest tests across the backend plus a Playwright end-to-end suite on the frontend.","href":"/operations/testing"},{"title":"DevOps \u0026 CI/CD","href":"/operations/devops"},{"title":"Observability","description":"What's instrumented, end to end, and what's not built yet.","href":"/operations/observability"},{"title":"Security","href":"/operations/security"}]},{"group":"Reference","pages":[{"title":"API Reference","href":"/reference/api"}]}]},{"tab":"Learn \u0026 Interview","pages":[{"title":"Learn \u0026 Interview","href":"/learning/curriculum"}]}]},"basePath":"","title":"DevOps \u0026 CI/CD - Sportz Docs","buildId":"dpl_77Hw7ZuAABM8E3WMbYAf2ohQCr3v","deploymentId":"dpl_77Hw7ZuAABM8E3WMbYAf2ohQCr3v","dynamics":[],"chunks":[]}