@zaaxch/tailframe 2.1.0 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/new.mjs +403 -37
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zaaxch/tailframe",
3
- "version": "2.1.0",
3
+ "version": "2.2.0",
4
4
  "description": "Tailframe architecture toolkit: validates the Tailframe structure, import-boundary, and file-convention contracts. The package version is the contract version.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/new.mjs CHANGED
@@ -63,6 +63,9 @@ if (fs.existsSync(root) && fs.readdirSync(root).length) fail(`Refusing to overwr
63
63
  const title = options.name.split("-").map((part) => part[0].toUpperCase() + part.slice(1)).join(" ");
64
64
  const svc = `${options.name}-svc`;
65
65
  const ui = `${options.name}-ui`;
66
+ const databaseName = options.name.replaceAll("-", "_");
67
+ const mongoApplicationUser = `${options.name}-app`;
68
+ const mongoBackupUser = `${options.name}-backup`;
66
69
  const files = {};
67
70
  const add = (relative, content) => { files[relative] = content.endsWith("\n") ? content : `${content}\n`; };
68
71
  // Generated repositories depend on the published toolkit rather than copying a validator, and they
@@ -222,11 +225,24 @@ module.exports = { projects: [
222
225
  ] };`);
223
226
  add(`${svc}/.prettierrc.json`, JSON.stringify(prettierConfig, null, "\t"));
224
227
  add(`${svc}/.gitattributes`, gitAttributes);
225
- add(`${svc}/.gitignore`, `node_modules/\ndist/\n.env*\n!.env.example\n*.log\nfirebase-service-account*.json\n`);
228
+ add(`${svc}/.gitignore`, `node_modules/\ndist/\n.env*\n!.env.example\n!.env.infrastructure.example\n*.log\nfirebase-service-account*.json\n`);
226
229
  add(`${svc}/.dockerignore`, `node_modules\ndist\n.git\n.env*\n!.env.example\n*.log\ndata\nfirebase-service-account*.json\n`);
227
230
  if (options.ui) add(".dockerignore", `**/node_modules\n**/dist\n**/.git\n**/.env*\n**/*.log\n**/data\n**/firebase-service-account*.json\n`);
228
- const dbEnv = "MONGODB_URI=mongodb://localhost:27017/?replicaSet=rs0&directConnection=true\nMONGODB_DB_NAME=" + options.name.replaceAll("-", "_");
231
+ const dbEnv = "MONGODB_URI=mongodb://localhost:27017/?replicaSet=rs0&directConnection=true\nMONGODB_DB_NAME=" + databaseName;
229
232
  add(`${svc}/.env.example`, `NODE_ENV=development\nPORT=3000\nCORS_ORIGIN=https://localhost:5173\n${dbEnv}\n${options.auth === "firebase" ? "FIREBASE_PROJECT_ID=\n" : ""}${options.redis ? "REDIS_URL=redis://localhost:6379\n" : ""}`);
233
+ add(`${svc}/.env.infrastructure.example`, `ECR_IMAGE=<account>.dkr.ecr.<region>.amazonaws.com/${options.name}
234
+ IMAGE_TAG=<service-sha>${options.ui ? "_<ui-sha>" : ""}
235
+ APP_ENV_FILE=/opt/${options.name}/shared/.env.production
236
+ MONGO_DATA_DIR=/opt/${options.name}/shared/mongo/data
237
+ MONGO_KEYFILE=/opt/${options.name}/shared/mongo/config/keyfile
238
+ MONGO_INIT_SCRIPT=/opt/${options.name}/current/deploy/mongo/10-create-users.js
239
+ MONGODB_ROOT_PASSWORD=<uri-safe-random-value>
240
+ MONGODB_APP_PASSWORD=<different-uri-safe-random-value>
241
+ MONGODB_BACKUP_PASSWORD=<different-uri-safe-random-value>
242
+ ${options.auth === "firebase" ? `SECRETS_DIR=/opt/${options.name}/shared/secrets
243
+ ` : ""}${options.redis ? `REDIS_DATA_DIR=/opt/${options.name}/shared/redis/data
244
+ REDIS_PASSWORD=<different-uri-safe-random-value>
245
+ ` : ""}`);
230
246
  add(`${svc}/src/platform/config/env.ts`, `import "@dotenvx/dotenvx/config";
231
247
  export const env = {
232
248
  nodeEnv: process.env.NODE_ENV ?? "development",
@@ -234,7 +250,7 @@ export const env = {
234
250
  corsOrigin: process.env.CORS_ORIGIN ?? "https://localhost:5173",
235
251
  ${options.auth === "firebase" ? `firebaseProjectId: process.env.FIREBASE_PROJECT_ID ?? "",` : ""}
236
252
  mongodbUri: process.env.MONGODB_URI ?? "mongodb://localhost:27017/?replicaSet=rs0&directConnection=true",
237
- mongodbDbName: process.env.MONGODB_DB_NAME ?? "${options.name.replaceAll("-", "_")}",
253
+ mongodbDbName: process.env.MONGODB_DB_NAME ?? "${databaseName}",
238
254
  ${options.redis ? `redisUrl: process.env.REDIS_URL ?? "redis://localhost:6379",` : ""}
239
255
  };`);
240
256
  const databaseSource = `import { MongoClient } from "mongodb";
@@ -444,10 +460,14 @@ This service uses MongoDB. Module-owned MongoDB adapters own collection access,
444
460
  - Keep optional entry-point assembly under \`src/app/<entry-point-kind>\`; root files such as \`src/server.ts\` and \`src/worker.ts\` may only bootstrap that assembly.
445
461
  - Initialize infrastructure first, then call \`registerDependencies(...)\` from every process entry point. The composition root resets the container, explicitly constructs dependencies, and registers class-token instances. Keep tsyringe, injection tokens, decorators, and container resolution out of modules and platform adapters.
446
462
  - Register routes, workers, and jobs explicitly. Shutdown paths are awaitable and close resources without forcing \`process.exit\`.
463
+ - Scheduled work prevents unintended overlap, stops intake before shutdown, awaits the active run, destroys its scheduler or consumer, and only then closes Redis and database clients.
447
464
  ${options.ui ? "- In production, serve the compiled UI from `dist/public` with a non-API SPA fallback. Never return the SPA for `/api` requests." : ""}
448
465
 
449
466
  ## Production image
450
- The ECR build targets \`linux/amd64\`. ${options.ui ? "Its default immutable tag is `<service-sha>_<ui-sha>` and production Compose requires that exact tag." : "Its default immutable tag is `<service-sha>` and production Compose requires that exact tag."}${options.ui && options.auth === "firebase" ? ` The build requires \`${ui}/.env.production\` and mounts it as a BuildKit secret only while Vite compiles the browser bundle; it is not copied into the final image.` : ""}
467
+ The ECR build targets \`linux/amd64\`. ${options.ui ? "Its default immutable tag is `<service-sha>_<ui-sha>` and production Compose requires that exact tag." : "Its default immutable tag is `<service-sha>` and production Compose requires that exact tag."} The runtime image contains production dependencies only and runs as the Node user.${options.ui && options.auth === "firebase" ? ` The build requires \`${ui}/.env.production\` and mounts it as a BuildKit secret only while Vite compiles the browser bundle; it is not copied into the final image.` : ""}
468
+
469
+ ## Production deployment
470
+ Only service \`main\` pushes or manual workflow dispatch deploy; UI pushes never trigger deployment. GitHub Actions may replace files under \`/opt/${options.name}/current\` and MUST NOT replace persistent configuration, credentials, keyfiles, or data under \`/opt/${options.name}/shared\`. Keep Node runtime behavior in the shared service \`.env.production\`, Compose interpolation and infrastructure credentials in \`.env.infrastructure\`, and fixed topology wiring in Compose \`environment:\`. The optional UI build-time \`.env.production\` is a separate ephemeral file. Do not run deployment until the host and GitHub production environment have been bootstrapped according to \`docs/production-deployment.md\`.
451
471
 
452
472
  ## Tests and validation
453
473
  Run \`npm run validate:architecture\` and \`npm run format:check\` after changing files or imports. The generated service also includes unit tests and Mongo-backed integration tests. Run \`npm run test:db:up\`, \`npm test\`, and \`npm run test:db:down\` for the full service check. Test use cases, policies, Mongo repository adapters, and HTTP boundaries where behavior lives. Every public operation needs success and error-envelope coverage; authenticated operations need trusted-identity coverage; persisted capabilities need Mongo ownership/query coverage; UI-enabled services must verify that \`/api\` paths never fall through to the SPA. All AWS/ECR and Docker commands, including \`npm run docker:push\`, must run outside the sandbox from the first attempt.
@@ -610,6 +630,11 @@ RUN npm ci
610
630
  COPY ${svc}/ ./
611
631
  RUN npm run build
612
632
 
633
+ FROM node:22.13-bookworm AS service-production-dependencies
634
+ WORKDIR /app/service
635
+ COPY ${svc}/package*.json ./
636
+ RUN npm ci --omit=dev && npm cache clean --force
637
+
613
638
  FROM node:22.13-bookworm AS ui-build
614
639
  WORKDIR /app/ui
615
640
  COPY ${ui}/package*.json ./
@@ -622,8 +647,9 @@ WORKDIR /app
622
647
  ENV NODE_ENV=production
623
648
  COPY --from=service-build /app/service/dist ./dist
624
649
  COPY --from=service-build /app/service/package*.json ./
625
- COPY --from=service-build /app/service/node_modules ./node_modules
650
+ COPY --from=service-production-dependencies /app/service/node_modules ./node_modules
626
651
  COPY --from=ui-build /app/ui/dist ./dist/public
652
+ USER node
627
653
  EXPOSE 3000
628
654
  CMD ["node", "dist/server.js"]
629
655
  ` : `FROM node:22.13-bookworm AS build
@@ -633,12 +659,18 @@ RUN npm ci
633
659
  COPY . .
634
660
  RUN npm run build
635
661
 
662
+ FROM node:22.13-bookworm AS production-dependencies
663
+ WORKDIR /app
664
+ COPY package*.json ./
665
+ RUN npm ci --omit=dev && npm cache clean --force
666
+
636
667
  FROM node:22.13-bookworm-slim
637
668
  WORKDIR /app
638
669
  ENV NODE_ENV=production
639
670
  COPY --from=build /app/dist ./dist
640
671
  COPY --from=build /app/package*.json ./
641
- COPY --from=build /app/node_modules ./node_modules
672
+ COPY --from=production-dependencies /app/node_modules ./node_modules
673
+ USER node
642
674
  EXPOSE 3000
643
675
  CMD ["node", "dist/server.js"]
644
676
  `);
@@ -678,75 +710,409 @@ docker push "\${IMAGE}"
678
710
  echo "Pushed \${IMAGE}"
679
711
  `);
680
712
 
713
+ const workflowUiCheckout = options.ui ? `
714
+ - name: Checkout UI dependency
715
+ uses: actions/checkout@v6
716
+ with:
717
+ repository: \${{ github.repository_owner }}/${ui}
718
+ ref: main
719
+ ssh-key: \${{ secrets.UI_REPO_SSH_KEY }}
720
+ path: ${ui}
721
+ ` : "";
722
+ const workflowUiEnvironment = options.ui && options.auth === "firebase" ? `
723
+ - name: Write UI production environment
724
+ env:
725
+ UI_ENV_PRODUCTION: \${{ secrets.UI_ENV_PRODUCTION }}
726
+ run: |
727
+ printf '%s\\n' "$UI_ENV_PRODUCTION" > ${ui}/.env.production
728
+ ` : "";
729
+ const workflowTagResolution = options.ui ? `
730
+ ui_sha="$(git -C ${ui} rev-parse --short HEAD)"
731
+ echo "tag=\${svc_sha}_\${ui_sha}" >> "$GITHUB_OUTPUT"` : `
732
+ echo "tag=\${svc_sha}" >> "$GITHUB_OUTPUT"`;
733
+ add(`${svc}/.github/workflows/deploy.yml`, `name: Build and deploy
734
+
735
+ on:
736
+ push:
737
+ branches:
738
+ - main
739
+ workflow_dispatch:
740
+
741
+ permissions:
742
+ contents: read
743
+ id-token: write
744
+
745
+ concurrency:
746
+ group: ${options.name}-production
747
+ cancel-in-progress: false
748
+
749
+ jobs:
750
+ deploy:
751
+ name: Deploy production
752
+ if: \${{ vars.DEPLOY_HOST != '' && vars.DEPLOY_USER != '' }}
753
+ runs-on: ubuntu-latest
754
+ environment: production
755
+ env:
756
+ AWS_ACCOUNT_ID: \${{ vars.AWS_ACCOUNT_ID }}
757
+ AWS_REGION: \${{ vars.AWS_REGION }}
758
+ ECR_REPOSITORY: \${{ vars.ECR_REPOSITORY || '${options.name}' }}
759
+ DEPLOY_HOST: \${{ vars.DEPLOY_HOST }}
760
+ DEPLOY_USER: \${{ vars.DEPLOY_USER }}
761
+
762
+ steps:
763
+ - name: Checkout service
764
+ uses: actions/checkout@v6
765
+ with:
766
+ path: ${svc}
767
+ ${workflowUiCheckout}${workflowUiEnvironment}
768
+ - name: Resolve immutable image
769
+ id: image
770
+ run: |
771
+ svc_sha="$(git -C ${svc} rev-parse --short HEAD)"${workflowTagResolution}
772
+ echo "registry=\${AWS_ACCOUNT_ID}.dkr.ecr.\${AWS_REGION}.amazonaws.com" >> "$GITHUB_OUTPUT"
773
+ echo "image=\${AWS_ACCOUNT_ID}.dkr.ecr.\${AWS_REGION}.amazonaws.com/\${ECR_REPOSITORY}" >> "$GITHUB_OUTPUT"
774
+
775
+ - name: Configure AWS credentials
776
+ uses: aws-actions/configure-aws-credentials@v6
777
+ with:
778
+ role-to-assume: \${{ vars.AWS_ROLE_ARN }}
779
+ aws-region: \${{ vars.AWS_REGION }}
780
+
781
+ - name: Build and push image
782
+ working-directory: ${svc}
783
+ env:
784
+ IMAGE_TAG: \${{ steps.image.outputs.tag }}
785
+ run: ./scripts/build_and_push.sh
786
+
787
+ - name: Configure deployment SSH
788
+ env:
789
+ DEPLOY_SSH_PRIVATE_KEY: \${{ secrets.DEPLOY_SSH_PRIVATE_KEY }}
790
+ DEPLOY_SSH_KNOWN_HOSTS: \${{ secrets.DEPLOY_SSH_KNOWN_HOSTS }}
791
+ run: |
792
+ install -m 700 -d ~/.ssh
793
+ printf '%s\\n' "$DEPLOY_SSH_PRIVATE_KEY" > ~/.ssh/${options.name}-deploy
794
+ chmod 600 ~/.ssh/${options.name}-deploy
795
+ printf '%s\\n' "$DEPLOY_SSH_KNOWN_HOSTS" > ~/.ssh/known_hosts
796
+
797
+ - name: Authenticate server with ECR
798
+ run: |
799
+ aws ecr get-login-password --region "$AWS_REGION" |
800
+ ssh -i ~/.ssh/${options.name}-deploy "\${DEPLOY_USER}@\${DEPLOY_HOST}" \\
801
+ "docker login --username AWS --password-stdin '\${{ steps.image.outputs.registry }}'"
802
+
803
+ - name: Install deployment files
804
+ run: |
805
+ ssh -i ~/.ssh/${options.name}-deploy "\${DEPLOY_USER}@\${DEPLOY_HOST}" \\
806
+ "install -m 755 -d /opt/${options.name}/current/deploy/mongo"
807
+ scp -i ~/.ssh/${options.name}-deploy \\
808
+ ${svc}/docker-compose.yml \\
809
+ ${svc}/scripts/deploy_remote.sh \\
810
+ "\${DEPLOY_USER}@\${DEPLOY_HOST}:/opt/${options.name}/current/"
811
+ scp -i ~/.ssh/${options.name}-deploy \\
812
+ ${svc}/deploy/mongo/10-create-users.js \\
813
+ "\${DEPLOY_USER}@\${DEPLOY_HOST}:/opt/${options.name}/current/deploy/mongo/"
814
+ ssh -i ~/.ssh/${options.name}-deploy "\${DEPLOY_USER}@\${DEPLOY_HOST}" \\
815
+ "chmod 755 /opt/${options.name}/current/deploy_remote.sh"
816
+
817
+ - name: Deploy image
818
+ run: |
819
+ ssh -i ~/.ssh/${options.name}-deploy "\${DEPLOY_USER}@\${DEPLOY_HOST}" \\
820
+ "/opt/${options.name}/current/deploy_remote.sh '\${{ steps.image.outputs.tag }}' '\${{ steps.image.outputs.image }}'"
821
+ `);
822
+
823
+ add(`${svc}/scripts/deploy_remote.sh`, `#!/usr/bin/env bash
824
+ set -euo pipefail
825
+
826
+ IMAGE_TAG="\${1:?Usage: deploy_remote.sh IMAGE_TAG ECR_IMAGE}"
827
+ ECR_IMAGE="\${2:?Usage: deploy_remote.sh IMAGE_TAG ECR_IMAGE}"
828
+ DEPLOY_DIR="\${DEPLOY_DIR:-/opt/${options.name}/current}"
829
+ SHARED_DIR="\${SHARED_DIR:-/opt/${options.name}/shared}"
830
+ COMPOSE_FILE="\${DEPLOY_DIR}/docker-compose.yml"
831
+ INFRA_ENV="\${SHARED_DIR}/.env.infrastructure"
832
+
833
+ if [[ ! -f "\${INFRA_ENV}" ]]; then
834
+ echo "Missing provisioned infrastructure environment: \${INFRA_ENV}" >&2
835
+ exit 1
836
+ fi
837
+
838
+ NEXT_ENV="$(mktemp "\${SHARED_DIR}/.env.infrastructure.XXXXXX")"
839
+ cleanup() {
840
+ rm -f "\${NEXT_ENV}"
841
+ }
842
+ trap cleanup EXIT
843
+
844
+ grep -Ev '^(ECR_IMAGE|IMAGE_TAG)=' "\${INFRA_ENV}" > "\${NEXT_ENV}" || true
845
+ printf 'ECR_IMAGE=%s\\nIMAGE_TAG=%s\\n' "\${ECR_IMAGE}" "\${IMAGE_TAG}" >> "\${NEXT_ENV}"
846
+ chmod 600 "\${NEXT_ENV}"
847
+ mv "\${NEXT_ENV}" "\${INFRA_ENV}"
848
+ trap - EXIT
849
+
850
+ docker compose --env-file "\${INFRA_ENV}" --file "\${COMPOSE_FILE}" config --quiet
851
+ docker compose --env-file "\${INFRA_ENV}" --file "\${COMPOSE_FILE}" pull
852
+ docker compose --env-file "\${INFRA_ENV}" --file "\${COMPOSE_FILE}" up -d --wait
853
+ `);
854
+
855
+ add(`${svc}/deploy/mongo/10-create-users.js`, `const applicationPassword = process.env.MONGODB_APP_PASSWORD;
856
+ const backupPassword = process.env.MONGODB_BACKUP_PASSWORD;
857
+
858
+ if (!applicationPassword || !backupPassword) {
859
+ throw new Error("MONGODB_APP_PASSWORD and MONGODB_BACKUP_PASSWORD are required");
860
+ }
861
+
862
+ db.getSiblingDB("${databaseName}").createUser({
863
+ user: "${mongoApplicationUser}",
864
+ pwd: applicationPassword,
865
+ roles: [{ role: "readWrite", db: "${databaseName}" }]
866
+ });
867
+
868
+ db.getSiblingDB("admin").createUser({
869
+ user: "${mongoBackupUser}",
870
+ pwd: backupPassword,
871
+ roles: [{ role: "backup", db: "admin" }]
872
+ });
873
+ `);
874
+
875
+ add(`${svc}/docs/production-deployment.md`, `# Production deployment
876
+
877
+ ## Delivery model
878
+
879
+ GitHub Actions checks out ${svc}${options.ui ? ` and ${ui}` : ""}${options.ui ? " as sibling build inputs" : ""}, builds one
880
+ non-root production image, tags it ${options.ui ? "`<service-sha>_<ui-sha>`" : "`<service-sha>`"}, pushes it to ECR, and installs only deployment artifacts on the host.
881
+ Only service \`main\` pushes and manual dispatch trigger this workflow; UI pushes never deploy.
882
+
883
+ The host separates replaceable artifacts from persistent state:
884
+
885
+ \`\`\`text
886
+ /opt/${options.name}/
887
+ ├── current/
888
+ │ ├── docker-compose.yml
889
+ │ ├── deploy_remote.sh
890
+ │ └── deploy/mongo/10-create-users.js
891
+ └── shared/
892
+ ├── .env.production
893
+ ├── .env.infrastructure
894
+ ${options.auth === "firebase" ? ` ├── secrets/firebase-service-account.json
895
+ ` : ""} ├── mongo/config/keyfile
896
+ ├── mongo/data/
897
+ ${options.redis ? ` └── redis/data/
898
+ ` : ""}\`\`\`
899
+
900
+ Actions may replace \`current/\` and must never replace \`shared/\`. The server never needs a source checkout.
901
+
902
+ ## Configuration ownership
903
+
904
+ - \`.env.production\` contains Node application behavior and provider credentials.
905
+ - \`.env.infrastructure\` contains Compose paths, image identity, and infrastructure passwords. Start from
906
+ \`.env.infrastructure.example\` and replace every placeholder.
907
+ - Compose \`environment:\` owns fixed production wiring: \`NODE_ENV\`, internal database/cache URLs, and mounted
908
+ credential paths.
909
+ ${options.ui && options.auth === "firebase" ? `- The UI repository's build-time \`.env.production\` comes from the GitHub \`UI_ENV_PRODUCTION\` secret and is unrelated to the service runtime file above.
910
+ ` : ""}
911
+ Do not inject \`.env.infrastructure\` into Node. Make both host environment files root-owned and mode \`0600\`.
912
+
913
+ ## One-time host bootstrap
914
+
915
+ 1. Install Docker Engine and Docker Compose.
916
+ 2. Create \`/opt/${options.name}/current/deploy/mongo\`, \`/opt/${options.name}/shared/mongo/config\`, and
917
+ \`/opt/${options.name}/shared/mongo/data\`${options.redis ? `, plus \`/opt/${options.name}/shared/redis/data\`` : ""}.
918
+ 3. Install \`.env.production\` and a completed \`.env.infrastructure\` under \`shared/\`.
919
+ 4. Generate the MongoDB replica key with \`openssl rand -base64 756\`, make it owned by UID/GID \`999:999\`, and
920
+ mode \`0400\`.
921
+ ${options.auth === "firebase" ? `5. Install the Firebase service account at \`shared/secrets/firebase-service-account.json\`, owned by \`root:1000\` and mode \`0640\`.
922
+ 6. ` : "5. "}Give the deployment user Docker access and write access to \`current/\`; keep \`shared/\` and its files protected.
923
+ ${options.auth === "firebase" ? "7" : "6"}. Authorize the dedicated deployment SSH key and record the host key for GitHub Actions.
924
+
925
+ Use a fresh Mongo data directory. Migrating an existing unauthenticated database requires a separately reviewed plan.
926
+
927
+ ## GitHub production environment
928
+
929
+ Configure these variables:
930
+
931
+ - \`AWS_ACCOUNT_ID\`, \`AWS_REGION\`, and \`AWS_ROLE_ARN\` for the repository-restricted OIDC role.
932
+ - \`DEPLOY_HOST\` and \`DEPLOY_USER\`; the deployment job remains skipped until both exist.
933
+ - Optional \`ECR_REPOSITORY\`; the default is \`${options.name}\`.
934
+
935
+ Configure these secrets:
936
+
937
+ ${options.ui ? `- \`UI_REPO_SSH_KEY\`: read-only deploy key for \`${ui}\`.
938
+ ` : ""}${options.ui && options.auth === "firebase" ? `- \`UI_ENV_PRODUCTION\`: Vite/Firebase build-time environment.
939
+ ` : ""}- \`DEPLOY_SSH_PRIVATE_KEY\`: dedicated host deployment key.
940
+ - \`DEPLOY_SSH_KNOWN_HOSTS\`: pinned host key.
941
+
942
+ The ECR repository and AWS OIDC provider/role are provisioned separately. The workflow never stores long-lived AWS
943
+ keys or creates cloud infrastructure.
944
+
945
+ ## Deploy and rollback
946
+
947
+ The remote script requires the provisioned infrastructure file, atomically replaces only \`ECR_IMAGE\` and
948
+ \`IMAGE_TAG\`, validates Compose, pulls, and starts with health waiting. Roll back by selecting a previous immutable
949
+ tag and running the same script. Never use \`docker compose down -v\` in production; image rollback does not restore
950
+ database state.
951
+
952
+ MongoDB and optional Redis expose no host ports and require authentication. Verify API readiness, container health,
953
+ database/cache authentication, file permissions, and off-host backup/restore before treating the deployment as
954
+ production-ready. Product-specific TLS, capacity limits, migrations, and scheduled-work grace periods must be added
955
+ from measured application requirements rather than copied from another product.
956
+ `);
957
+
681
958
  const serviceVolumes = [
682
- ...(options.auth === "firebase" ? [" - ./firebase-service-account.production.json:/app/firebase-service-account.production.json:ro"] : [])
959
+ ...(options.auth === "firebase" ? [" - ${SECRETS_DIR:?Set SECRETS_DIR}/firebase-service-account.json:/app/secrets/firebase-service-account.json:ro"] : [])
683
960
  ];
684
961
  const devVolumes = [
685
962
  " - .:/app",
686
963
  " - /app/node_modules",
687
964
  ...(options.auth === "firebase" ? [" - ./firebase-service-account.development.json:/app/firebase-service-account.development.json:ro"] : [])
688
965
  ];
689
- const dependencies = [
966
+ const productionDependencies = [
967
+ ["mongo-init", "service_completed_successfully"],
968
+ ...(options.redis ? [["redis", "service_healthy"]] : [])
969
+ ];
970
+ const developmentDependencies = [
690
971
  ["mongo-init", "service_completed_successfully"],
691
972
  ...(options.redis ? [["redis", "service_started"]] : [])
692
973
  ];
693
- const dependsBlock = `\n depends_on:\n${dependencies.map(([dependency, condition]) => ` ${dependency}:\n condition: ${condition}`).join("\n")}`;
974
+ const dependsBlock = `\n depends_on:\n${productionDependencies.map(([dependency, condition]) => ` ${dependency}:\n condition: ${condition}`).join("\n")}`;
975
+ const devDependsBlock = `\n depends_on:\n${developmentDependencies.map(([dependency, condition]) => ` ${dependency}:\n condition: ${condition}`).join("\n")}`;
976
+ const productionEnvironment = [
977
+ " NODE_ENV: production",
978
+ ` MONGODB_URI: "mongodb://${mongoApplicationUser}:\${MONGODB_APP_PASSWORD:?Set MONGODB_APP_PASSWORD}@mongodb:27017/${databaseName}?replicaSet=rs0&directConnection=true&authSource=${databaseName}"`,
979
+ ` MONGODB_DB_NAME: "${databaseName}"`,
980
+ ...(options.auth === "firebase" ? [" GOOGLE_APPLICATION_CREDENTIALS: /app/secrets/firebase-service-account.json"] : []),
981
+ ...(options.redis ? [" REDIS_URL: \"redis://:\${REDIS_PASSWORD:?Set REDIS_PASSWORD}@redis:6379\""] : [])
982
+ ];
983
+ const productionEnvironmentBlock = `\n environment:\n${productionEnvironment.join("\n")}`;
694
984
  const databaseEnvironmentBlock = `\n environment:\n MONGODB_URI: "mongodb://mongodb:27017/?replicaSet=rs0&directConnection=true"`;
695
985
  const volumeBlock = serviceVolumes.length ? `\n volumes:\n${serviceVolumes.join("\n")}` : "";
696
986
  const workerService = options.worker ? `\n worker:
697
987
  image: \${ECR_IMAGE:?Set ECR_IMAGE}:\${IMAGE_TAG:?Set IMAGE_TAG}
698
988
  env_file:
699
- - .env.production
700
- command: ["node", "dist/worker.js"]${databaseEnvironmentBlock}${volumeBlock}${dependsBlock}
989
+ - \${APP_ENV_FILE:-.env.production}
990
+ command: ["node", "dist/worker.js"]${productionEnvironmentBlock}${volumeBlock}${dependsBlock}
991
+ healthcheck:
992
+ test: ["CMD", "node", "-e", "process.kill(1, 0)"]
993
+ interval: 60s
994
+ timeout: 5s
995
+ retries: 3
996
+ start_period: 30s
997
+ stop_grace_period: 2m
998
+ networks:
999
+ - backend
701
1000
  restart: unless-stopped` : "";
702
1001
  const databaseService = `\n mongodb:
703
1002
  image: mongo:8
704
- command: mongod --replSet rs0 --bind_ip_all
1003
+ command:
1004
+ - mongod
1005
+ - --replSet
1006
+ - rs0
1007
+ - --bind_ip_all
1008
+ - --keyFile
1009
+ - /etc/mongo-keyfile/keyfile
1010
+ environment:
1011
+ MONGO_INITDB_DATABASE: "${databaseName}"
1012
+ MONGO_INITDB_ROOT_USERNAME: "${options.name}-root"
1013
+ MONGO_INITDB_ROOT_PASSWORD: \${MONGODB_ROOT_PASSWORD:?Set MONGODB_ROOT_PASSWORD}
1014
+ MONGODB_APP_PASSWORD: \${MONGODB_APP_PASSWORD:?Set MONGODB_APP_PASSWORD}
1015
+ MONGODB_BACKUP_PASSWORD: \${MONGODB_BACKUP_PASSWORD:?Set MONGODB_BACKUP_PASSWORD}
705
1016
  volumes:
706
- - mongodb-data:/data/db
1017
+ - \${MONGO_DATA_DIR:?Set MONGO_DATA_DIR}:/data/db
1018
+ - \${MONGO_KEYFILE:?Set MONGO_KEYFILE}:/etc/mongo-keyfile/keyfile:ro
1019
+ - \${MONGO_INIT_SCRIPT:?Set MONGO_INIT_SCRIPT}:/docker-entrypoint-initdb.d/10-create-users.js:ro
707
1020
  healthcheck:
708
- test: ["CMD", "mongosh", "--quiet", "--eval", "db.adminCommand('ping').ok"]
709
- interval: 2s
710
- timeout: 2s
1021
+ test:
1022
+ - CMD-SHELL
1023
+ - >-
1024
+ mongosh --quiet --host localhost
1025
+ --username "$\${MONGO_INITDB_ROOT_USERNAME}"
1026
+ --password "$\${MONGO_INITDB_ROOT_PASSWORD}"
1027
+ --authenticationDatabase admin
1028
+ --eval "quit(db.adminCommand({ping:1}).ok ? 0 : 1)"
1029
+ interval: 5s
1030
+ timeout: 5s
711
1031
  retries: 30
1032
+ start_period: 20s
1033
+ networks:
1034
+ - backend
712
1035
  restart: unless-stopped
713
1036
  mongo-init:
714
1037
  image: mongo:8
1038
+ environment:
1039
+ MONGO_INITDB_ROOT_USERNAME: "${options.name}-root"
1040
+ MONGO_INITDB_ROOT_PASSWORD: \${MONGODB_ROOT_PASSWORD:?Set MONGODB_ROOT_PASSWORD}
715
1041
  depends_on:
716
1042
  mongodb:
717
1043
  condition: service_healthy
718
1044
  restart: "no"
719
- entrypoint:
720
- - bash
721
- - -c
722
- - |
723
- mongosh --host mongodb --quiet --eval '
724
- try {
725
- rs.status().ok
726
- } catch (error) {
727
- rs.initiate({ _id: "rs0", members: [{ _id: 0, host: "localhost:27017" }] })
1045
+ entrypoint: ["bash", "-ec"]
1046
+ command:
1047
+ - >-
1048
+ mongosh --quiet --host mongodb:27017
1049
+ --username "$\${MONGO_INITDB_ROOT_USERNAME}"
1050
+ --password "$\${MONGO_INITDB_ROOT_PASSWORD}"
1051
+ --authenticationDatabase admin
1052
+ --eval "try { const status = rs.status(); quit(status.ok === 1 ? 0 : 1); }
1053
+ catch (error) {
1054
+ if (error.code === 94 || error.codeName === 'NotYetInitialized') {
1055
+ const result = rs.initiate({_id:'rs0',members:[{_id:0,host:'mongodb:27017'}]});
1056
+ quit(result.ok === 1 ? 0 : 1);
728
1057
  }
729
- '`;
1058
+ throw error;
1059
+ }"
1060
+ networks:
1061
+ - backend`;
730
1062
  const redisService = options.redis ? `\n redis:
731
1063
  image: redis:7-alpine
1064
+ command:
1065
+ - redis-server
1066
+ - --appendonly
1067
+ - "yes"
1068
+ - --appendfsync
1069
+ - everysec
1070
+ - --maxmemory-policy
1071
+ - noeviction
1072
+ - --requirepass
1073
+ - \${REDIS_PASSWORD:?Set REDIS_PASSWORD}
1074
+ environment:
1075
+ REDIS_PASSWORD: \${REDIS_PASSWORD:?Set REDIS_PASSWORD}
732
1076
  volumes:
733
- - redis-data:/data
1077
+ - \${REDIS_DATA_DIR:?Set REDIS_DATA_DIR}:/data
1078
+ healthcheck:
1079
+ test:
1080
+ - CMD-SHELL
1081
+ - REDISCLI_AUTH="$\${REDIS_PASSWORD}" redis-cli ping | grep -q PONG
1082
+ interval: 10s
1083
+ timeout: 3s
1084
+ retries: 10
1085
+ start_period: 10s
1086
+ networks:
1087
+ - backend
734
1088
  restart: unless-stopped` : "";
735
- const namedVolumes = [
736
- " mongodb-data:",
737
- ...(options.redis ? [" redis-data:"] : [])
738
- ];
739
1089
  add(`${svc}/docker-compose.yml`, `name: ${options.name}-prod
740
1090
  services:
741
1091
  ${options.name}:
742
1092
  image: \${ECR_IMAGE:?Set ECR_IMAGE}:\${IMAGE_TAG:?Set IMAGE_TAG}
743
1093
  env_file:
744
- - .env.production
1094
+ - \${APP_ENV_FILE:-.env.production}
745
1095
  ports:
746
- - "3000:3000"${databaseEnvironmentBlock}${volumeBlock}${dependsBlock}
1096
+ - "3000:3000"${productionEnvironmentBlock}${volumeBlock}${dependsBlock}
1097
+ healthcheck:
1098
+ test:
1099
+ - CMD-SHELL
1100
+ - >-
1101
+ node -e
1102
+ "fetch('http://127.0.0.1:3000/api/v1/health.readiness',
1103
+ {method:'POST',headers:{'Content-Type':'application/json','X-Requested-With':'XMLHttpRequest'},body:'{}'})
1104
+ .then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
1105
+ interval: 30s
1106
+ timeout: 10s
1107
+ retries: 3
1108
+ start_period: 30s
1109
+ stop_grace_period: 2m
1110
+ networks:
1111
+ - backend
747
1112
  restart: unless-stopped${workerService}${databaseService}${redisService}
748
- volumes:
749
- ${namedVolumes.join("\n")}
1113
+ networks:
1114
+ backend:
1115
+ driver: bridge
750
1116
  `);
751
1117
 
752
1118
  const devWorker = options.worker ? `\n worker:
@@ -757,7 +1123,7 @@ const devWorker = options.worker ? `\n worker:
757
1123
  - .env.development
758
1124
  command: ["npm", "run", "dev:worker"]${databaseEnvironmentBlock}
759
1125
  volumes:
760
- ${devVolumes.join("\n")}${dependsBlock}` : "";
1126
+ ${devVolumes.join("\n")}${devDependsBlock}` : "";
761
1127
  const devDatabase = `\n mongodb:
762
1128
  image: mongo:8
763
1129
  command: mongod --replSet rs0 --bind_ip_all
@@ -808,7 +1174,7 @@ services:
808
1174
  ports:
809
1175
  - "3000:3000"${databaseEnvironmentBlock}
810
1176
  volumes:
811
- ${devVolumes.join("\n")}${dependsBlock}${devWorker}${devDatabase}${devRedis}
1177
+ ${devVolumes.join("\n")}${devDependsBlock}${devWorker}${devDatabase}${devRedis}
812
1178
  volumes:
813
1179
  ${devNamedVolumes.join("\n")}
814
1180
  `);