deploy-stack 0.2.2 → 0.2.5
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.
- package/package.json +1 -1
- package/src/commands/init.js +41 -159
- package/src/utils/aws.js +38 -0
- package/src/utils/generator.js +56 -0
- package/src/utils/warnings.js +21 -0
- package/templates/README.md +9 -1
- package/templates/docker/node.Dockerfile +17 -2
package/package.json
CHANGED
package/src/commands/init.js
CHANGED
|
@@ -1,18 +1,15 @@
|
|
|
1
|
-
import { STSClient, GetCallerIdentityCommand } from '@aws-sdk/client-sts';
|
|
2
|
-
import { S3Client, CreateBucketCommand, PutBucketVersioningCommand } from '@aws-sdk/client-s3';
|
|
3
1
|
import fsSync from 'fs';
|
|
4
2
|
import fs from 'fs/promises';
|
|
5
3
|
import path from 'path';
|
|
6
|
-
import { fileURLToPath } from 'url';
|
|
7
4
|
import { intro, outro, group, text, select, spinner, cancel, confirm, log } from '@clack/prompts';
|
|
8
5
|
import color from 'picocolors';
|
|
9
6
|
|
|
10
7
|
import { checkDependency } from '../utils/system.js';
|
|
11
8
|
import { detectFramework } from '../utils/detector.js';
|
|
12
9
|
import { trackEvent } from '../core/telemetry.js';
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
10
|
+
import { getFrameworkWarning } from '../utils/warnings.js';
|
|
11
|
+
import { provisionStateBucket } from '../utils/aws.js';
|
|
12
|
+
import { generateTemplates } from '../utils/generator.js';
|
|
16
13
|
|
|
17
14
|
export async function mainStack() {
|
|
18
15
|
const startTime = Date.now();
|
|
@@ -111,19 +108,6 @@ export async function mainStack() {
|
|
|
111
108
|
placeholder: defaultPort,
|
|
112
109
|
defaultValue: defaultPort,
|
|
113
110
|
}),
|
|
114
|
-
framework: () => {
|
|
115
|
-
if (detectedFramework) return undefined;
|
|
116
|
-
|
|
117
|
-
return select({
|
|
118
|
-
message: 'Which framework preset should we configure?',
|
|
119
|
-
options: [
|
|
120
|
-
{ value: 'node', label: 'Node.js / Express' },
|
|
121
|
-
{ value: 'nextjs', label: 'Next.js (Standalone)' },
|
|
122
|
-
{ value: 'python', label: 'Python FastAPI' },
|
|
123
|
-
{ value: 'static', label: 'Static Site (Gatsby, React, plain HTML via Nginx)' },
|
|
124
|
-
],
|
|
125
|
-
});
|
|
126
|
-
},
|
|
127
111
|
size: () =>
|
|
128
112
|
select({
|
|
129
113
|
message: 'Select your Fargate compute size:',
|
|
@@ -200,138 +184,38 @@ export async function mainStack() {
|
|
|
200
184
|
s.start('Provisioning infrastructure...');
|
|
201
185
|
|
|
202
186
|
// 6. Provision S3 bucket for Terraform state & enable versioning
|
|
203
|
-
|
|
204
|
-
let awsAccountId;
|
|
187
|
+
let awsAccountId, stateBucketName;
|
|
205
188
|
try {
|
|
206
|
-
const
|
|
207
|
-
awsAccountId =
|
|
189
|
+
const bucketData = await provisionStateBucket(project.region, actualProjectName);
|
|
190
|
+
awsAccountId = bucketData.awsAccountId;
|
|
191
|
+
stateBucketName = bucketData.stateBucketName;
|
|
208
192
|
} catch (error) {
|
|
209
|
-
s.stop('❌ Failed to authenticate with AWS.');
|
|
210
|
-
console.error(color.red(`AWS Error:
|
|
211
|
-
|
|
212
|
-
trackEvent('cli-error', { step: 'aws_sts_auth', error_code: error.name });
|
|
193
|
+
s.stop('❌ Failed to provision remote state or authenticate with AWS.');
|
|
194
|
+
console.error(color.red(`AWS Error: ${error.message}`));
|
|
195
|
+
trackEvent('cli-error', { step: 'aws_provisioning', error_code: error.name || 'UNKNOWN' });
|
|
213
196
|
process.exit(1);
|
|
214
197
|
}
|
|
215
198
|
|
|
216
|
-
let stateBucketName = `${actualProjectName}-tfstate-${awsAccountId}`.toLowerCase().replace(/[^a-z0-9-]/g, '-');
|
|
217
|
-
if (stateBucketName.length > 63) {
|
|
218
|
-
stateBucketName = stateBucketName.substring(0, 63).replace(/-$/, '');
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
const s3Client = new S3Client({ region: project.region });
|
|
222
|
-
try {
|
|
223
|
-
await s3Client.send(new CreateBucketCommand({
|
|
224
|
-
Bucket: stateBucketName,
|
|
225
|
-
CreateBucketConfiguration: project.region === 'us-east-1' ? undefined : { LocationConstraint: project.region }
|
|
226
|
-
}));
|
|
227
|
-
|
|
228
|
-
await s3Client.send(new PutBucketVersioningCommand({
|
|
229
|
-
Bucket: stateBucketName,
|
|
230
|
-
VersioningConfiguration: { Status: 'Enabled' }
|
|
231
|
-
}));
|
|
232
|
-
} catch (error) {
|
|
233
|
-
if (error.name !== 'BucketAlreadyOwnedByYou') {
|
|
234
|
-
s.stop('❌ Failed to provision remote state.');
|
|
235
|
-
console.error(color.red(`AWS S3 Error: ${error.message}`));
|
|
236
|
-
|
|
237
|
-
trackEvent('cli-error', { step: 's3_bucket_creation', error_code: error.name });
|
|
238
|
-
process.exit(1);
|
|
239
|
-
}
|
|
240
|
-
}
|
|
241
|
-
|
|
242
199
|
s.message('Synthesizing Terraform templates...');
|
|
243
200
|
|
|
244
|
-
// 7.
|
|
245
|
-
await
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
let tfMain = await fs.readFile(tfMainPath, 'utf-8');
|
|
262
|
-
let tfNetwork = await fs.readFile(tfNetworkPath, 'utf-8');
|
|
263
|
-
let tfSecrets = await fs.readFile(tfSecretsPath, 'utf-8');
|
|
264
|
-
let tfOidc = await fs.readFile(tfOidcPath, 'utf-8');
|
|
265
|
-
let tfBackend = await fs.readFile(tfBackendPath, 'utf-8');
|
|
266
|
-
let tfCloudfront = await fs.readFile(tfCloudfrontPath, 'utf-8');
|
|
267
|
-
let dockerContent = await fs.readFile(dockerTemplatePath, 'utf-8');
|
|
268
|
-
let githubAction = await fs.readFile(githubActionPath, 'utf-8');
|
|
269
|
-
let readmeContent = await fs.readFile(readmePath, 'utf-8');
|
|
270
|
-
let gitignoreContent = await fs.readFile(gitignorePath, 'utf-8');
|
|
271
|
-
|
|
272
|
-
// 9. Update the variables
|
|
273
|
-
const injectVariables = (content) => {
|
|
274
|
-
return content
|
|
275
|
-
.replace(/{{PROJECT_NAME}}/g, actualProjectName)
|
|
276
|
-
.replace(/{{REGION}}/g, project.region)
|
|
277
|
-
.replace(/{{PORT}}/g, project.port)
|
|
278
|
-
.replace(/{{CPU}}/g, cpu)
|
|
279
|
-
.replace(/{{MEMORY}}/g, memory)
|
|
280
|
-
.replace(/{{COMPUTE_TIER}}/g, computeTier)
|
|
281
|
-
.replace(/{{ESTIMATED_COST}}/g, estimatedCost)
|
|
282
|
-
.replace(/{{STATE_BUCKET}}/g, stateBucketName)
|
|
283
|
-
.replace(/{{AWS_ACCOUNT_ID}}/g, awsAccountId)
|
|
284
|
-
.replace(/{{HEALTH_CHECK_PATH}}/g, healthCheckPath)
|
|
285
|
-
.replace(/{{DESIRED_COUNT}}/g, desiredCount)
|
|
286
|
-
.replace(/{{DEPLOY_BRANCH}}/g, deployBranch);
|
|
287
|
-
};
|
|
288
|
-
|
|
289
|
-
tfMain = injectVariables(tfMain);
|
|
290
|
-
tfNetwork = injectVariables(tfNetwork);
|
|
291
|
-
tfSecrets = injectVariables(tfSecrets);
|
|
292
|
-
tfOidc = injectVariables(tfOidc);
|
|
293
|
-
tfBackend = injectVariables(tfBackend);
|
|
294
|
-
tfCloudfront = injectVariables(tfCloudfront);
|
|
295
|
-
dockerContent = injectVariables(dockerContent);
|
|
296
|
-
githubAction = injectVariables(githubAction);
|
|
297
|
-
readmeContent = injectVariables(readmeContent);
|
|
298
|
-
|
|
299
|
-
// 10. Write the finalized files
|
|
300
|
-
await fs.writeFile(path.join(targetDir, 'terraform', 'main.tf'), tfMain);
|
|
301
|
-
await fs.writeFile(path.join(targetDir, 'terraform', 'network.tf'), tfNetwork);
|
|
302
|
-
await fs.writeFile(path.join(targetDir, 'terraform', 'secrets.tf'), tfSecrets);
|
|
303
|
-
await fs.writeFile(path.join(targetDir, 'terraform', 'oidc.tf'), tfOidc);
|
|
304
|
-
await fs.writeFile(path.join(targetDir, 'terraform', 'backend.tf'), tfBackend);
|
|
305
|
-
await fs.writeFile(path.join(targetDir, 'terraform', 'cloudfront.tf'), tfCloudfront);
|
|
306
|
-
await fs.writeFile(path.join(targetDir, 'Dockerfile'), dockerContent);
|
|
307
|
-
await fs.writeFile(path.join(targetDir, '.github', 'workflows', 'deploy.yml'), githubAction);
|
|
308
|
-
await fs.writeFile(path.join(targetDir, 'README.md'), readmeContent);
|
|
309
|
-
await fs.writeFile(path.join(targetDir, 'terraform', 'secret_keys.json'), "[]");
|
|
310
|
-
|
|
311
|
-
// 10.5. Ensure .gitignore exists and contains necessary Terraform ignores
|
|
312
|
-
const targetGitignore = path.join(targetDir, '.gitignore');
|
|
313
|
-
if (!fsSync.existsSync(targetGitignore)) {
|
|
314
|
-
// No gitignore exists? Give them the full template (Terraform + Node + Python)
|
|
315
|
-
await fs.writeFile(targetGitignore, gitignoreContent);
|
|
316
|
-
} else {
|
|
317
|
-
// File exists? Only inject the Terraform rules to prevent duplicates
|
|
318
|
-
const existingGitignore = await fs.readFile(targetGitignore, 'utf-8');
|
|
319
|
-
|
|
320
|
-
if (!existingGitignore.includes('terraform/.terraform/')) {
|
|
321
|
-
const terraformIgnores = `
|
|
322
|
-
# Added by deploy-stack (Terraform)
|
|
323
|
-
terraform/.terraform/
|
|
324
|
-
terraform/*.tfstate
|
|
325
|
-
terraform/*.tfstate.backup
|
|
326
|
-
terraform/.terraform.lock.hcl
|
|
327
|
-
terraform/secret_keys.json
|
|
328
|
-
terraform/.terraform.*
|
|
329
|
-
`;
|
|
330
|
-
await fs.appendFile(targetGitignore, '\n' + terraformIgnores);
|
|
331
|
-
}
|
|
332
|
-
}
|
|
201
|
+
// 7. Generate all templates and directories
|
|
202
|
+
await generateTemplates(targetDir, {
|
|
203
|
+
PROJECT_NAME: actualProjectName,
|
|
204
|
+
REGION: project.region,
|
|
205
|
+
PORT: project.port,
|
|
206
|
+
CPU: cpu,
|
|
207
|
+
MEMORY: memory,
|
|
208
|
+
COMPUTE_TIER: computeTier,
|
|
209
|
+
ESTIMATED_COST: estimatedCost,
|
|
210
|
+
STATE_BUCKET: stateBucketName,
|
|
211
|
+
AWS_ACCOUNT_ID: awsAccountId,
|
|
212
|
+
HEALTH_CHECK_PATH: healthCheckPath,
|
|
213
|
+
DESIRED_COUNT: desiredCount,
|
|
214
|
+
DEPLOY_BRANCH: deployBranch,
|
|
215
|
+
finalFramework: finalFramework
|
|
216
|
+
});
|
|
333
217
|
|
|
334
|
-
//
|
|
218
|
+
// 8. Track the event in telemetry
|
|
335
219
|
trackEvent('project_provisioned', {
|
|
336
220
|
projectName: actualProjectName,
|
|
337
221
|
framework: finalFramework,
|
|
@@ -344,27 +228,25 @@ export async function mainStack() {
|
|
|
344
228
|
|
|
345
229
|
s.stop('Infrastructure provisioned successfully!');
|
|
346
230
|
|
|
347
|
-
//
|
|
348
|
-
|
|
231
|
+
// 9. Provide the Outro, Framework Warnings and Next Steps
|
|
232
|
+
const frameworkWarnings = getFrameworkWarning(finalFramework);
|
|
349
233
|
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
color.yellow('\n You must modify your next.config file and create a health check route before deploying.') +
|
|
354
|
-
color.yellow('\n See the "Critical Application Prerequisites" section in your README.md for copy-paste code.\n\n');
|
|
355
|
-
}
|
|
234
|
+
const cdStep = projectName === '.' ? '' : `1. cd ${projectName}\n `;
|
|
235
|
+
const deployStepNum = projectName === '.' ? '1' : '2';
|
|
236
|
+
const gitStepNum = projectName === '.' ? '2' : '3';
|
|
356
237
|
|
|
357
238
|
outro(`
|
|
358
239
|
${color.green('✅ Project provisioned successfully!')}
|
|
240
|
+
|
|
241
|
+
${frameworkWarnings}
|
|
359
242
|
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
${color.cyan('Once deployed, Terraform will output your new https://*.cloudfront.net URL.')}
|
|
243
|
+
Next steps:
|
|
244
|
+
${cdStep}${deployStepNum}. Deploy infrastructure:
|
|
245
|
+
cd terraform && terraform init && terraform apply
|
|
246
|
+
${gitStepNum}. Push to GitHub:
|
|
247
|
+
git init && git add . && git commit -m "Initial commit"
|
|
248
|
+
|
|
249
|
+
${color.cyan('Once deployed, Terraform will output your new https://*.cloudfront.net URL.')}
|
|
368
250
|
|
|
369
251
|
${color.magenta('🚀 Infrastructure ready! Need help or have feedback? Grab 15 mins with Anton:')}
|
|
370
252
|
${color.underline('https://calendly.com/anton-codes-iac/15min')}
|
package/src/utils/aws.js
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { STSClient, GetCallerIdentityCommand } from '@aws-sdk/client-sts';
|
|
2
|
+
import { S3Client, CreateBucketCommand, PutBucketVersioningCommand } from '@aws-sdk/client-s3';
|
|
3
|
+
|
|
4
|
+
export async function provisionStateBucket(region, projectName) {
|
|
5
|
+
const stsClient = new STSClient({ region });
|
|
6
|
+
let awsAccountId;
|
|
7
|
+
|
|
8
|
+
// 1. Get AWS Account ID
|
|
9
|
+
const { Account } = await stsClient.send(new GetCallerIdentityCommand({}));
|
|
10
|
+
awsAccountId = Account;
|
|
11
|
+
|
|
12
|
+
// 2. Format Bucket Name
|
|
13
|
+
let stateBucketName = `${projectName}-tfstate-${awsAccountId}`.toLowerCase().replace(/[^a-z0-9-]/g, '-');
|
|
14
|
+
if (stateBucketName.length > 63) {
|
|
15
|
+
stateBucketName = stateBucketName.substring(0, 63).replace(/-$/, '');
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// 3. Create S3 Bucket & Enable Versioning
|
|
19
|
+
const s3Client = new S3Client({ region });
|
|
20
|
+
try {
|
|
21
|
+
await s3Client.send(new CreateBucketCommand({
|
|
22
|
+
Bucket: stateBucketName,
|
|
23
|
+
CreateBucketConfiguration: region === 'us-east-1' ? undefined : { LocationConstraint: region }
|
|
24
|
+
}));
|
|
25
|
+
|
|
26
|
+
await s3Client.send(new PutBucketVersioningCommand({
|
|
27
|
+
Bucket: stateBucketName,
|
|
28
|
+
VersioningConfiguration: { Status: 'Enabled' }
|
|
29
|
+
}));
|
|
30
|
+
} catch (error) {
|
|
31
|
+
// Ignore if the bucket already exists and is owned by the user
|
|
32
|
+
if (error.name !== 'BucketAlreadyOwnedByYou') {
|
|
33
|
+
throw error;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return { awsAccountId, stateBucketName };
|
|
38
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import fsSync from 'fs';
|
|
2
|
+
import fs from 'fs/promises';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import { fileURLToPath } from 'url';
|
|
5
|
+
|
|
6
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
7
|
+
const __dirname = path.dirname(__filename);
|
|
8
|
+
|
|
9
|
+
export async function generateTemplates(targetDir, config) {
|
|
10
|
+
// 1. Create directories
|
|
11
|
+
await fs.mkdir(targetDir, { recursive: true });
|
|
12
|
+
await fs.mkdir(path.join(targetDir, 'terraform'), { recursive: true });
|
|
13
|
+
await fs.mkdir(path.join(targetDir, '.github', 'workflows'), { recursive: true });
|
|
14
|
+
|
|
15
|
+
// 2. Define paths
|
|
16
|
+
const templatesDir = path.join(__dirname, '../../templates');
|
|
17
|
+
const filesToProcess = [
|
|
18
|
+
{ src: 'terraform/main.tf', dest: 'terraform/main.tf' },
|
|
19
|
+
{ src: 'terraform/network.tf', dest: 'terraform/network.tf' },
|
|
20
|
+
{ src: 'terraform/secrets.tf', dest: 'terraform/secrets.tf' },
|
|
21
|
+
{ src: 'terraform/oidc.tf', dest: 'terraform/oidc.tf' },
|
|
22
|
+
{ src: 'terraform/backend.tf', dest: 'terraform/backend.tf' },
|
|
23
|
+
{ src: 'terraform/cloudfront.tf', dest: 'terraform/cloudfront.tf' },
|
|
24
|
+
{ src: `docker/${config.finalFramework}.Dockerfile`, dest: 'Dockerfile' },
|
|
25
|
+
{ src: 'github/deploy.yml', dest: '.github/workflows/deploy.yml' },
|
|
26
|
+
{ src: 'README.md', dest: 'README.md' }
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
// 3. Process standard files
|
|
30
|
+
for (const file of filesToProcess) {
|
|
31
|
+
let content = await fs.readFile(path.join(templatesDir, file.src), 'utf-8');
|
|
32
|
+
|
|
33
|
+
// Inject variables
|
|
34
|
+
for (const [key, value] of Object.entries(config)) {
|
|
35
|
+
content = content.replace(new RegExp(`{{${key}}}`, 'g'), value);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
await fs.writeFile(path.join(targetDir, file.dest), content);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// 4. Create empty secrets file
|
|
42
|
+
await fs.writeFile(path.join(targetDir, 'terraform', 'secret_keys.json'), "[]");
|
|
43
|
+
|
|
44
|
+
// 5. Handle .gitignore appending cleanly
|
|
45
|
+
const targetGitignore = path.join(targetDir, '.gitignore');
|
|
46
|
+
const gitignoreContent = await fs.readFile(path.join(templatesDir, '_gitignore'), 'utf-8');
|
|
47
|
+
|
|
48
|
+
if (!fsSync.existsSync(targetGitignore)) {
|
|
49
|
+
await fs.writeFile(targetGitignore, gitignoreContent);
|
|
50
|
+
} else {
|
|
51
|
+
const existingGitignore = await fs.readFile(targetGitignore, 'utf-8');
|
|
52
|
+
if (!existingGitignore.includes('terraform/.terraform/')) {
|
|
53
|
+
await fs.appendFile(targetGitignore, '\n# Added by deploy-stack (Terraform)\nterraform/.terraform/\nterraform/*.tfstate\nterraform/*.tfstate.backup\nterraform/.terraform.lock.hcl\nterraform/secret_keys.json\nterraform/.terraform.*\n');
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import color from 'picocolors';
|
|
2
|
+
|
|
3
|
+
export function getFrameworkWarning(frameworkId) {
|
|
4
|
+
switch (frameworkId) {
|
|
5
|
+
case 'nextjs':
|
|
6
|
+
return (
|
|
7
|
+
color.bgYellow(color.black(' ⚠️ IMPORTANT: NEXT.JS SETUP REQUIRED ')) +
|
|
8
|
+
color.yellow('\n You must modify your next.config file and create a health check route before deploying.') +
|
|
9
|
+
color.yellow('\n See the "Critical Application Prerequisites" section in your README.md for copy-paste code.\n\n')
|
|
10
|
+
);
|
|
11
|
+
case 'node':
|
|
12
|
+
return (
|
|
13
|
+
color.bgYellow(color.black(' ⚠️ IMPORTANT: NODE.JS SETUP REQUIRED ')) +
|
|
14
|
+
color.yellow('\n 1. Ensure your package.json has a "start" script (e.g., "start": "node index.js").') +
|
|
15
|
+
color.yellow('\n 2. Your app must listen on 0.0.0.0 (not localhost) to receive traffic in Docker.\n\n')
|
|
16
|
+
);
|
|
17
|
+
// Python / FastAPI warnings can be added here easily!
|
|
18
|
+
default:
|
|
19
|
+
return '';
|
|
20
|
+
}
|
|
21
|
+
}
|
package/templates/README.md
CHANGED
|
@@ -82,4 +82,12 @@ const nextConfig: NextConfig = {
|
|
|
82
82
|
};
|
|
83
83
|
|
|
84
84
|
export default nextConfig;
|
|
85
|
-
```
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
### 3. Container Network Binding (Node & Python)
|
|
88
|
+
|
|
89
|
+
When running inside a Docker container, your server must bind to all network interfaces (`0.0.0.0`), not just `localhost` or `127.0.0.1`. If you bind to localhost, the AWS Load Balancer will not be able to route traffic to your application.
|
|
90
|
+
|
|
91
|
+
Make sure your app is configured correctly:
|
|
92
|
+
* **Express.js:** `app.listen(port, '0.0.0.0', () => ...)`
|
|
93
|
+
* **FastAPI:** `uvicorn.run(app, host="0.0.0.0", port=8000)`
|
|
@@ -1,7 +1,22 @@
|
|
|
1
|
-
FROM node:
|
|
1
|
+
FROM node:20-alpine
|
|
2
|
+
|
|
3
|
+
# 1. Set production environment (optimizes Node and prevents dev dependencies)
|
|
4
|
+
ENV NODE_ENV=production
|
|
5
|
+
|
|
2
6
|
WORKDIR /app
|
|
7
|
+
|
|
8
|
+
# 2. Copy only dependency files first to cache the npm install layer
|
|
3
9
|
COPY package*.json ./
|
|
4
|
-
|
|
10
|
+
|
|
11
|
+
# 3. Use npm ci for strict, deterministic, and faster CI/CD installs
|
|
12
|
+
RUN npm ci
|
|
13
|
+
|
|
14
|
+
# 4. Copy the rest of the application source code
|
|
5
15
|
COPY . .
|
|
16
|
+
|
|
17
|
+
# 5. DevSecOps best practice: do not run the container as root
|
|
18
|
+
USER node
|
|
19
|
+
|
|
6
20
|
EXPOSE {{PORT}}
|
|
21
|
+
|
|
7
22
|
CMD ["npm", "start"]
|