← All articles

Render: deploy Angular and .NET with a Blueprint

An Angular/.NET deployment walkthrough covering build/start scripts, ports, configuration precedence, health checks and persistent state.

t.Turgay
Render: deploy Angular and .NET with a Blueprint

Two applications do not automatically require two servers. PortfolioBlog packages its Angular/Node frontend and .NET API in one Docker image and runs them in one Render container. Content stays in Supabase PostgreSQL; uploaded images stay in Supabase Storage. This guide follows the repository’s Dockerfile, Blueprint and actual container checks. Passing local production checks does not claim that a remote Render deployment has finished.

One address, two processes

Node accepts public requests. Angular assets, initial HTML, canonical metadata and the sitemap belong to the frontend. Requests for /api, /uploads and /health are forwarded to the .NET API over loopback. The public port comes from PORT, defaulting to 8080. The API binds only to 127.0.0.1:8081; connections to that port through the container’s network interface are rejected too.

A shared origin keeps browser cookies and CSRF handling straightforward. Authorization still belongs to the API: an Angular guard helps navigation but cannot replace the server’s admin policy. Sharing a container does not give the frontend a SQL connection or a Supabase server key.

Separate build stages

Building Angular requires npm dependencies and a compiler. Publishing the API requires the .NET SDK. The running image needs neither complete toolchain. Separate stages build the frontend, install the small PostgreSQL tooling dependency and publish the API; the final stage receives only the files used at runtime.

# syntax=docker/dockerfile:1

FROM node:24-bookworm-slim AS node

FROM node AS frontend-build
WORKDIR /src/frontend
COPY frontend/package.json frontend/package-lock.json ./
RUN --mount=type=cache,target=/root/.npm npm ci --no-audit --no-fund
COPY frontend/ ./
RUN npm run build -- --configuration production

FROM node AS tooling
WORKDIR /src
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm npm ci --omit=dev --ignore-scripts --no-audit --no-fund

FROM mcr.microsoft.com/dotnet/sdk:10.0-noble AS api-build
WORKDIR /src
COPY Directory.Packages.props ./
COPY backend/PortfolioBlog.Core/PortfolioBlog.Core.csproj backend/PortfolioBlog.Core/
COPY backend/PortfolioBlog.Data/PortfolioBlog.Data.csproj backend/PortfolioBlog.Data/
COPY backend/PortfolioBlog.Business/PortfolioBlog.Business.csproj backend/PortfolioBlog.Business/
COPY backend/PortfolioBlog.WebApi/PortfolioBlog.WebApi.csproj backend/PortfolioBlog.WebApi/
RUN dotnet restore backend/PortfolioBlog.WebApi/PortfolioBlog.WebApi.csproj
COPY backend/ backend/
COPY config/supabase-ca.crt config/
RUN dotnet publish backend/PortfolioBlog.WebApi/PortfolioBlog.WebApi.csproj \
    --configuration Release --no-restore --no-self-contained \
    --output /out -p:UseAppHost=false

FROM mcr.microsoft.com/dotnet/aspnet:10.0-noble AS runtime
# The official Node binary targets an older glibc and runs on Ubuntu Noble.
COPY --from=node /usr/local/bin/node /usr/local/bin/node
RUN apt-get update \
    && apt-get install -y --no-install-recommends libatomic1 libstdc++6 libgssapi-krb5-2 \
    && rm -rf /var/lib/apt/lists/* \
    && node --version
WORKDIR /app
ENV NODE_ENV=production \
    ASPNETCORE_ENVIRONMENT=Production \
    ASPNETCORE_HTTP_PORTS= \
    DOTNET_EnableDiagnostics=0 \
    PORT=8080 \
    START_API=1 \
    API_ORIGIN=http://127.0.0.1:8081 \
    API_DIRECTORY=/app/backend/PortfolioBlog.WebApi \
    API_EXECUTABLE=dotnet \
    API_ASSEMBLY=/app/backend/PortfolioBlog.WebApi/PortfolioBlog.WebApi.dll
COPY --from=api-build /out/ backend/PortfolioBlog.WebApi/
COPY --from=frontend-build /src/frontend/dist/portfolio-web/browser/ frontend/dist/portfolio-web/browser/
COPY --from=tooling /src/node_modules/ node_modules/
COPY package.json ./
COPY frontend/server/ frontend/server/
COPY frontend/shared/ frontend/shared/
COPY scripts/container-start.mjs scripts/
COPY scripts/lib/env.mjs scripts/lib/local-settings.mjs scripts/lib/database.mjs scripts/lib/setup-database.mjs scripts/lib/
COPY backend/PortfolioBlog.Data/db/ backend/PortfolioBlog.Data/db/
COPY content/site.json content/
COPY config/supabase-ca.crt config/
USER $APP_UID
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=5s --start-period=90s --retries=3 \
    CMD node -e "fetch('http://127.0.0.1:'+process.env.PORT+'/health',{signal:AbortSignal.timeout(4000)}).then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
ENTRYPOINT ["node", "scripts/container-start.mjs"]

Package manifests are copied before source files deliberately. Editing a C# class should not download NuGet dependencies again; editing an Angular component should not reinstall every npm package. npm ci follows the lockfile. The publish target is WebApi, so the standalone WPF and Prisma article examples do not become extra applications in this container.

The API is framework-dependent and runs through the ASP.NET 10 runtime in the final image. The SDK stays behind. The official Node 24 Linux binary is copied into that runtime image, with required libraries including libatomic1 and libgssapi-krb5-2 installed explicitly. An actual container run exposed the missing GSSAPI library warning during the Npgsql connection. “It runs on my machine” would have missed that detail.

USER $APP_UID runs the application without root. EXPOSE 8080 documents the intended public port; the API is private because of its loopback binding. EXPOSE is not a firewall. The health command reads PORT as well, so changing the public port does not silently break the probe.

Build input and runtime configuration

The .dockerignore allowlist keeps environment files, Git history, backups, node_modules and old build outputs out of the context. Copying a secret and deleting it in a later layer would still leave it in the earlier layer. No COPY instruction or build argument receives these credentials.

The required runtime settings are shown with placeholders:

ConnectionStrings__Portfolio=YOUR_SUPABASE_SESSION_POOLER_CONNECTION
Supabase__Url=https://YOUR_PROJECT_REF.supabase.co
Supabase__SecretKey=YOUR_SERVER_SECRET
Supabase__Bucket=portfolio-media
Admin__Username=YOUR_ADMIN_USERNAME
Admin__PasswordHash=YOUR_GENERATED_PBKDF2_HASH
Security__DataProtectionKeys=YOUR_GENERATED_KEY_RING
Database__Schema=public
# PUBLIC_ORIGIN=https://portfolio.example.com

The database password, Storage server key, PBKDF2 password hash and cookie key ring serve different purposes. Security__DataProtectionKeys must remain consistent across restarts. Generating a new ring on every startup would invalidate existing admin cookies and CSRF tokens.

Use Environment → Add from .env on Render to import production settings. The private repository’s .env.render file is excluded from the image and does not populate Render’s environment automatically. A local Docker run can bind-mount it read-only and select it through PORTFOLIO_ENV_FILE; the root README contains that command. Node’s dotenv parser supports quoted values. Process environment values take precedence over the mounted file.

Initialize before accepting traffic

import path from "node:path";
import { fileURLToPath } from "node:url";
import { loadEnvironment } from "./lib/env.mjs";
import { validateProductionEnvironment } from "../frontend/server/configuration.mjs";
import { setupDatabase } from "./lib/setup-database.mjs";

const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
loadEnvironment(root);
validateProductionEnvironment();
// Schema setup is explicit, transactional and preserves existing content.
console.log(JSON.stringify(await setupDatabase()));
await import("../frontend/server/start.mjs");

The entry point validates required production settings, then runs the explicit database setup tool. Six content tables are initialized inside a transaction protected by an advisory lock. Seed content is inserted only when the profile table is empty. Restarting does not overwrite articles, publication choices or profile edits. The API itself does not create tables or insert seed data.

Node then starts the dotnet child process and waits up to 60 seconds for its /health endpoint. The public listener opens only after that check succeeds. A failed API startup or an unexpected API exit terminates the supervisor with a failure code. Keeping a storefront lit while its data service has stopped would make a rather misleading health signal.

On SIGTERM the server stops accepting new connections and asks the API to shut down. After 15 seconds, remaining connections and a stuck child are forcibly closed. Docker or Render owns restarting the container; the application does not add another endless restart loop.

The Render Blueprint

# yaml-language-server: $schema=https://render.com/schema/render.yaml.json
services:
  - type: web
    name: portfolio-blog
    runtime: docker
    region: frankfurt
    plan: starter
    repo: https://github.com/turgaybudak/Porfolio
    branch: main
    autoDeployTrigger: commit
    dockerfilePath: ./Dockerfile
    dockerContext: .
    healthCheckPath: /health
    envVars:
      - key: ASPNETCORE_ENVIRONMENT
        value: Production
      - key: PORT
        value: "8080"
      - key: ConnectionStrings__Portfolio
        sync: false
      - key: Supabase__Url
        sync: false
      - key: Supabase__SecretKey
        sync: false
      - key: Admin__Username
        sync: false
      - key: Admin__PasswordHash
        sync: false
      - key: Security__DataProtectionKeys
        sync: false

runtime: docker selects a Dockerfile build. dockerContext points to the repository root so frontend, backend and shared inputs are available. Native buildCommand and startCommand settings are removed; the image’s ENTRYPOINT is used. Values for sync: false entries are supplied through Render Environment rather than stored in YAML.

Select the repository and main branch through New → Blueprint. If an existing service uses a native runtime, create a Docker service and move the domain to it. Do not copy old build/start overrides. PUBLIC_ORIGIN selects a custom canonical domain; otherwise the frontend uses Render’s RENDER_EXTERNAL_URL. It does not trust an incoming Host header to choose canonical URLs.

What the checks prove

docker build -t portfolio-blog:local .
node scripts/verify-container.mjs portfolio-blog:local

# Read-only verification of an existing deployment (PowerShell):
$env:PUBLIC_ORIGIN = 'https://YOUR_SERVICE.onrender.com'
node scripts/check-deployment.mjs

verify-container runs the actual image with a temporary Supabase schema and temporary admin credentials. It does not change the main schema or upload Storage objects. It checks 42 articles, four projects, article HTML in both languages, the sitemap and six-item pagination. An anonymous admin read must return 401, a write without CSRF must return 400, and saving a stale revision must return 409.

The test changes a profile field, restarts the container and reads the edit back. It then saves with the original cookie and CSRF token. Killing the API must stop the whole container with an error code; ordinary docker stop must finish without needing Docker’s SIGKILL. The checks also verify a non-root user, no SDK or .env files in the image, and an API port accessible only over loopback.

/health proves that the API responds; it does not query PostgreSQL on every request. It is not a continuous database readiness check after a later connection failure. The read-only deployment script therefore reads /api/site and articles too. Production admin cookies are Secure and HttpOnly, so browser administration should use HTTPS.

Diagnosing a failed deployment

For a build failure, read the first npm, Angular, restore or publish error. For a startup failure, check configuration names, the CA certificate and the Supabase Session pooler connection. This persistent backend rejects transaction pooler port 6543. A missing ConnectionStrings__Portfolio error identifies missing configuration; it does not establish that PostgreSQL is unavailable.

Rolling back a commit does not roll back the database. Content lives in PostgreSQL, images in Storage and session keys in runtime configuration. A recovery plan must consider all three. Rebuilding an image should be routine; attempting to recreate user data is not a backup strategy.

References

✳ You have reached the end of this orbit.
Explore more articles →
turgay.A little code, a few pixels, a lot of curiosity.© 2026 · Made on Earth ✳