From b959a175338655e51d9b84bef5a6483dd1ae1c19 Mon Sep 17 00:00:00 2001 From: Darren Reid Date: Thu, 23 Nov 2023 13:05:13 +1100 Subject: [PATCH 1/2] Update .csproj files to net8.0 and adjust versions --- .deploy/docker-compose.yml | 33 +++++++++++ .deploy/nginx-proxy-compose.yml | 0 .github/workflows/README.md | 99 +++++++++++++++++++++++++++++++++ .github/workflows/build.yml | 38 +++++++++---- .github/workflows/release.yml | 76 +++++++++++++++---------- Dockerfile | 16 ------ MyApp/App_Data/README.md | 11 ++++ MyApp/MyApp.csproj | 2 + docker-compose.override.yml | 27 --------- docker-compose.prod.yml | 34 ----------- docker-compose.yml | 15 ----- 11 files changed, 221 insertions(+), 130 deletions(-) create mode 100755 .deploy/docker-compose.yml mode change 100644 => 100755 .deploy/nginx-proxy-compose.yml create mode 100755 .github/workflows/README.md mode change 100644 => 100755 .github/workflows/build.yml mode change 100644 => 100755 .github/workflows/release.yml delete mode 100644 Dockerfile create mode 100755 MyApp/App_Data/README.md delete mode 100644 docker-compose.override.yml delete mode 100644 docker-compose.prod.yml delete mode 100644 docker-compose.yml diff --git a/.deploy/docker-compose.yml b/.deploy/docker-compose.yml new file mode 100755 index 0000000..7ba3624 --- /dev/null +++ b/.deploy/docker-compose.yml @@ -0,0 +1,33 @@ +version: "3.9" +services: + app: + image: ghcr.io/${IMAGE_REPO}:${RELEASE_VERSION} + restart: always + ports: + - "8080" + container_name: ${APP_NAME}_app + environment: + VIRTUAL_HOST: ${HOST_DOMAIN} + VIRTUAL_PORT: 8080 # New default ASP.NET port -> https://learn.microsoft.com/en-us/dotnet/core/compatibility/containers/8.0/aspnet-port + LETSENCRYPT_HOST: ${HOST_DOMAIN} + LETSENCRYPT_EMAIL: ${LETSENCRYPT_EMAIL} + volumes: + - app-mydb:/app/App_Data + + app-migration: + image: ghcr.io/${IMAGE_REPO}:${RELEASE_VERSION} + restart: "no" + container_name: ${APP_NAME}_app_migration + profiles: + - migration + command: --AppTasks=migrate + volumes: + - app-mydb:/app/App_Data + +networks: + default: + external: true + name: nginx + +volumes: + app-mydb: diff --git a/.deploy/nginx-proxy-compose.yml b/.deploy/nginx-proxy-compose.yml old mode 100644 new mode 100755 diff --git a/.github/workflows/README.md b/.github/workflows/README.md new file mode 100755 index 0000000..d633ad0 --- /dev/null +++ b/.github/workflows/README.md @@ -0,0 +1,99 @@ +## Overview + +This template uses the deployment configurations for a ServiceStack .NET 8 application. The application is containerized using Docker and is set up to be automatically built and deployed via GitHub Actions. The recommended deployment target is a stand-alone Linux server running Ubuntu, with an NGINX reverse proxy also containerized using Docker, which a Docker Compose file is included in the template under the `.deploy` directory. + +### Highlights +- 🌐 **NGINX Reverse Proxy**: Utilizes an NGINX reverse proxy to handle web traffic and SSL termination. +- 🚀 **GitHub Actions**: Leverages GitHub Actions for CI/CD, pushing Docker images to GitHub Container Registry and deploying them on a remote server. +- 🐳 **Dockerized ServiceStack App**: The application is containerized, with the image built using `.NET 8`. +- 🔄 **Automated Migrations**: Includes a separate service for running database migrations. + +### Technology Stack +- **Web Framework**: ServiceStack +- **Language**: C# (.NET 8) +- **Containerization**: Docker +- **Reverse Proxy**: NGINX +- **CI/CD**: GitHub Actions +- **OS**: Ubuntu 22.04 (Deployment Server) + + + +## Deployment Server Setup + +To successfully host your ServiceStack applications, there are several components you need to set up on your deployment server. This guide assumes you're working on a standalone Linux server (Ubuntu is recommended) with SSH access enabled. + +### Prerequisites + +1. **SSH Access**: Required for GitHub Actions to communicate with your server. +2. **Docker**: To containerize your application. +3. **Docker-Compose**: For orchestrating multiple containers. +4. **Ports**: 80 and 443 should be open for web access. +5. **nginx-reverse-proxy**: For routing traffic to multiple ServiceStack applications and managing TLS certificates. + +You can use any cloud-hosted or on-premises server like Digital Ocean, AWS, Azure, etc., for this setup. + +### Step-by-Step Guide + +#### 1. Install Docker and Docker-Compose + +It is best to follow the [latest installation instructions on the Docker website](https://docs.docker.com/engine/install/ubuntu/) to ensure to have the correct setup with the latest patches. + +#### 2. Configure SSH for GitHub Actions + +Generate a dedicated SSH key pair to be used by GitHub Actions: + +```bash +ssh-keygen -t rsa -b 4096 -f ~/.ssh/github_actions +``` + +Add the public key to the `authorized_keys` file on your server: + +```bash +cat ~/.ssh/github_actions.pub >> ~/.ssh/authorized_keys +``` + +Then, add the *private* key to your GitHub Secrets as `DEPLOY_KEY` to enable GitHub Actions to SSH into the server securely. + +#### 3. Set Up nginx-reverse-proxy + +You should have a `docker-compose` file similar to the `nginx-proxy-compose.yml` in your repository. Upload this file to your server: + +```bash +scp nginx-proxy-compose.yml user@your_server:~/ +``` + +To bring up the nginx reverse proxy and its companion container for handling TLS certificates, run: + +```bash +docker compose -f ~/nginx-proxy-compose.yml up -d +``` + +This will start an nginx reverse proxy along with a companion container. They will automatically watch for additional Docker containers on the same network and initialize them with valid TLS certificates. + + + +## GitHub Repository Setup + +Configuring your GitHub repository is an essential step for automating deployments via GitHub Actions. This guide assumes you have a `release.yml` workflow file in your repository's `.github/workflows/` directory, and your deployment server has been set up according to the [Deployment Server Setup](#Deployment-Server-Setup) guidelines. + +### Secrets Configuration + +Your GitHub Actions workflow requires the following secrets to be set in your GitHub repository: + +1. **`DEPLOY_HOST`**: The hostname for SSH access. This can be either an IP address or a domain with an A-record pointing to your server. +2. **`DEPLOY_USERNAME`**: The username for SSH login. Common examples include `ubuntu`, `ec2-user`, or `root`. +3. **`DEPLOY_KEY`**: The SSH private key to securely access the deployment server. This should be the same key you've set up on your server for GitHub Actions. +4. **`LETSENCRYPT_EMAIL`**: Your email address, required for Let's Encrypt automated TLS certificates. + +#### Using GitHub CLI for Secret Management + +You can conveniently set these secrets using the [GitHub CLI](https://cli.github.com/manual/gh_secret_set) like this: + +```bash +gh secret set DEPLOY_HOST --body="your-host-or-ip" +gh secret set DEPLOY_USERNAME --body="your-username" +gh secret set DEPLOY_KEY --bodyFile="path/to/your/ssh-private-key" +gh secret set LETSENCRYPT_EMAIL --body="your-email@example.com" +``` + +These secrets will populate environment variables within your GitHub Actions workflow and other configuration files, enabling secure and automated deployment of your ServiceStack applications. diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml old mode 100644 new mode 100755 index 0b97854..df2d8ae --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,17 +1,35 @@ name: Build -on: [push] +on: + pull_request: {} + push: + branches: + - '**' # matches every branch jobs: build: runs-on: ubuntu-22.04 steps: - - uses: actions/checkout@v3 - - name: Setup dotnet - uses: actions/setup-dotnet@v3 - with: - dotnet-version: '8.0' - - name: Build - run: dotnet build - - name: Test - run: dotnet test ./MyApp.Tests + - name: checkout + uses: actions/checkout@v3 + + - name: Setup dotnet + uses: actions/setup-dotnet@v3 + with: + dotnet-version: '8.0' + + - name: build + run: dotnet build + working-directory: . + + - name: test + run: | + dotnet test + if [ $? -eq 0 ]; then + echo TESTS PASSED + else + echo TESTS FAILED + exit 1 + fi + working-directory: ./MyApp.Tests + diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml old mode 100644 new mode 100755 index 0df6622..0fdcb44 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -34,12 +34,7 @@ jobs: uses: actions/checkout@v3 with: ref: refs/tags/${{ github.event.inputs.version }} - - - name: Setup dotnet - uses: actions/setup-dotnet@v3 - with: - dotnet-version: '8.0' - + # Assign environment variables used in subsequent steps - name: Env variable assignment run: echo "image_repository_name=$(echo ${{ github.repository }} | tr '[:upper:]' '[:lower:]')" >> $GITHUB_ENV @@ -53,20 +48,42 @@ jobs: if [ "${{ github.event.inputs.version }}" != "" ]; then echo "TAG_NAME=${{ github.event.inputs.version }}" >> $GITHUB_ENV fi; - - # Authenticate, build and push to GitHub Container Registry (ghcr.io) + if [ ! -z "${{ secrets.APPSETTINGS_PATCH }}" ]; then + echo "HAS_APPSETTINGS_PATCH=true" >> $GITHUB_ENV + else + echo "HAS_APPSETTINGS_PATCH=false" >> $GITHUB_ENV + fi; + - name: Login to GitHub Container Registry uses: docker/login-action@v2 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} + + + - name: Setup dotnet + uses: actions/setup-dotnet@v3 + with: + dotnet-version: '8.0' + + - name: Install x tool + if: env.HAS_APPSETTINGS_PATCH == 'true' + run: dotnet tool install -g x + + - name: Apply Production AppSettings + if: env.HAS_APPSETTINGS_PATCH == 'true' + working-directory: ./MyApp + run: | + cat <> appsettings.json.patch + ${{ secrets.APPSETTINGS_PATCH }} + EOF + x patch appsettings.json.patch # Build and push new docker image, skip for manual redeploy other than 'latest' - name: Build and push Docker image - working-directory: ./MyApp run: | - dotnet publish --os linux --arch x64 -c Release -p:ContainerRepository=${{ env.image_repository_name }} -p:ContainerRegistry=ghcr.io -p:ContainerImageTags=${{ env.TAG_NAME }} -p:ContainerPort=8080 + dotnet publish --os linux --arch x64 -c Release -p:ContainerRepository=${{ env.image_repository_name }} -p:ContainerRegistry=ghcr.io -p:ContainerImageTags=${{ env.TAG_NAME }} -p:ContainerPort=80 deploy_via_ssh: needs: push_to_registry @@ -97,42 +114,45 @@ jobs: - name: Create .env file run: | echo "Generating .env file" - - echo "# Autogenerated .env file" > .env - echo "HOST_DOMAIN=${{ secrets.DEPLOY_API }}" >> .env - echo "DEPLOY_CDN=${{ secrets.DEPLOY_CDN }}" >> .env - echo "LETSENCRYPT_EMAIL=${{ secrets.LETSENCRYPT_EMAIL }}" >> .env - echo "APP_NAME=${{ github.event.repository.name }}" >> .env - echo "IMAGE_REPO=${{ env.image_repository_name }}" >> .env - echo "RELEASE_VERSION=${{ env.TAG_NAME }}" >> .env - + + echo "# Autogenerated .env file" > .deploy/.env + echo "HOST_DOMAIN=${{ secrets.DEPLOY_HOST }}" >> .deploy/.env + echo "LETSENCRYPT_EMAIL=${{ secrets.LETSENCRYPT_EMAIL }}" >> .deploy/.env + echo "APP_NAME=${{ github.event.repository.name }}" >> .deploy/.env + echo "IMAGE_REPO=${{ env.image_repository_name }}" >> .deploy/.env + echo "RELEASE_VERSION=${{ env.TAG_NAME }}" >> .deploy/.env + # Copy only the docker-compose.yml to remote server home folder - name: copy files to target server via scp uses: appleboy/scp-action@v0.1.3 with: - host: ${{ secrets.DEPLOY_API }} + host: ${{ secrets.DEPLOY_HOST }} username: ${{ secrets.DEPLOY_USERNAME }} port: 22 key: ${{ secrets.DEPLOY_KEY }} - source: "./docker-compose.yml,./docker-compose.prod.yml,./.env" + strip_components: 2 + source: "./.deploy/docker-compose.yml,./.deploy/.env" target: "~/.deploy/${{ github.event.repository.name }}/" - + - name: Run remote db migrations uses: appleboy/ssh-action@v0.1.5 env: APPTOKEN: ${{ secrets.GITHUB_TOKEN }} USERNAME: ${{ secrets.DEPLOY_USERNAME }} with: - host: ${{ secrets.DEPLOY_API }} + host: ${{ secrets.DEPLOY_HOST }} username: ${{ secrets.DEPLOY_USERNAME }} key: ${{ secrets.DEPLOY_KEY }} port: 22 envs: APPTOKEN,USERNAME script: | + set -e echo $APPTOKEN | docker login ghcr.io -u $USERNAME --password-stdin cd ~/.deploy/${{ github.event.repository.name }} - docker compose -f ./docker-compose.yml -f ./docker-compose.prod.yml pull - docker compose -f ./docker-compose.yml -f ./docker-compose.prod.yml up app-migration + docker compose pull + export APP_ID=$(docker compose run --entrypoint "id -u" --rm app) + docker compose run --entrypoint "chown $APP_ID:$APP_ID /app/App_Data" --user root --rm app + docker compose up app-migration --exit-code-from app-migration # Deploy Docker image with your application using `docker compose up` remotely - name: remote docker-compose up via ssh @@ -141,7 +161,7 @@ jobs: APPTOKEN: ${{ secrets.GITHUB_TOKEN }} USERNAME: ${{ secrets.DEPLOY_USERNAME }} with: - host: ${{ secrets.DEPLOY_API }} + host: ${{ secrets.DEPLOY_HOST }} username: ${{ secrets.DEPLOY_USERNAME }} key: ${{ secrets.DEPLOY_KEY }} port: 22 @@ -149,5 +169,5 @@ jobs: script: | echo $APPTOKEN | docker login ghcr.io -u $USERNAME --password-stdin cd ~/.deploy/${{ github.event.repository.name }} - docker compose -f ./docker-compose.yml -f ./docker-compose.prod.yml pull - docker compose -f ./docker-compose.yml -f ./docker-compose.prod.yml up app -d + docker compose pull + docker compose up app -d diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index a1c7183..0000000 --- a/Dockerfile +++ /dev/null @@ -1,16 +0,0 @@ -FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build -WORKDIR /app - -COPY ./ . -RUN dotnet restore - -ARG DEPLOY_API -ARG DEPLOY_CDN - -WORKDIR /app/MyApp -RUN dotnet publish -c release /p:DEPLOY_API=${DEPLOY_API} /p:DEPLOY_CDN=${DEPLOY_CDN} /p:APP_TASKS=prerender -o /out --no-restore - -FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS runtime -WORKDIR /app -COPY --from=build /out . -ENTRYPOINT ["dotnet", "MyApp.dll"] diff --git a/MyApp/App_Data/README.md b/MyApp/App_Data/README.md new file mode 100755 index 0000000..5bebc95 --- /dev/null +++ b/MyApp/App_Data/README.md @@ -0,0 +1,11 @@ +## App Writable Folder + +This directory is designated for: + +- **Embedded Databases**: Such as SQLite. +- **Writable Files**: Files that the application might need to modify during its operation. + +For applications running in **Docker**, it's a common practice to mount this directory as an external volume. This ensures: + +- **Data Persistence**: App data is preserved across deployments. +- **Easy Replication**: Facilitates seamless data replication for backup or migration purposes. diff --git a/MyApp/MyApp.csproj b/MyApp/MyApp.csproj index 5270b2b..5e5540d 100644 --- a/MyApp/MyApp.csproj +++ b/MyApp/MyApp.csproj @@ -1,6 +1,7 @@  + DefaultContainer DefaultContainer net8.0 enable @@ -33,6 +34,7 @@ + DefaultContainer DefaultContainer $(MSBuildProjectDirectory)/../MyApp.Client $(ClientDir)/wwwroot diff --git a/docker-compose.override.yml b/docker-compose.override.yml deleted file mode 100644 index 6103c1a..0000000 --- a/docker-compose.override.yml +++ /dev/null @@ -1,27 +0,0 @@ -version: "3.9" -services: - app: - build: - context: . - args: - DEPLOY_API: localhost:5001 - DEPLOY_CDN: localhost:5001 - ports: - - "5001:443" - - "5000:80" - environment: - - ASPNETCORE_ENVIRONMENT=Development - - ASPNETCORE_URLS=https://+:443;http://+:80 - - ASPNETCORE_Kestrel__Certificates__Default__Password=password - - ASPNETCORE_Kestrel__Certificates__Default__Path=/https/aspnetapp.pfx - volumes: - - ./MyApp/App_Data:/app/App_Data - - ~/.aspnet/https:/https:ro - app-migration: - build: . - restart: "no" - profiles: - - migration - command: --AppTasks=migrate - volumes: - - ./MyApp/App_Data:/app/App_Data \ No newline at end of file diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml deleted file mode 100644 index 00d3c69..0000000 --- a/docker-compose.prod.yml +++ /dev/null @@ -1,34 +0,0 @@ -version: "3.9" -services: - app: - image: ghcr.io/${IMAGE_REPO}:${RELEASE_VERSION} - restart: always - ports: - - "8080" - container_name: ${APP_NAME}_app - environment: - VIRTUAL_HOST: ${HOST_DOMAIN} - VIRTUAL_PORT: 8080 - LETSENCRYPT_HOST: ${HOST_DOMAIN} - LETSENCRYPT_EMAIL: ${LETSENCRYPT_EMAIL} - DEPLOY_API: ${HOST_DOMAIN} - DEPLOY_CDN: ${DEPLOY_CDN} - volumes: - - app-mydb:/app/App_Data - app-migration: - image: ghcr.io/${IMAGE_REPO}:${RELEASE_VERSION} - restart: "no" - container_name: ${APP_NAME}_app_migration - profiles: - - migration - command: --AppTasks=migrate - volumes: - - app-mydb:/app/App_Data - -networks: - default: - external: true - name: nginx - -volumes: - app-mydb: diff --git a/docker-compose.yml b/docker-compose.yml deleted file mode 100644 index bdc4cc0..0000000 --- a/docker-compose.yml +++ /dev/null @@ -1,15 +0,0 @@ -version: "3.9" -services: - app: - volumes: - - app-mydb:/app/App_Data - app-migration: - restart: "no" - profiles: - - migration - command: --AppTasks=migrate - volumes: - - app-mydb:/app/App_Data - -volumes: - app-mydb: \ No newline at end of file From f27db4ea746d24c2c6fbbd682e4cdf3b717036af Mon Sep 17 00:00:00 2001 From: Darren Reid Date: Thu, 23 Nov 2023 13:18:07 +1100 Subject: [PATCH 2/2] Remove usage of CDN and prerendering since Blazor for .NET 8 now has static rendering option. --- MyApp.Client/MyApp.Client.csproj | 5 - MyApp.Client/Program.cs | 3 +- .../wwwroot/appsettings.Production.json | 3 - MyApp.Client/wwwroot/content/deploy.md | 160 ------ MyApp.Client/wwwroot/content/hosting.md | 2 +- MyApp.Client/wwwroot/content/prerender.md | 522 ------------------ MyApp/App.razor | 2 - MyApp/Configure.AppHost.cs | 3 +- MyApp/MyApp.csproj | 39 -- 9 files changed, 3 insertions(+), 736 deletions(-) delete mode 100644 MyApp.Client/wwwroot/appsettings.Production.json delete mode 100644 MyApp.Client/wwwroot/content/deploy.md delete mode 100644 MyApp.Client/wwwroot/content/prerender.md diff --git a/MyApp.Client/MyApp.Client.csproj b/MyApp.Client/MyApp.Client.csproj index e39689f..fa8e05a 100644 --- a/MyApp.Client/MyApp.Client.csproj +++ b/MyApp.Client/MyApp.Client.csproj @@ -19,11 +19,6 @@ - - - PreserveNewest - - diff --git a/MyApp.Client/Program.cs b/MyApp.Client/Program.cs index 0c9c1ee..28cbee4 100644 --- a/MyApp.Client/Program.cs +++ b/MyApp.Client/Program.cs @@ -26,8 +26,7 @@ // Use / for local or CDN resources builder.Services.AddScoped(sp => new HttpClient { BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) }); -var hasConfiguredDeployApi = !builder.Configuration["ApiBaseUrl"]?.Contains("{DEPLOY_API}"); -var apiBaseUrl = hasConfiguredDeployApi == true ? (builder.Configuration["ApiBaseUrl"] ?? builder.HostEnvironment.BaseAddress) : builder.HostEnvironment.BaseAddress; +var apiBaseUrl = builder.Configuration["ApiBaseUrl"] ?? builder.HostEnvironment.BaseAddress; builder.Services.AddBlazorApiClient(apiBaseUrl); builder.Services.AddLocalStorage(); diff --git a/MyApp.Client/wwwroot/appsettings.Production.json b/MyApp.Client/wwwroot/appsettings.Production.json deleted file mode 100644 index dde8719..0000000 --- a/MyApp.Client/wwwroot/appsettings.Production.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "ApiBaseUrl": "https://{DEPLOY_API}" -} diff --git a/MyApp.Client/wwwroot/content/deploy.md b/MyApp.Client/wwwroot/content/deploy.md deleted file mode 100644 index 970f66d..0000000 --- a/MyApp.Client/wwwroot/content/deploy.md +++ /dev/null @@ -1,160 +0,0 @@ ---- -title: Deployment with GitHub Actions -summary: Configuring your GitHub repo for SSH and CDN deployments -date: 2021-11-21 -WARN: During development Browser Cache needs to be disabled to refresh .md changes ---- - -# ServiceStack GitHub Action Deployments - -The [release.yml](https://github.com/NetCoreTemplates/blazor-tailwind/blob/main/.github/workflows/release.yml) -in this template enables GitHub Actions CI deployment to a dedicated server with SSH access. - -## Overview -`release.yml` is designed to work with a ServiceStack app deploying directly to a single server via SSH. A docker image is built and stored on GitHub's `ghcr.io` docker registry when a GitHub Release is created. - -GitHub Actions specified in `release.yml` then copy files remotely via scp and use `docker-compose` to run the app remotely via SSH. - -## What's the process of `release.yml`? - -![](https://raw.githubusercontent.com/ServiceStack/docs/master/docs/images/mix/release-ghr-vanilla-diagram.png) - -## Deployment server setup -To get this working, a server needs to be setup with the following: - -- SSH access -- docker -- docker-compose -- ports 443 and 80 for web access of your hosted application - -This can be your own server or any cloud hosted server like Digital Ocean, AWS, Azure etc. - -When setting up your server, you'll want to use a dedicated SSH key for access to be used by GitHub Actions. GitHub Actions will need the *private* SSH key within a GitHub Secret to authenticate. This can be done via ssh-keygen and copying the public key to the authorized clients on the server. - -To let your server handle multiple ServiceStack applications and automate the generation and management of TLS certificates, an additional docker-compose file is provided in this template, `nginx-proxy-compose.yml`. This docker-compose file is ready to run and can be copied to the deployment server. - -For example, once copied to remote `~/nginx-proxy-compose.yml`, the following command can be run on the remote server. - -``` -docker-compose -f ~/nginx-proxy-compose.yml up -d -``` - -This will run an nginx reverse proxy along with a companion container that will watch for additional containers in the same docker network and attempt to initialize them with valid TLS certificates. - -### GitHub Actions secrets - -The `release.yml` uses the following secrets. - -| Required Secrets | Description | -| -- | -- | -| `DEPLOY_API` | Hostname used to SSH deploy .NET App to, this can either be an IP address or subdomain with A record pointing to the server | -| `DEPLOY_USERNAME` | Username to log in with via SSH e.g, **ubuntu**, **ec2-user**, **root** | -| `DEPLOY_KEY` | SSH private key used to remotely access deploy .NET App | -| `LETSENCRYPT_EMAIL` | Email required for Let's Encrypt automated TLS certificates | - -To also enable deploying static assets to a CDN: - -| Optional Secrets | Description | -| -- | -- | -| `DEPLOY_CDN` | Hostname where static **/wwwroot** assets should be deployed to | - -These secrets can use the [GitHub CLI](https://cli.github.com/manual/gh_secret_set) for ease of creation. Eg, using the GitHub CLI the following can be set. - -```bash -gh secret set DEPLOY_API -b"" -gh secret set DEPLOY_USERNAME -b"" -gh secret set DEPLOY_KEY < key.pem # DEPLOY_KEY -gh secret set LETSENCRYPT_EMAIL -b"" -gh secret set DEPLOY_CDN -b"" -``` - -These secrets are used to populate variables within GitHub Actions and other configuration files. - -## Client UI Deployment - -The Blazor Client application is built and deployed to GitHub Pages during the `release.yml` workflow process by committing -the result of `vite build` to `gh-pages` branch in the repository. - -### CI .csproj After Build Tasks - -The Host Server `.csproj` includes post build instructions populated by GitHub Actions when publishing **Client** assets to CDN -by first copying the generated `index.html` home page into `404.html` in order to enable full page reloads to use Blazor's App -client routing: - -```xml - - $(MSBuildProjectDirectory)/../MyApp.Client - $(ClientDir)/wwwroot - - - - - - - - - - - - - - - - - - - - - -``` - -Whilst the `_redirects` file is a convention supported by many [popular Jamstack CDNs](https://jamstack.wtf/#deployment) -that sets up a new rule that proxies `/api*` requests to where the production .NET App is deployed to in order -for API requests to not need CORS: - -``` -/api/* {DEPLOY_API}/api/:splat 200 -``` - -By default this template doesn't use the `/api` proxy route & makes CORS API requests so it can be freely hosted -on GitHub pages CDN. - -## Pushing updates and rollbacks - -By default, deployments of both the **Client** and **Server** occur on commit to your main branch. A new Docker image for your -ServiceStack API is produced, pushed to GHCR.io and hosted on your Linux server with Docker Compose. -Your Blazor WASM UI is built and pushed to the repository GitHub Pages. - -The template also will run the release process on the creation of a GitHub Release making it easier to switch to manual production releases. - -Additionally, the `release.yml` workflow can be run manually specifying a version. This enables production rollbacks based on previously tagged releases. -A release must have already been created for the rollback build to work, it doesn't create a new Docker build based on previous code state, only redeploys as existing Docker image. - -## No CORS Hosting Options - -The `CorsFeature` needs to be enabled when adopting our recommended deployment configuration of having static -`/wwwroot` assets hosted from a CDN in order to make cross-domain requests to your .NET APIs. - -### Using a CDN Proxy -Should you want to, our recommended approach to avoid your App making CORS requests is to define an `/api` proxy route -on your CDN to your `$DEPLOY_API` server. - -To better support this use-case, this template includes populating the `_redirects` file used by popular CDNs like -[Cloudflare proxy redirects](https://developers.cloudflare.com/pages/platform/redirects) and -[Netlify proxies](https://docs.netlify.com/routing/redirects/rewrites-proxies/#proxy-to-another-service) to define -redirect and proxy rules. For AWS CloudFront you would need to define a -[Behavior for a custom origin](https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/RequestAndResponseBehaviorCustomOrigin.html). - -### No CDN - -Of course the easiest solution is to not need CORS in the first place by not deploying to a CDN and serving both **Server** -and Blazor Client **UI** from your .NET App. This would be the preferred approach when deploying within an Intranet where -network speeds are much faster in order for initial load times to be heavily reduced. - -However when deploying to a public site on the Internet we'd highly recommend deploying Blazor WASM's static assets to a CDN -so load times can be reduced as much as possible. diff --git a/MyApp.Client/wwwroot/content/hosting.md b/MyApp.Client/wwwroot/content/hosting.md index 33b3934..19c0051 100644 --- a/MyApp.Client/wwwroot/content/hosting.md +++ b/MyApp.Client/wwwroot/content/hosting.md @@ -25,7 +25,7 @@ who are able to provide generous free & low cost hosting options. ## [/MyApp.Client](https://github.com/NetCoreTemplates/blazor-tailwind/tree/main/MyApp.Client) -This template takes advantage of its decoupled architecture and uses [GitHub Actions to deploy](/docs/deploy) +This template takes advantage of its decoupled architecture and uses GitHub Actions to deploy a copy of its static UI generated assets and hosted on: ### GitHub Pages CDN diff --git a/MyApp.Client/wwwroot/content/prerender.md b/MyApp.Client/wwwroot/content/prerender.md deleted file mode 100644 index bd945ff..0000000 --- a/MyApp.Client/wwwroot/content/prerender.md +++ /dev/null @@ -1,522 +0,0 @@ ---- -title: Improving UX with Prerendering ---- - -# Improving UX with Prerendering - -> Why does this page load so fast? - -### Blazor WASM trade-offs - -Blazor WASM enables reuse of C# skills, tooling & libraries offers a compelling advantage for .NET teams, so much so -it's become our preferred technology for developing internal LOB applications as it's better able to reuse existing -C# investments in an integrated SPA Framework utilizing a single toolchain. - -It does however comes at a cost of a larger initial download size and performance cost resulting in a high Time-To-First-Render (TTFR) -and an overall poor initial User Experience when served over the Internet, that's further exacerbated over low speed Mobile connections. - -This is likely an acceptable trade-off for most LOB applications served over high-speed local networks but may not be a -suitable choice for public Internet sites _(an area our other [jamstacks.net](https://jamstacks.net) templates may serve better)_. - -As an SPA it also suffers from poor SEO as content isn't included in the initial page and needs to be rendered in the browser after -the App has initialized. For some content heavy sites this can be a deal breaker either requiring proxy rules so content pages -are served by a different SEO friendly site or otherwise prohibits using Blazor WASM entirely. - -### Improving Startup Performance - -The solution to both issues is fairly straightforward, by utilizing the mainstay solution behind -[Jamstack Frameworks](https://jamstack.org/generators/) and prerender content at build time. - -We know what needs to be done, but how best to do it in Blazor WASM? Unfortunately the -[official Blazor WASM prerendering guide](https://docs.microsoft.com/en-us/aspnet/core/blazor/components/prerendering-and-integration?view=aspnetcore-6.0&pivots=webassembly) -isn't actually a prerendering solution, as is typically used to describe -[Static Site Generators (SSG)](https://www.netlify.com/blog/2020/04/14/what-is-a-static-site-generator-and-3-ways-to-find-the-best-one/) -prerendering static content at build-time, whilst Blazor WASM prerendering docs instead describes -a [Server-Side-Rendering (SSR)](https://www.omnisci.com/technical-glossary/server-side-renderings) solution mandating the additional -complexity of maintaining your Apps dependencies in both client and server projects. Unfortunately this approach also wont yield an -optimal result since prerendering is typically used so Apps can host their SSG content on static file hosts, instead SSR does the -opposite whose forced runtime coupling to the .NET Server Host prohibits Blazor WASM Apps from being served from a CDN. - -As this defeats [many of the benefits](hosting) of a Blazor WASM Jamstack App in the first place, we've instead opted for a more optimal -solution that doesn't compromise its CDN hostability. - -### Increasing Perceived Performance - -We've little opportunity over improving the startup time of the real C# Blazor App beyond hosting its static assets on CDN edge caches, -but ultimately what matters is [perceived performance](https://marvelapp.com/blog/a-designers-guide-to-perceived-performance/) which -we do have control over given the screen for a default Blazor WASM project is a glaring white screen flash: - -![](https://raw.githubusercontent.com/ServiceStack/docs/master/docs/images/jamstack/blazor-tailwind/loading-default.png) - -The longer users have to wait looking at this black loading screen without signs of progress, the more they'll associate your site -with taking forever to load. - -One technique many popular sites are using to increase perceived performance is to use content placeholders in place of real-content -which gives the impression that the site has almost loaded and only requires a few moments more for the latest live data to be slotted in. - -As an example here's what YouTube content placeholders mimicking the page layout looks like before the real site has loaded: - -![](https://raw.githubusercontent.com/ServiceStack/docs/master/docs/images/jamstack/youtube-placeholder.png) - -But we can do even better than an inert content placeholder, and load a temporary chrome of our App. But as this needs to be done -before Blazor has loaded we need to implement this with a sprinkling of HTML + JS. - -Essentially we need to copy the Chrome and navigation of our App from the -[Header](https://github.com/NetCoreTemplates/blazor-tailwind/blob/main/MyApp.Client/Shared/Header.razor), -[Sidebar](https://github.com/NetCoreTemplates/blazor-tailwind/blob/main/MyApp.Client/Shared/Sidebar.razor) and -[MainLayout](https://github.com/NetCoreTemplates/blazor-tailwind/blob/main/MyApp.Client/Shared/MainLayout.razor) -and paste it into -[/wwwroot/index.html](https://github.com/NetCoreTemplates/blazor-tailwind/blob/main/MyApp.Client/wwwroot/index.html) -where anything between `
` is displayed whilst our Blazor App is loading, before it's replaced with the real C# rendered App. - -After which we end up with HTML similar to the structure below: - -```html -
- -
- -
- ... - -
- - - - - -
-
-
-
- -
-

- Loading... -

-
- -
-
-
-
-
-
-``` - -Less our App's navigation menus which we'll dynamically generate with the splash of JS below: - -```js -TOP = ` - $0.40 /mo, /docs/hosting - Prerendering, /docs/prerender - Deployments, /docs/deploy -` -SIDEBAR = ` - Counter, /counter, /img/nav/counter.svg - Todos, /todomvc, /img/nav/todomvc.svg - Bookings CRUD, /bookings-crud, /img/nav/bookings-crud.svg - Call Hello, /hello$, /img/nav/hello.svg - Call HelloSecure, /hello-secure, /img/nav/hello-secure.svg - Fetch data, /fetchdata, /img/nav/fetchdata.svg -` - -const path = location.pathname -const renderNav = (csv, f) => csv.trim().split(/\r?\n/g).map(s => f.apply(null, s.split(',').map(x => x.trim()))).join('') -$1 = s => document.querySelector(s) - -/* Header */ -$1('#app-loading header nav ul').insertAdjacentHTML('afterbegin', renderNav(TOP, (label, route) => - `
  • - ${label}
  • ` -)) - -/* Sidebar */ -const NAV = ({ label, route, exact, icon, cls, iconCls }) => ` - - ${label} -` - -$1('#app-loading .mobile nav').innerHTML = renderNav(SIDEBAR, (label, route, icon) => NAV({ - label, cls: `text-gray-600 hover:bg-gray-50 hover:text-gray-900 group flex items-center px-2 py-2 text-base font-medium`, - iconCls: `mr-4 flex-shrink-0 h-6 w-6`, - icon, route: route.replace(/\$$/, ''), exact: route.endsWith('$') -})) -$1('#app-loading .desktop nav').innerHTML = renderNav(SIDEBAR, (label, route, icon) => NAV({ - label, cls: `text-gray-600 hover:bg-gray-50 hover:text-gray-900 group flex items-center px-2 py-2 text-sm font-medium`, - iconCls: `mr-3 flex-shrink-0 h-6 w-6`, - icon, route: route.replace(/\$$/, ''), exact: route.endsWith('$') -})) -``` - -Which takes care of both rendering the top and sidebar menus as well as highlighting the active menu for the active -nav item being loaded, and because we're rendering our real App navigation with real links, users will be able to navigate -to the page they want before our App has loaded. - -To minimize maintenance efforts the C# Blazor App also uses the navigation defined in `TOP` and `SIDEBAR` to render its Navigation Menus. - -With just this, every page now benefits from an instant App chrome to give the perception that our App has loaded instantly -before any C# in our Blazor App is run. E.g. here's what the [Blazor Counter](/counter) page looks like while it's loading: - -![](https://raw.githubusercontent.com/ServiceStack/docs/master/docs/images/jamstack/blazor-tailwind/loading.png) - -If you click refresh the [/counter](/counter) page a few times you'll see the new loading screen prior to the Counter page being available. - -Our App is now in a pretty shippable state with decent UX of a loading page that looks like it loaded instantly instead -of the "under construction" Loading... page from the default Blazor WASM project template. - -It's a fairly low maintenance solution with the primary concession being the App's main navigation menus are defined in -`SIDEBAR` and `TOP` csv lists so it maintains a single source of truth for both C# and JS generated UIs. - -### Improving UX with Prerendering - -We'll now turn our focus to the most important page in our App, the [Home Page](/) which is the page most people will see -when loading the App from the first time. - -With the above temp App chrome already in place, a simple generic pre-rendering solution to be able to load any prerendered -page is to check if any prerendered content exists in the -[/prerender](https://github.com/NetCoreTemplates/blazor-tailwind/tree/gh-pages/prerender) -folder for the current path, then if it does replace the default index.html `Loading...` page with it: - -```js -const pagePath = path.endsWith('/') - ? path.substring(0, path.length - 2) + '/index.html' - : path -fetch(`/prerender${pagePath}`) - .then(r => r.text()) - .then(html => { - if (html.indexOf('') >= 0) return // ignore CDN 404.html - const pageBody = $1('#app-loading .content') - if (pageBody) - pageBody.innerHTML = `` + html - }) - .catch(/* no prerendered content found for this path */) -``` - -We also tag which path the prerendered content is for and provide a JS function to fetch the prerendered content -which we'll need to access later in our App: - -```html - -``` - -We now have a solution in place to load pre-rendered content from the `/prerender` folder, but still need some way of generating it. - -The solution is technology independent in that you can you use any solution your most comfortable with, (even manually construct -each prerendered page if preferred), although it's less maintenance if you automate and get your CI to regenerate it when it publishes -your App. - -Which ever tool you choose would also need to be installed in your CI/GitHub Action if that's where it's run, so we've opted for -a dependency-free & least invasive solution by utilizing the existing Tests project, which has both great IDE tooling support and -can easily be run from the command-line and importantly is supported by the [bUnit](https://bunit.dev) testing library which we'll -be using to render component fragments in isolation. - -To distinguish prerendering tasks from our other Tests we've tagged -[PrerenderTasks.cs](https://github.com/NetCoreTemplates/blazor-tailwind/blob/main/MyApp.Tests/PrerenderTasks.cs) -with the `prerender` Test category. The only configuration the tasks require is the location of the `ClientDir` WASM Project -defined in [appsettings.json](https://github.com/NetCoreTemplates/blazor-tailwind/blob/main/MyApp.Tests/appsettings.json) -that's setup in the constructor. - -The `Render()` method renders the Blazor Page inside a `Bunit.TestContext` which it saves at the location -specified by its `@page` directive. - -```csharp -[TestFixture, Category("prerender")] -public class PrerenderTasks -{ - Bunit.TestContext Context; - string ClientDir; - string WwrootDir => ClientDir.CombineWith("wwwroot"); - string PrerenderDir => WwrootDir.CombineWith("prerender"); - - public PrerenderTasks() - { - Context = new(); - var config = new ConfigurationBuilder().AddJsonFile("appsettings.json").Build(); - ClientDir = config[nameof(ClientDir)] - ?? throw new Exception($"{nameof(ClientDir)} not defined in appsettings.json"); - FileSystemVirtualFiles.RecreateDirectory(PrerenderDir); - } - - void Render(params ComponentParameter[] parameters) where T : IComponent - { - WriteLine($"Rendering: {typeof(T).FullName}..."); - var component = Context.RenderComponent(parameters); - var route = typeof(T).GetCustomAttribute()?.Template; - if (string.IsNullOrEmpty(route)) - throw new Exception($"Couldn't infer @page for component {typeof(T).Name}"); - - var fileName = route.EndsWith("/") ? route + "index.html" : $"{route}.html"; - - var writeTo = Path.GetFullPath(PrerenderDir.CombineWith(fileName)); - WriteLine($"Written to {writeTo}"); - File.WriteAllText(writeTo, component.Markup); - } - - [Test] - public void PrerenderPages() - { - Render(); - // Add Pages to prerender... - } -} -``` - -Being a unit test gives it a number of different ways it can be run, using any of the NUnit test runners, from the GUI -integrated in C# IDEs or via command-line test runners like `dotnet test` which can be done with: - -```bash -$ dotnet test --filter TestCategory=prerender -``` - -To have CI automatically run it when it creates a production build of our App we'll add it to our Host `.csproj`: - -```xml - - $(MSBuildProjectDirectory)/../MyApp.Tests - - - - - - - -``` - -Which allows [GitHub Actions to run it](https://github.com/NetCoreTemplates/blazor-tailwind/blob/9460ebf57d3e46af1680eb3a2ff5080e59d33a54/.github/workflows/release.yml#L80) -when it publishes the App with: - -```bash -$ dotnet publish -c Release /p:APP_TASKS=prerender -``` - -Now when we next commit code, the GitHub CI Action will run the above task to generate our -[/prerender/index.html](https://github.com/NetCoreTemplates/blazor-tailwind/blob/gh-pages/prerender/index.html) page -that now loads our [Home Page](/) instantly! - -[![](https://raw.githubusercontent.com/ServiceStack/docs/master/docs/images/jamstack/blazor-tailwind/home-prerendered.png)](/) - -The only issue now is that the default Blazor template behavior will yank our pre-rendered page, once during loading -and another during Authorization. To stop the unwanted yanking we've updated the -[](https://github.com/NetCoreTemplates/blazor-tailwind/blob/main/MyApp.Client/Shared/Loading.razor) component -to instead load the prerendered page content if it's **for the current path**: - -```razor -@inject IJSRuntime JsRuntime -@inject NavigationManager NavigationManager - -@if (!string.IsNullOrEmpty(prerenderedHtml)) -{ - @((MarkupString)prerenderedHtml) -} -else -{ -
    - -
    -

    - Loading... -

    -} - -@code { - [Parameter] - public string Message { get; set; } = "Loading..."; - - [Parameter] - public string @class { get; set; } = ""; - - public string prerenderedHtml { get; set; } = ""; - - protected override async Task OnInitializedAsync() - { - var html = await JsRuntime.InvokeAsync("JS.prerenderedPage") ?? ""; - var currentPath = new Uri(NavigationManager.Uri).AbsolutePath; - if (html.IndexOf($"data-prerender=\"{currentPath}\"") >= 0) - prerenderedHtml = html; - } -} -``` - -Whilst to prevent yanking by the Authorization component we'll also include the current page when rendering -the alternate layout with an `Authenticating...` text that will appear under the Login/Logout buttons on the top-right: - -```xml - - -

    Authenticating...

    - -
    -
    -``` - -This last change brings us to the optimal UX we have now with the home page loading instantly whilst our Blazor App -is loading in the background that'll eventually replace the home page with its identical looking C# version. - -### Prerendering Markdown Content - -The other pages that would greatly benefit from prerendering are the Markdown `/docs/*` pages (like this one) that's implemented in -[Docs.razor](https://github.com/NetCoreTemplates/blazor-tailwind/blob/main/MyApp.Client/Pages/Docs.razor). - -However to enable SEO friendly content our `fetch(/prerender/*)` solution isn't good enough as the initial page download -needs to contain the prerendered content, i.e. instead of being downloaded in after. - -### PrerenderMarkdown Task - -To do this our `PrerenderMarkdown` Task scans all `*.md` pages in the -[content](https://github.com/NetCoreTemplates/blazor-tailwind/tree/main/MyApp.Client/wwwroot/content) -directory and uses the same -[/MyApp.Client/MarkdownUtils.cs](https://github.com/NetCoreTemplates/blazor-tailwind/blob/main/MyApp.Client/MarkdownUtils.cs) -implementation [Docs.razor](https://github.com/NetCoreTemplates/blazor-tailwind/blob/main/MyApp.Client/Pages/Docs.razor) -uses to generate the markdown and embeds it into the `index.html` loading page to generate the pre-rendered page: - -```csharp -[Test] -public async Task PrerenderMarkdown() -{ - var srcDir = WwrootDir.CombineWith("content").Replace('\\', '/'); - var dstDir = WwrootDir.CombineWith("docs").Replace('\\', '/'); - - var indexPage = PageTemplate.Create(WwrootDir.CombineWith("index.html")); - if (!Directory.Exists(srcDir)) throw new Exception($"{Path.GetFullPath(srcDir)} does not exist"); - FileSystemVirtualFiles.RecreateDirectory(dstDir); - - foreach (var file in new DirectoryInfo(srcDir).GetFiles("*.md", SearchOption.AllDirectories)) - { - WriteLine($"Converting {file.FullName} ..."); - - var name = file.Name.WithoutExtension(); - var docRender = await Client.MarkdownUtils.LoadDocumentAsync(name, doc => - Task.FromResult(File.ReadAllText(file.FullName))); - - if (docRender.Failed) - { - WriteLine($"Failed: {docRender.ErrorMessage}"); - continue; - } - - var dirName = dstDir.IndexOf("wwwroot") >= 0 - ? dstDir.LastRightPart("wwwroot").Replace('\\', '/') - : new DirectoryInfo(dstDir).Name; - var path = dirName.CombineWith(name == "index" ? "" : name); - - var mdBody = @$" -
    -
    - {docRender.Response!.Preview!} -
    -
    "; - var prerenderedPage = indexPage.Render(mdBody); - string htmlPath = Path.GetFullPath(Path.Combine(dstDir, $"{name}.html")); - File.WriteAllText(htmlPath, prerenderedPage); - WriteLine($"Written to {htmlPath}"); - } -} - -public class PageTemplate -{ - string? Header { get; set; } - string? Footer { get; set; } - - public PageTemplate(string? header, string? footer) - { - Header = header; - Footer = footer; - } - - public static PageTemplate Create(string indexPath) - { - if (!File.Exists(indexPath)) - throw new Exception($"{Path.GetFullPath(indexPath)} does not exist"); - - string? header = null; - string? footer = null; - - var sb = new StringBuilder(); - foreach (var line in File.ReadAllLines(indexPath)) - { - if (header == null) - { - if (line.Contains("")) - { - header = sb.ToString(); // capture up to start page marker - sb.Clear(); - } - else sb.AppendLine(line); - } - else - { - if (sb.Length == 0) - { - if (line.Contains("")) // discard up to end page marker - { - sb.AppendLine(); - continue; - } - } - else sb.AppendLine(line); - } - } - footer = sb.ToString(); - - if (string.IsNullOrEmpty(header) || string.IsNullOrEmpty(footer)) - throw new Exception($"Parsing {indexPath} failed, missing ... markers"); - - return new PageTemplate(header, footer); - } - - public string Render(string body) => Header + body + Footer; -} -``` - -Whilst the `wwwroot/index.html` is parsed with `PageTemplate` above who uses the resulting layout to generate pages -within `` markers. - -After it's also executed by the same MSBuild task run by GitHub Actions it prerenders all `/wwwroot/content/*.md` pages -which are written to the [/wwwroot/docs/*.html](https://github.com/NetCoreTemplates/blazor-tailwind/tree/gh-pages/docs) folder. - -This results in the path to the pre-generated markdown docs i.e. [/docs/prerender](/docs/prerender) having the **exact same path** -as its route in the Blazor App, which when exists, CDNs give priority to over the SPA fallback the Blazor App is loaded with. - -It shares similar behavior as the home page where its pre-rendered content is initially loaded before it's replaced with the -C# version once the Blazor App loads. The difference is that it prerenders "complete pages" for better SEO & TTFR. - -> Why does this page load so fast? - -So to answer the initial question, this page loads so fast because a prerendered version is initially loaded from a CDN edge cache, -i.e. the same reason why [Jamstack.org](https://jamstack.org) SSG templates like our modern -[nextjs.jamstacks.net](https://nextjs.jamstacks.net) and [vue-ssg.jamstacks.net](https://vue-ssg.jamstacks.net) -exhibit such great performance and UX out-of-the-box. - -We hope this technique serves useful in greatly improving the initial UX of Blazor Apps, a new Blazor App -with all this integrated can be created on the [Home Page](/) diff --git a/MyApp/App.razor b/MyApp/App.razor index 999d91d..eee2cdd 100644 --- a/MyApp/App.razor +++ b/MyApp/App.razor @@ -50,8 +50,6 @@