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,
+ },
+ };
});