From 7fa51ad55aaa0fda496e41b5ca8c82e993d635b2 Mon Sep 17 00:00:00 2001 From: MatthewHsu1 Date: Sun, 30 Aug 2026 17:32:45 -0600 Subject: [PATCH] feat: Add Docker support with configuration for API and static site deployment --- .dockerignore | 20 ++++++ docker/api.Dockerfile | 52 ++++++++++++++ render.yaml | 71 +++++++++++++++++++ src/AppName.Api/Program.cs | 6 ++ src/AppName.Api/appsettings.json | 5 +- .../AppName.Infrastructure.csproj | 5 +- .../Persistence/Context/DatabaseMigrator.cs | 58 +++++++++++++++ .../SecretKeysConstants.cs | 13 ++++ src/web/package.json | 1 + src/web/vite.config.ts | 46 ++++++++---- 10 files changed, 258 insertions(+), 19 deletions(-) create mode 100644 .dockerignore create mode 100644 docker/api.Dockerfile create mode 100644 render.yaml create mode 100644 src/AppName.Infrastructure/Persistence/Context/DatabaseMigrator.cs diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..89b2bb4 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,20 @@ +# Build output and dependencies — restored/compiled inside the image instead. +**/bin/ +**/obj/ +**/node_modules/ +src/web/dist/ +src/AppName.Maui/Resources/Raw/web/ + +# Not part of the API image. +src/AppName.Maui/ +tests/ +docs/ +docker/ +.github/ +.git/ +.just-dev/ +.playwright-cli/ +.serena/ +.claude/ +.devcontainer/ +.superpowers/ diff --git a/docker/api.Dockerfile b/docker/api.Dockerfile new file mode 100644 index 0000000..8df8621 --- /dev/null +++ b/docker/api.Dockerfile @@ -0,0 +1,52 @@ +# syntax=docker/dockerfile:1 +# +# Container image for AppName.Api. Render has no native .NET runtime, so the API +# deploys as a Docker service; build it from the REPOSITORY ROOT, not from this +# directory, because the restore step needs global.json and the Directory.*.props +# files that live there: +# +# docker build -f docker/api.Dockerfile -t appname-api . + +# ---- Build ------------------------------------------------------------------- + +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build +WORKDIR /src + +# The project files are copied on their own first so that a source-only change +# reuses the cached (slow) restore layer instead of re-downloading every package. +COPY global.json Directory.Build.props Directory.Packages.props ./ +COPY src/AppName.Domain/AppName.Domain.csproj src/AppName.Domain/ +COPY src/AppName.Application/AppName.Application.csproj src/AppName.Application/ +COPY src/AppName.Infrastructure/AppName.Infrastructure.csproj src/AppName.Infrastructure/ +COPY src/AppName.Api/AppName.Api.csproj src/AppName.Api/ +RUN dotnet restore src/AppName.Api/AppName.Api.csproj + +# The MAUI head is deliberately absent: it needs the MAUI workload and platform +# SDKs, and the API does not reference it. +COPY src/AppName.Domain/ src/AppName.Domain/ +COPY src/AppName.Application/ src/AppName.Application/ +COPY src/AppName.Infrastructure/ src/AppName.Infrastructure/ +COPY src/AppName.Api/ src/AppName.Api/ +RUN dotnet publish src/AppName.Api/AppName.Api.csproj --configuration Release --no-restore --output /app + +# ---- Runtime ----------------------------------------------------------------- + +FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime +WORKDIR /app + +COPY --from=build /app ./ + +# The published output carries certs/yugabyte-ca.crt next to the binary, which is +# what a relative `Root Certificate=certs/yugabyte-ca.crt` in the connection +# string resolves against at runtime. + +ENV ASPNETCORE_ENVIRONMENT=Production + +# Render injects the port to bind in $PORT. ASPNETCORE_HTTP_PORTS is read at +# start-up and makes Kestrel listen on every interface in the container, which a +# hard-coded localhost URL would not. The variable has to expand at run time, so +# the command goes through a shell; `exec` keeps the app as PID 1 so it still +# receives the shutdown signal. +EXPOSE 10000 +USER $APP_UID +CMD ["sh", "-c", "ASPNETCORE_HTTP_PORTS=${PORT:-10000} exec dotnet AppName.Api.dll"] diff --git a/render.yaml b/render.yaml new file mode 100644 index 0000000..2160595 --- /dev/null +++ b/render.yaml @@ -0,0 +1,71 @@ +# Render Blueprint: the .NET API and the React static site. +# +# Apply it from the Render dashboard (Blueprints -> New Blueprint Instance). +# Values marked `sync: false` are NOT stored here — Render prompts for them on +# first apply and keeps them in the service's environment. Nothing secret +# belongs in this file. + +services: + # ---- API ------------------------------------------------------------------ + # + # Docker, because Render has no native .NET runtime. The Dockerfile builds from + # the repository root so it can see global.json and the Directory.*.props. + - type: web + name: appname-api + runtime: docker + plan: free + region: oregon + dockerfilePath: ./docker/api.Dockerfile + dockerContext: . + healthCheckPath: /health + envVars: + # The API applies its own EF Core migrations at start-up. The free plan + # runs a single instance, which is what makes that safe. + - key: Database__MigrateOnStartup + value: "true" + + # YugabyteDB Cloud connection string. The CA cert ships inside the image, + # so a RELATIVE path is what you want here, for example: + # Host=...;Port=5433;Database=...;Username=...;Password=...; + # SSL Mode=VerifyFull;Root Certificate=certs/yugabyte-ca.crt + # Allow Render's outbound IPs in the cluster's IP allow list, or the + # connection times out with no useful error. + - key: ConnectionStrings__AppDb + sync: false + + # Production JWT settings. The values in appsettings.Development.json are a + # local stub and must not be reused here. + - key: Jwt__Issuer + sync: false + - key: Jwt__Audience + sync: false + - key: Jwt__Key + sync: false + + # The browser origin of the static site below, scheme included and with no + # trailing slash, for example https://appname-web.onrender.com. Set it + # after the first apply, once Render has assigned the site its URL. + - key: Cors__Origins__0 + sync: false + + # ---- Web ------------------------------------------------------------------ + # + # A static site: Vite builds the bundle, Render serves the files. + - type: web + name: appname-web + runtime: static + rootDir: src/web + buildCommand: npm ci && npm run build:web + staticPublishPath: dist + envVars: + # Baked into the bundle AT BUILD TIME, not read in the browser. Changing it + # needs a rebuild, not just a restart. Use the API's URL, for example + # https://appname-api.onrender.com. + - key: VITE_API_BASE_URL + sync: false + routes: + # Client-side routing: every unknown path serves the app shell, otherwise a + # refresh on /bonds/1234 returns 404. + - type: rewrite + source: /* + destination: /index.html diff --git a/src/AppName.Api/Program.cs b/src/AppName.Api/Program.cs index 9087f68..4f2eaee 100644 --- a/src/AppName.Api/Program.cs +++ b/src/AppName.Api/Program.cs @@ -5,6 +5,7 @@ using AppName.Api.OpenApi; using AppName.Application; using AppName.Infrastructure; +using AppName.Infrastructure.Persistence.Context; using Scalar.AspNetCore; using SecretKeysConstants = AppName.Api.SecretKeysConstants; @@ -34,6 +35,9 @@ var app = builder.Build(); +// --- Database ------------------------------------------------------------ +await app.Services.MigrateDatabaseAsync(); + app.MapOpenApi(); if (app.Environment.IsDevelopment()) @@ -45,6 +49,8 @@ app.UseAuthentication(); app.UseAuthorization(); +app.MapGet("/health", () => Results.Ok(new { status = "healthy" })); + var api = app.MapGroup("/api"); api.MapAuthEndpoints(); api.MapBondEndpoints(); diff --git a/src/AppName.Api/appsettings.json b/src/AppName.Api/appsettings.json index e9403ff..31e56d2 100644 --- a/src/AppName.Api/appsettings.json +++ b/src/AppName.Api/appsettings.json @@ -6,6 +6,9 @@ } }, "AllowedHosts": "*", + "Database": { + "MigrateOnStartup": false + }, "Jwt": { "Issuer": "", "Audience": "", @@ -14,4 +17,4 @@ "Cors": { "Origins": [] } -} \ No newline at end of file +} diff --git a/src/AppName.Infrastructure/AppName.Infrastructure.csproj b/src/AppName.Infrastructure/AppName.Infrastructure.csproj index 0b27360..3d321b2 100644 --- a/src/AppName.Infrastructure/AppName.Infrastructure.csproj +++ b/src/AppName.Infrastructure/AppName.Infrastructure.csproj @@ -20,10 +20,9 @@ - - + diff --git a/src/AppName.Infrastructure/Persistence/Context/DatabaseMigrator.cs b/src/AppName.Infrastructure/Persistence/Context/DatabaseMigrator.cs new file mode 100644 index 0000000..64df213 --- /dev/null +++ b/src/AppName.Infrastructure/Persistence/Context/DatabaseMigrator.cs @@ -0,0 +1,58 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace AppName.Infrastructure.Persistence.Context; + +/// +/// Applies pending EF Core migrations from the running host. +/// +public static class DatabaseMigrator +{ + /// + /// Applies any pending migrations when Database:MigrateOnStartup is enabled, and returns + /// without touching the database otherwise. + /// + public static async Task MigrateDatabaseAsync( + this IServiceProvider services, CancellationToken cancellationToken = default) + { + var configuration = services.GetRequiredService(); + + // Read and parsed by hand rather than through GetValue: the binder lives in a package + // this project does not reference, and the value arrives as an environment-variable string. + if (!bool.TryParse(configuration[SecretKeysConstants.Database.MigrateOnStartup], out var migrateOnStartup) + || !migrateOnStartup) + { + return; + } + + await using var scope = services.CreateAsyncScope(); + + var logger = scope.ServiceProvider + .GetRequiredService() + .CreateLogger(typeof(DatabaseMigrator).FullName!); + + var factory = scope.ServiceProvider.GetRequiredService>(); + + await using var context = await factory.CreateDbContextAsync(cancellationToken); + + var pending = (await context.Database.GetPendingMigrationsAsync(cancellationToken)).ToList(); + + if (pending.Count == 0) + { + logger.LogInformation("Database schema is up to date; no migrations to apply."); + return; + } + + if (logger.IsEnabled(LogLevel.Information)) + { + logger.LogInformation( + "Applying {Count} pending migration(s): {Migrations}.", pending.Count, string.Join(", ", pending)); + } + + await context.Database.MigrateAsync(cancellationToken); + + logger.LogInformation("Migrations applied."); + } +} diff --git a/src/AppName.Infrastructure/SecretKeysConstants.cs b/src/AppName.Infrastructure/SecretKeysConstants.cs index 6ed897d..4ed52e2 100644 --- a/src/AppName.Infrastructure/SecretKeysConstants.cs +++ b/src/AppName.Infrastructure/SecretKeysConstants.cs @@ -18,4 +18,17 @@ public static class ConnectionStrings /// public const string AppDb = "AppDb"; } + + /// + /// Database host behaviour keys (section Database). + /// + public static class Database + { + /// + /// Whether the host applies pending EF Core migrations on start-up + /// (Database:MigrateOnStartup, or the + /// Database__MigrateOnStartup environment variable). + /// + public const string MigrateOnStartup = "Database:MigrateOnStartup"; + } } diff --git a/src/web/package.json b/src/web/package.json index fbd40de..20bd8ec 100644 --- a/src/web/package.json +++ b/src/web/package.json @@ -6,6 +6,7 @@ "scripts": { "dev": "vite", "build": "tsc -b && vite build", + "build:web": "tsc -b && vite build --mode web", "codegen": "openapi-typescript http://localhost:5000/openapi/v1.json -o src/api/schema.gen.ts", "lint": "oxlint", "format": "prettier --write .", diff --git a/src/web/vite.config.ts b/src/web/vite.config.ts index 194079a..f647107 100644 --- a/src/web/vite.config.ts +++ b/src/web/vite.config.ts @@ -3,19 +3,35 @@ import react from "@vitejs/plugin-react"; import tailwindcss from "@tailwindcss/vite"; import { tanstackRouter } from "@tanstack/router-plugin/vite"; -// HybridWebView serves from a virtual root; assets must use relative paths. -export default defineConfig({ - plugins: [ - // Must precede react(): the generator rewrites route files before the - // React plugin transforms them. Code splitting stays off — dynamic-import - // chunk URLs are a resolution risk under the virtual root. - tanstackRouter({ target: "react", autoCodeSplitting: false }), - react(), - tailwindcss(), - ], - base: "./", - build: { - outDir: "../AppName.Maui/Resources/Raw/web", - emptyOutDir: true, - }, +// Two build targets share this config, selected by mode: +// +// npm run build -> the MAUI HybridWebView bundle. +// HybridWebView serves from a virtual root, so assets must use relative +// paths, and the output lands in the MAUI project's Raw resources where the +// BuildReactApp MSBuild target expects it. +// +// npm run build:web (mode web) -> the static site deployed to a web host. +// Served from the domain root, with deep links rewritten to /index.html, so +// the base must be absolute: a relative base would make /bonds/1234 resolve +// its assets against /bonds/ and load nothing. +export default defineConfig(({ mode }) => { + const isStaticSite = mode === "web"; + + return { + plugins: [ + // Must precede react(): the generator rewrites route files before the + // React plugin transforms them. Code splitting stays off — dynamic-import + // chunk URLs are a resolution risk under the virtual root. + tanstackRouter({ target: "react", autoCodeSplitting: false }), + react(), + tailwindcss(), + ], + + base: isStaticSite ? "/" : "./", + + build: { + outDir: isStaticSite ? "dist" : "../AppName.Maui/Resources/Raw/web", + emptyOutDir: true, + }, + }; });