Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -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/
52 changes: 52 additions & 0 deletions docker/api.Dockerfile
Original file line number Diff line number Diff line change
@@ -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"]
71 changes: 71 additions & 0 deletions render.yaml
Original file line number Diff line number Diff line change
@@ -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
6 changes: 6 additions & 0 deletions src/AppName.Api/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -34,6 +35,9 @@

var app = builder.Build();

// --- Database ------------------------------------------------------------
await app.Services.MigrateDatabaseAsync();

app.MapOpenApi();

if (app.Environment.IsDevelopment())
Expand All @@ -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();
Expand Down
5 changes: 4 additions & 1 deletion src/AppName.Api/appsettings.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@
}
},
"AllowedHosts": "*",
"Database": {
"MigrateOnStartup": false
},
"Jwt": {
"Issuer": "",
"Audience": "",
Expand All @@ -14,4 +17,4 @@
"Cors": {
"Origins": []
}
}
}
5 changes: 2 additions & 3 deletions src/AppName.Infrastructure/AppName.Infrastructure.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,9 @@
<PackageReference Include="Microsoft.Extensions.Options" />
</ItemGroup>

<!-- Non-secret CA cert (e.g. YugabyteDB) shipped to the build output so `dotnet ef`
migrations can resolve a relative `Root Certificate=certs/...` at design time. -->
<ItemGroup>
<None Include="certs\**" Exclude="certs\*.md" CopyToOutputDirectory="PreserveNewest" />
<None Include="certs\**" Exclude="certs\*.md"
CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="Never" />
</ItemGroup>

</Project>
58 changes: 58 additions & 0 deletions src/AppName.Infrastructure/Persistence/Context/DatabaseMigrator.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;

namespace AppName.Infrastructure.Persistence.Context;

/// <summary>
/// Applies pending EF Core migrations from the running host.
/// </summary>
public static class DatabaseMigrator
{
/// <summary>
/// Applies any pending migrations when <c>Database:MigrateOnStartup</c> is enabled, and returns
/// without touching the database otherwise.
/// </summary>
public static async Task MigrateDatabaseAsync(
this IServiceProvider services, CancellationToken cancellationToken = default)
{
var configuration = services.GetRequiredService<IConfiguration>();

// Read and parsed by hand rather than through GetValue<bool>: 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<ILoggerFactory>()
.CreateLogger(typeof(DatabaseMigrator).FullName!);

var factory = scope.ServiceProvider.GetRequiredService<IDbContextFactory<AppDbContext>>();

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.");
}
}
13 changes: 13 additions & 0 deletions src/AppName.Infrastructure/SecretKeysConstants.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,17 @@ public static class ConnectionStrings
/// </summary>
public const string AppDb = "AppDb";
}

/// <summary>
/// Database host behaviour keys (section <c>Database</c>).
/// </summary>
public static class Database
{
/// <summary>
/// Whether the host applies pending EF Core migrations on start-up
/// (<c>Database:MigrateOnStartup</c>, or the
/// <c>Database__MigrateOnStartup</c> environment variable).
/// </summary>
public const string MigrateOnStartup = "Database:MigrateOnStartup";
}
}
1 change: 1 addition & 0 deletions src/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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 .",
Expand Down
46 changes: 31 additions & 15 deletions src/web/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
};
});
Loading