Recently I did some work to set up continuous deployment for a practice project (Larder), which is an ASP.NET Core web API with a React app frontend. This post is not intended to be a guide or show best practices, just some notes on how I got along which may provide some helpful hints to someone (probably future me).
GitHub action job to build and deploy the React app
The environment: development
sets the environment that the secrets are read from. The source code organizes the React app in a directory client
, so after checking out the repo, the working directory for the run
steps is set to ./client
.
npm run build
builds the React app to .client/build
and that is SCPed to the VM (using credentials read from GitHub secrets). The build is placed in the standard location for static assets served by Apache.
name: my-github-action
on: [push]
jobs:
build-and-deploy-react-client:
environment: development
runs-on: ubuntu-latest
defaults:
run:
working-directory: ./client
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 14.x
- name: Install dependencies
run: npm install
- name: Build React app
run: npm run build
- name: SCP bundle to VM
uses: appleboy/scp-action@v0.1.7
with:
host: ${{ secrets.HOST }}
username: ${{ secrets.USERNAME }}
key: ${{ secrets.PRIVATE_SSH_KEY }}
source: "./client/build"
target: /var/www/larder_client
Create React App documentation explains using .env.production
to set environment variables at build time, however for some reason I’ve been unable to get that to work, despite using .env.development
without any issue. It is a TODO for me to figure that out, so for now my base class for API client services has this workaround:
Apache configuration
Relevant directory: /etc/apache2
The following would be in /etc/apache2/sites-available
. Apache listens to two ports for Larder, one for the React app and one for the API, and enforces HTTPS. The React app is served at port 446 as a static asset. The React app fetches JSON from the API, to do so it makes requests to Apache listening on 49152, which acts as a reverse proxy to the web API’s built-in Kestrel web server listening on localhost:5000
.
<VirtualHost *:446>
ServerName kylerego.net
DocumentRoot /var/www/larder_client/client/build
<Directory /var/www/larder_client/client/build>
Options Indexes FollowSymLinks
AllowOverride All
Require all granted
</Directory>
ErrorLog ${APACHE_LOG_DIR}/error.log
CustomLog ${APACHE_LOG_DIR}/access.log combined
SSLEngine On
SSLCertificateFile /path_to_certificate/kylerego_net.crt
SSLCertificateKeyFile /path_to_certificate/kylerego.net.key
SSLCertificateChainFile /path_to_certificate/kylerego_net.ca-bundle
SSLProtocol -all +TLSv1.2
</VirtualHost>
<VirtualHost *:49152>
ProxyPreserveHost On
ProxyPass / http://localhost:5000/
ProxyPassReverse / http://localhost:5000/
ErrorLog ${APACHE_LOG_DIR}/app-error.log
CustomLog ${APACHE_LOG_DIR}/app-access.log common
SSLEngine On
SSLCertificateFile /path_to_certificate/kylerego_net.crt
SSLCertificateKeyFile /path_to_certificate/kylerego.net.key
SSLCertificateChainFile /path_to_certificate/kylerego_net.ca-bundle
SSLProtocol -all +TLSv1.2
</VirtualHost>
The ports that Apache listens to are specified in /etc/apache2/ports.conf
. Any ports I use on this VM in the future will probably be in the dynamic/private range (49152 through 65535). In my situation, I am using an Azure VM so it is also necessary to open the ports with network security groups.
GitHub action job to deploy the web API
This shows a self-contained publish which includes the .NET runtime (the app will be able to run on a machine which does not have the .NET framework installed). However, the runtime target needs to be specified (linux-arm64
).
build-test-and-deploy-webapi:
environment: development
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: '8.0.x'
- name: Run tests
run: dotnet test WebApi.Tests
- name: Publish self-contained executable
run: dotnet publish WebApi -r linux-arm64 --self-contained true
- name: SCP dotnet executable to VM
uses: appleboy/scp-action@v0.1.7
with:
host: ${{ secrets.HOST }}
username: ${{ secrets.USERNAME }}
key: ${{ secrets.PRIVATE_SSH_KEY }}
source: "./WebApi/bin/Release/net8.0/linux-arm64/publish"
target: ${{ secrets.WEBAPI_DEPLOY_DIRECTORY }}
Since the deployment directory target is not standard like /var/www
, I figured it would be better to keep it a secret.
systemd service for the web API
Relevant directory: /etc/systemd/system
[Unit]
Description=Larder
[Service]
Type=simple
WorkingDirectory=/path_to_webapi_deploy_directory/WebApi/bin/Release/net8.0/linux-arm64/publish
ExecStart=/path_to_webapi_deploy_directory/WebApi/bin/Release/net8.0/linux-arm64/publish/Larder
Environment="LARDER_DATABASE_PATH=Data Source=/path_to_sqlite_database/larder_database.db"
[Install]
WantedBy=multi-user.target
The working directory is set to be the publish directory so that the appsettings.json
file there can be read. That configuration file includes the origin of the React app so that the CORS policy can be configured:
The service file also sets an environment variable for the SQLite database connection string.