import { Rule, SchematicContext, SchematicsException, Tree } from '@angular-devkit/schematics';
import { Schema } from './schema';
type PackageManager = 'npm' | 'pnpm' | 'yarn';
interface PackageManagerCommands {
install: string;
build: string;
prepare?: string;
}
const DOCKERFILE_PATH = '/Dockerfile';
const DOCKERIGNORE_PATH = '/.dockerignore';
const NGINX_CONFIG_PATH = '/nginx.conf';
export function docker(options: Schema): Rule {
return (tree: Tree, context: SchematicContext) => {
const files = [DOCKERFILE_PATH, DOCKERIGNORE_PATH, NGINX_CONFIG_PATH];
const existingFiles = files.filter((file) => tree.exists(file));
if (existingFiles.length > 0 && !options.force) {
throw new SchematicsException(`${existingFiles.join(', ')} already exist. Re-run with --force to overwrite them.`);
}
const workspace = readJson(tree, '/angular.json');
const projectName = resolveProjectName(workspace, options.project);
const outputPath = resolveBuildOutputPath(workspace, projectName);
const packageManager = detectPackageManager(tree);
const commands = getPackageManagerCommands(packageManager, tree.exists('/package-lock.json'));
writeFile(tree, DOCKERFILE_PATH, buildDockerfile(outputPath, commands));
writeFile(tree, DOCKERIGNORE_PATH, buildDockerignore());
writeFile(tree, NGINX_CONFIG_PATH, buildNginxConfig());
context.logger.warn(
"NOTE: nginx.conf's Content-Security-Policy allows style-src 'unsafe-inline' — required because " +
"Angular's production build inlines critical CSS and per-component encapsulated styles by default. " +
'To remove it, add ngCspNonce="__CSP_NONCE__" to your root element in index.html (e.g. ' +
'), then swap \'unsafe-inline\' for a matching ' +
"'nonce-$request_id' in nginx.conf and add a sub_filter directive to substitute it at request time " +
'— see SECURITY_ROADMAP.md.',
);
};
}
function resolveProjectName(workspace: Record, projectName?: string): string {
if (projectName) {
if (!workspace.projects?.[projectName]) {
throw new SchematicsException(`Project "${projectName}" was not found in angular.json.`);
}
return projectName;
}
if (workspace.defaultProject && workspace.projects?.[workspace.defaultProject]) {
return workspace.defaultProject as string;
}
const projects = Object.keys(workspace.projects ?? {});
if (projects.length === 0) {
throw new SchematicsException('No Angular project was found in angular.json.');
}
return projects[0];
}
function resolveBuildOutputPath(workspace: Record, projectName: string): string {
const project = workspace.projects?.[projectName] ?? {};
const buildTarget = project.architect?.build ?? project.targets?.build;
const outputPath = buildTarget?.options?.outputPath;
if (typeof outputPath === 'string') {
return trimSlashes(outputPath);
}
if (outputPath?.base) {
return trimSlashes([outputPath.base, outputPath.browser].filter(Boolean).join('/'));
}
return `dist/${projectName}/browser`;
}
function detectPackageManager(tree: Tree): PackageManager {
if (tree.exists('/pnpm-lock.yaml')) {
return 'pnpm';
}
if (tree.exists('/yarn.lock')) {
return 'yarn';
}
return 'npm';
}
function getPackageManagerCommands(packageManager: PackageManager, hasNpmLockfile: boolean): PackageManagerCommands {
if (packageManager === 'pnpm') {
return {
prepare: 'RUN corepack enable',
install: 'pnpm install --frozen-lockfile',
build: 'pnpm run build',
};
}
if (packageManager === 'yarn') {
return {
prepare: 'RUN corepack enable',
install: 'yarn install --frozen-lockfile',
build: 'yarn build',
};
}
return {
install: hasNpmLockfile ? 'npm ci' : 'npm install',
build: 'npm run build',
};
}
function buildDockerfile(outputPath: string, commands: PackageManagerCommands): string {
const prepareStep = commands.prepare ? `${commands.prepare}\n` : '';
return `FROM node:24-alpine AS build
WORKDIR /app
COPY package*.json ./
COPY pnpm-lock.yaml* yarn.lock* ./
${prepareStep}RUN ${commands.install}
COPY . .
RUN ${commands.build}
FROM nginx:1.27-alpine
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/${outputPath} /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
`;
}
function buildDockerignore(): string {
return `node_modules
dist
.angular
.git
.github
coverage
playwright-report
test-results
.DS_Store
npm-debug.log*
yarn-debug.log*
yarn-error.log*
`;
}
function buildNginxConfig(): string {
return `server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Baseline security headers (see angular.dev/best-practices/security). Sent on every
# response, including the SPA shell and static assets.
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "DENY" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
# Ignored by browsers unless the response is served over HTTPS (e.g. behind a TLS-terminating
# load balancer) — harmless to send over plain HTTP.
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;
# script-src is intentionally 'self' only, no 'unsafe-inline'/'unsafe-eval': Angular's build
# output never needs inline scripts. style-src allows 'unsafe-inline' because Angular's
# production build inlines critical CSS into a