Etsub
Remote / Worldwide  
All writing
10 min readStructure That Survives · Part 4

Everything That Isn't Code: Assets, Migrations, Config and Uploads

Where templates, SQL, static files, secrets and user uploads belong in a NestJS repository — with the nest-cli asset globs, migration rules, cache headers and presigned-URL limits quoted from the docs.

By , Fullstack Software Engineer

Architecture posts stop at the code. Then you ship and discover the interesting failures live everywhere else: the Handlebars email template that was not in the container, the migration two people numbered the same, the config file with a staging password in git, the avatar upload sitting on a disk that autoscaling is about to delete.

TypeScript makes the first one almost inevitable. tsc emits .js and .d.ts and ignores everything else, so every non-code file in src/ is invisible to your build until you say otherwise. Non-code files need the same boundary discipline as code — here is a classification that works, and the exact rules the tools enforce.

Four kinds of "asset", four different homes

Lumping these together is the root mistake. They differ on one axis — who writes them, and when:

Kind Written by Changes with Belongs
Build-time assets — email templates, GraphQL SDL, seed fixtures, static UI developers the code in the repo, next to the code that uses them, copied into dist/
Schema migrations developers the code, but forward-only in production migrations/, versioned, immutable once merged
Configuration operators the deploy the environment, not the repo
User content — uploads, avatars, generated PDFs users runtime object storage, never the container filesystem, never the repo

Everything below is the mechanics of each row.

Build-time assets: nest build does not copy them by default

This is the rule that costs people an afternoon, stated in the docs:

"The assets should be located in the src folder otherwise they will not be copied." — NestJS, CLI: Workspaces

compilerOptions.assets in nest-cli.json takes glob strings or objects with include, exclude, outDir and watchAssets:

{
  "compilerOptions": {
    "assets": [
      "**/*.graphql",
      { "include": "**/mail/templates/**/*.hbs", "watchAssets": true },
      { "include": "**/*.sql", "exclude": "**/*.test.sql", "outDir": "dist/sql" }
    ],
    "watchAssets": true
  }
}

outDir defaults to the compiler output directory, and one precedence detail is worth knowing before you debug it: "Setting watchAssets in a top-level compilerOptions property overrides any watchAssets settings within the assets property."

Because assets must live under src/, they land beside the code that owns them — which is what you want anyway:

src/billing/
  infrastructure/mail/
    invoice-paid.hbs
    payment-failed.hbs
    mail.service.ts        # loads templates relative to __dirname

Resolve paths from __dirname, never process.cwd(). cwd is whatever directory the process was started in — your terminal locally, / in some container runtimes, something else again under a process manager. join(__dirname, 'templates') follows the file into dist/ and is the only form that survives both.

For a built frontend served by the same process, @nestjs/serve-static is the supported route:

ServeStaticModule.forRoot({
  rootPath: join(__dirname, '..', 'client'),
  serveRoot: '/app',
  exclude: ['/api/{*path}'],
})

exclude is not optional in practice: the default renderPath is * — "The default renderPath of the Static App is * (all paths), and the module will send index.html files in response" — so without it the static handler swallows your API routes (NestJS, Serve Static).

If you render server-side templates instead, the platform methods are explicit about locations — app.useStaticAssets(join(__dirname, '..', 'public')), app.setBaseViewsDir(join(__dirname, '..', 'views')), app.setViewEngine('hbs'), where "the public directory will be used for storing static assets, views will contain templates" (NestJS, MVC). Note those two directories sit outside src/ in the docs' example — which means the assets rule above does not apply to them and your Dockerfile has to copy them explicitly. That mismatch is the second-most-common missing-file bug.

The Dockerfile is part of your asset story

# build stage
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
COPY . .
RUN pnpm build                       # tsc + nest-cli asset copy -> dist/

# runtime stage
COPY --from=build /app/dist ./dist
COPY --from=build /app/views ./views     # only needed if views/ lives outside src/
COPY --from=build /app/public ./public
RUN pnpm install --frozen-lockfile --prod

One check catches nearly all of these: run the image with nothing mounted and hit the code path that reads the file. If it only works on your laptop, something is being loaded from a path that exists only there.

Cache headers are part of asset structure

How you name files determines how aggressively they can be cached. MDN's guidance for content-hashed assets:

"The immutable response directive indicates that the response will not be updated while it's fresh." … "When you use a cache-busting pattern for resources and apply them to a long max-age, you can also add immutable to avoid revalidation." — MDN, Cache-Control

So, two rules:

/assets/app.9f2c1b.js   →  Cache-Control: public, max-age=31536000, immutable
/index.html             →  Cache-Control: no-cache

And the correction everyone needs at least once: "Note that no-cache does not mean 'don't cache'. no-cache allows caches to store a response but requires them to revalidate it before reuse." If you actually mean never store this — an authenticated JSON response, say — that is no-store.

Migrations: a directory with rules

Migrations are the one place where filename format is load-bearing, and both major Node ORMs converge on the same shape.

TypeORM generates {TIMESTAMP}-post-refactoring.ts, "where {TIMESTAMP} is the current timestamp when the migration was generated", with a two-method contract: "up has to contain the code you need to perform the migration" and "down has to revert whatever up changed" (TypeORM, Creating migrations). The same docs state the reason migrations exist at all: "Typically, it is unsafe to use synchronize: true for schema synchronization on production once you get data in your database."

Prisma keeps one timestamped directory per migration:

prisma/migrations/
  20210313140442_init/migration.sql
  20210313140442_added_job_title/migration.sql

with two non-negotiables: "You must commit the entire prisma/migrations folder to source control," and do not edit or delete migrations that have been applied — if you do, "the end state of the migration history no longer matches the Prisma schema" and Prisma "will always warn you that migration histories do not match … each time you run the command" (Prisma, Migration histories). Prisma is equally direct about the deploy split: "migrate dev is a development command and should never be used in a production environment"; production uses migrate deploy, ideally from CI (Prisma, Development and production).

Policy that survives a team, regardless of tool:

  • Timestamps, not sequence numbers. Two developers on parallel branches both pick 0008_ and collide at merge. 20260718142530_ does not. Both tools default to timestamps — do not "tidy" them into ordinals.
  • Immutable once merged. Editing an applied migration means environments disagree about what version 8 is. Fix forward with a new file.
  • Location follows deployment, not code. One migrations/ (or prisma/migrations/) directory at the repo root when one service owns the database. Per-context directories only when contexts own separate schemas and you run them separately — do not scatter migrations per feature folder if a single process applies them all.
  • Run them as a deploy step, not on boot. Two replicas starting simultaneously and both running migrations is a race with a live database. Make it a job that runs once.
  • Seed data is not a migration. Reference data (country codes, plan tiers) is arguably schema; demo fixtures are not. Keep fixtures in test/fixtures/ or prisma/seed.ts, excluded from the production image.

Config: out of the repo, by definition

The Twelve-Factor App's rule and its test:

"The twelve-factor app requires strict separation of config from code. Config varies substantially across deploys, code does not."

The litmus test is whether "the codebase could be made open source at any moment, without compromising any credentials" (12factor.net, Config).

That gives a clean split:

.env.example              # every key documented, no real values      ✅ commit
.env                      # developer-local real values               ❌ gitignore
src/shared/config/
  env.validation.ts       # schema; the app refuses to boot without it ✅ commit
  config.module.ts
// src/shared/config/config.module.ts
ConfigModule.forRoot({
  isGlobal: true,
  validate: validateEnv,          // throws at bootstrap on a missing/invalid key
  validationOptions: { abortEarly: false },
});

Two things to insist on:

  1. Validate at boot, fail fast. Parse the environment into a typed object once at startup and exit non-zero on anything missing or malformed. A service that starts with a blank STRIPE_KEY and fails at 03:00 is strictly worse than one that refuses to start at deploy time.
  2. Config is a driven port too. Domain and application code should receive typed values through the constructor, not call process.env or this.config.get() from deep inside a use case. Otherwise every unit test needs environment setup — the exact isolation Cockburn's hexagon exists to protect (Hexagonal Architecture).

User uploads: object storage and a boundary

Never the container filesystem — it is ephemeral, unshared across replicas, and unbacked up. The pattern that avoids streaming bytes through your Nest process is a presigned URL, and its security model is worth quoting exactly:

"The capabilities of a presigned URL are limited by the permissions of the user who created it. In essence, presigned URLs are bearer tokens that grant access to those who possess them. As such, we recommend that you protect them appropriately."

Expiry has hard ceilings you cannot design around:

"If you create a presigned URL with the Amazon S3 console, the expiration time can be set between 1 minute and 12 hours. If you use the AWS CLI or AWS SDKs, the expiration time can be set as high as 7 days."

"If you created a presigned URL by using a temporary token, then the URL expires when the token expires… This is true even if the URL was created with a later expiration time." — AWS, Download and upload objects with presigned URLs

That last one is the production bug: your service runs under an IAM role, role sessions default to an hour, and your "7-day" download link dies in 45 minutes. For genuinely long-lived links, sign with long-lived credentials or serve through a CDN with its own signing.

Structurally, storage is a driven port like any other:

// src/media/domain/media-store.port.ts
export interface MediaStore {
  uploadUrl(key: string, ttlSeconds: number): Promise<string>;
  downloadUrl(key: string, ttlSeconds: number): Promise<string>;
}

export const MEDIA_STORE = Symbol('MediaStore');
src/media/
  domain/
    media-store.port.ts
    attachment.ts           # allowed MIME types, size limits, key naming rules
  infrastructure/
    s3/s3-media.store.ts    # bucket names, SDK types, presigning
    local/local-media.store.ts   # filesystem, for tests and local dev

Key naming belongs in the domain, not the adapter: orgs/{orgId}/invoices/{invoiceId}/{ulid}.pdf encodes the authorisation boundary in the path, so a bucket policy or IAM condition can enforce tenancy without consulting your database. Never let a user-supplied filename become the key — that is how you get path traversal and overwritten objects in the same bug.

The five-minute repo audit

  • git ls-files | grep -E '\.(env|pem|key|p12)$' — should return nothing.
  • Does the container start with nothing mounted, and can it render an email template? If not, an asset is missing from dist/.
  • Does anything resolve a file path from process.cwd()?
  • Are two migrations sharing a version, or has an applied migration been edited?
  • Does any handler write to a local path outside /tmp?
  • Do hashed asset URLs get immutable, and does HTML get no-cache?
  • Can you delete a context's folder and have its templates, SQL and fixtures go with it?

The last one is the real test — the same deletion test from Part 3. A boundary that code respects but files ignore is not a boundary.

Sources