A travel tracking web application built with ASP.NET Core that helps you track international trips and calculate days spent in each country over a rolling 12-month (365-day) period for residency and tax purposes.
- Architecture
- Features
- Tech Stack
- Quick Start
- Configuration
- API Documentation
- Project Structure
- How It Works
- Deployment
- Troubleshooting
- License
ResidencyRoll uses a modern API-first architecture with separated concerns:
-
API Backend (ResidencyRoll.Api): ASP.NET Core Web API with:
- RESTful endpoints with versioning (
/api/v1/trips) - JWT Bearer token authentication
- OpenAPI/Swagger documentation
- SQLite database with Entity Framework Core
- RESTful endpoints with versioning (
-
Web Frontend (ResidencyRoll.Web): Blazor Server application with:
- Interactive UI using Radzen components
- OpenID Connect authentication
- Typed HTTP client for API communication
- Token forwarding to backend API
-
Shared Models (ResidencyRoll.Shared): Common DTOs used by both projects
This separation allows the API to be consumed by multiple clients (web, mobile, third-party integrations) while maintaining a single source of truth for business logic.
- Rolling 365-Day Calculations: Automatically calculates days spent in each country within the last 365 days from today
- Visual Dashboard:
- Arc Gauge showing Days Away vs Days at Home
- Donut Chart displaying country distribution
- Interactive Timeline of all trips
- Trip Management: Inline CRUD operations using Radzen DataGrid
- Forecast Tool: Plan future trips and see projected day counts
- Authentication: JWT/OAuth/OpenID Connect support for secure API access
- API-First Design: RESTful API ready for mobile apps and integrations
- Persistent Storage: SQLite database with Docker volume mapping
- Responsive UI: Built with Radzen Blazor components
- Backend: ASP.NET Core Web API (.NET 10)
- Frontend: Blazor Server (.NET 10)
- Authentication: OpenID Connect + JWT Bearer
- UI Library: Radzen.Blazor (Free/Community components)
- Database: SQLite with Entity Framework Core
- API Documentation: Swagger/OpenAPI
- Deployment: Docker Compose with multi-container setup
The fastest way to run the application with persistent data.
-
Download the required files:
# Download docker-compose.yml and .env.example curl -O https://raw.githubusercontent.com/GlenConway/ResidencyRoll/main/docker-compose.yml curl -O https://raw.githubusercontent.com/GlenConway/ResidencyRoll/main/.env.example -
Configure environment variables:
# Copy the example file cp .env.example .env # Edit .env with your configuration nano .env
At minimum, configure these settings in
.env:# Port Configuration (defaults are fine for most setups) API_PORT=8080 WEB_PORT=8081 # Enable authentication (optional - set to false for no auth) OIDC_ENABLED=false # If OIDC_ENABLED=true, configure your identity provider: OIDC_AUTHORITY=https://your-tenant.auth0.com/ OIDC_CLIENT_ID=your-client-id OIDC_CLIENT_SECRET=your-client-secret JWT_AUTHORITY=https://your-tenant.auth0.com/ JWT_AUDIENCE=your-api-identifier
See
.env.examplefor all available configuration options. -
Start the services:
docker compose up -d
Access the application:
- Web UI:
http://localhost:8081 - API:
http://localhost:8080 - API Swagger:
http://localhost:8080/swagger
The docker-compose.yml uses environment variables from the .env file for all configuration:
| Variable | Description | Default |
|---|---|---|
API_PORT |
External port for API | 8080 |
API_INTERNAL_PORT |
Internal container port for API | 80 |
WEB_PORT |
External port for Web UI | 8081 |
WEB_INTERNAL_PORT |
Internal container port for Web | 8080 |
ASPNETCORE_ENVIRONMENT |
ASP.NET Core environment | Production |
DB_PATH |
SQLite database path | /app/data/residencyroll.db |
OIDC_ENABLED |
Enable/disable authentication | false |
OIDC_AUTHORITY |
Identity provider URL | - |
OIDC_CLIENT_ID |
Web app client ID | - |
OIDC_CLIENT_SECRET |
Web app client secret | - |
JWT_AUTHORITY |
JWT issuer URL | - |
JWT_AUDIENCE |
JWT audience identifier | - |
CORS_ORIGIN_0 |
Allowed CORS origin 1 | http://localhost:8081 |
CORS_ORIGIN_1 |
Allowed CORS origin 2 | http://residencyroll-web:80 |
FORWARDED_HEADERS_KNOWN_PROXY_0 |
Trusted reverse proxy IP address | - |
FORWARDED_HEADERS_KNOWN_PROXY_1 |
Additional trusted proxy IP | - |
FORWARDED_HEADERS_KNOWN_NETWORK_0 |
Trusted proxy network in CIDR notation | - |
FORWARDED_HEADERS_KNOWN_NETWORK_1 |
Additional trusted network | - |
If you deploy ResidencyRoll behind a reverse proxy (nginx, Caddy, cloud load balancer), you must configure trusted proxies to ensure the application correctly processes X-Forwarded-For and X-Forwarded-Proto headers. This is critical for:
- Correctly identifying client IP addresses in logs
- Proper HTTPS redirect behavior
- Security (preventing header spoofing attacks)
Default Behavior: Without configuration, ASP.NET Core only trusts localhost/loopback addresses, which is secure for direct deployments.
When to Configure:
- ✅ Using nginx, Caddy, Traefik, or cloud load balancers
- ✅ Containers behind Docker network or Kubernetes ingress
- ✅ Any multi-tier deployment with a reverse proxy layer
Configuration Options:
-
Known Proxies (specific IP addresses):
# Single nginx proxy FORWARDED_HEADERS_KNOWN_PROXY_0=172.17.0.1 # Multiple proxies FORWARDED_HEADERS_KNOWN_PROXY_0=172.17.0.1 FORWARDED_HEADERS_KNOWN_PROXY_1=10.0.1.5
-
Known Networks (CIDR ranges for dynamic IPs):
# Docker bridge network FORWARDED_HEADERS_KNOWN_NETWORK_0=172.17.0.0/16 # Cloud load balancer subnet FORWARDED_HEADERS_KNOWN_NETWORK_0=10.240.0.0/16 # Private network range FORWARDED_HEADERS_KNOWN_NETWORK_0=10.0.0.0/8
Example nginx Configuration:
server {
listen 80;
server_name residencyroll.example.com;
location / {
proxy_pass http://localhost:8081;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}Then configure the proxy's IP in .env:
FORWARDED_HEADERS_KNOWN_PROXY_0=172.17.0.1 # nginx container IPSecurity Warning: Only add IP addresses/networks you control. Misconfiguration can allow attackers to spoof headers and bypass security controls.
Docker volumes ensure data persists across container restarts:
residencyroll-api-data- SQLite databaseresidencyroll-web-data- Web application data
To backup your data:
# Export the database
docker compose exec residencyroll-api cat /app/data/residencyroll.db > backup.db
# Restore from backup
docker compose cp backup.db residencyroll-api:/app/data/residencyroll.dbTo completely remove all data:
docker compose down -v # The -v flag removes volumes# Clone and build locally
git clone https://github.com/GlenConway/ResidencyRoll.git
cd ResidencyRoll
# Edit the docker-compose.yml to use local builds instead of GHCR images:
# Replace 'image: ghcr.io/...' with:
# build:
# context: .
# dockerfile: Dockerfile.api # (or Dockerfile for web)
docker compose up -d --build-
Start the API:
cd src/ResidencyRoll.Api dotnet watch run -
Start the Web app (in a new terminal):
cd src/ResidencyRoll.Web dotnet watch run -
Access:
https://localhost:5001orhttp://localhost:5000
Authentication is disabled by default for local development.
For complete setup including authentication:
# Copy example configuration files
cp src/ResidencyRoll.Web/appsettings.Development.json.example src/ResidencyRoll.Web/appsettings.Development.json
cp src/ResidencyRoll.Api/appsettings.Development.json.example src/ResidencyRoll.Api/appsettings.Development.jsonSee Configuration section below for authentication setup.
Configuration files are excluded from source control to protect secrets.
-
Copy example files:
cp src/ResidencyRoll.Web/appsettings.Development.json.example src/ResidencyRoll.Web/appsettings.Development.json cp src/ResidencyRoll.Api/appsettings.Development.json.example src/ResidencyRoll.Api/appsettings.Development.json
-
Verify files are ignored:
git status --ignored | grep appsettings
These files are in .gitignore and will NOT be committed.
Authentication is disabled by default for development. To enable:
- Azure AD / Microsoft Entra ID
- Auth0 (easiest for testing)
- Keycloak (self-hosted)
- Okta, Duende IdentityServer
- Any OIDC-compliant provider
- Sign up at auth0.com
- Create a tenant (e.g.,
your-tenant-name) - Your Authority URL:
https://your-tenant-name.auth0.com/
- Dashboard → Applications → APIs → Create API
- Name:
ResidencyRoll API - Identifier:
https://api.residencyroll.com(or any unique value) - Signing Algorithm: RS256
- Save the Identifier - this is your Audience
- Dashboard → Applications → Applications → Create Application
- Name:
ResidencyRoll Web - Type: Regular Web Application
- Settings:
- Allowed Callback URLs:
https://localhost:5001/signin-oidc - Allowed Logout URLs:
https://localhost:5001/ - Allowed Web Origins:
https://localhost:5001
- Allowed Callback URLs:
- Copy: Domain, Client ID, Client Secret
Choose one of these methods:
# Web App
cd src/ResidencyRoll.Web
dotnet user-secrets set "Authentication:OpenIdConnect:Authority" "https://YOUR-TENANT.auth0.com/"
dotnet user-secrets set "Authentication:OpenIdConnect:ClientId" "YOUR-CLIENT-ID"
dotnet user-secrets set "Authentication:OpenIdConnect:ClientSecret" "YOUR-CLIENT-SECRET"
# API
cd ../ResidencyRoll.Api
dotnet user-secrets set "Jwt:Authority" "https://YOUR-TENANT.auth0.com/"
dotnet user-secrets set "Jwt:Audience" "YOUR-API-IDENTIFIER"Or use the automated configuration script:
./configure-auth0.shWeb (src/ResidencyRoll.Web/appsettings.Development.json):
{
"Authentication": {
"OpenIdConnect": {
"Enabled": true,
"Authority": "https://YOUR-TENANT.auth0.com/",
"ClientId": "YOUR-CLIENT-ID",
"ClientSecret": "YOUR-CLIENT-SECRET",
"RequireHttpsMetadata": false,
"ApiScope": "openid profile email"
}
}
}API (src/ResidencyRoll.Api/appsettings.Development.json):
{
"Jwt": {
"Authority": "https://YOUR-TENANT.auth0.com/",
"Audience": "YOUR-API-IDENTIFIER",
"RequireHttpsMetadata": false
}
}git restore if you edit them directly.
- Start both services
- Open
https://localhost:5001 - Click Login button
- Authenticate with Auth0
- You should see your name in the top-right corner
API configuration:
{
"Jwt": {
"Authority": "https://login.microsoftonline.com/{tenant-id}/v2.0",
"Audience": "api://residencyroll-api"
}
}Web configuration:
{
"Authentication": {
"OpenIdConnect": {
"Enabled": true,
"Authority": "https://login.microsoftonline.com/{tenant-id}/v2.0",
"ClientId": "{web-app-client-id}",
"ClientSecret": "{web-app-client-secret}",
"ApiScope": "api://residencyroll-api/.default"
}
}
}API configuration:
{
"Jwt": {
"Authority": "https://keycloak.example.com/realms/{realm-name}",
"Audience": "residencyroll-api"
}
}Web configuration:
{
"Authentication": {
"OpenIdConnect": {
"Enabled": true,
"Authority": "https://keycloak.example.com/realms/{realm-name}",
"ClientId": "residencyroll-web",
"ClientSecret": "{your-client-secret}",
"ApiScope": "residencyroll-api"
}
}
}-
Create
.envfile from the example:cp .env.example .env
-
Configure authentication in
.env:# Enable authentication OIDC_ENABLED=true # Identity Provider Configuration OIDC_AUTHORITY=https://your-tenant.auth0.com/ OIDC_CLIENT_ID=residencyroll-web OIDC_CLIENT_SECRET=your-secret-here OIDC_REQUIRE_HTTPS=true OIDC_API_SCOPE=residencyroll-api # API JWT Configuration JWT_AUTHORITY=https://your-tenant.auth0.com/ JWT_AUDIENCE=residencyroll-api JWT_REQUIRE_HTTPS=true # Update CORS origins for your domain CORS_ORIGIN_0=https://your-domain.com CORS_ORIGIN_1=http://residencyroll-web:80
-
Deploy with Docker Compose:
docker compose up -d
-
Security Checklist:
- Set
OIDC_ENABLED=truein production - Set
JWT_REQUIRE_HTTPS=trueandOIDC_REQUIRE_HTTPS=true - Use HTTPS for all endpoints (configure reverse proxy like nginx or Caddy)
- Store
.envfile securely (never commit it to git) - Configure proper CORS origins (no wildcards)
- Set appropriate token expiration times in your identity provider
- Enable security headers (HSTS, CSP, etc.) via reverse proxy
- Regularly update Docker images:
docker compose pull && docker compose up -d
- Set
When running the API, Swagger UI is available at:
http://localhost:5003/swagger(local development)http://localhost:8080/swagger(Docker)
The API supports versioning (/api/v1/trips) and includes comprehensive OpenAPI documentation.
# Get an access token from your identity provider
TOKEN="your-jwt-token-here"
# Call API endpoint
curl -X GET "http://localhost:8080/api/v1/trips" \
-H "Authorization: Bearer $TOKEN"ResidencyRoll/
├── src/
│ ├── ResidencyRoll.Api/ # API Backend
│ │ ├── Controllers/
│ │ │ └── TripsController.cs # REST endpoints
│ │ ├── Services/
│ │ │ └── TripService.cs # Business logic
│ │ ├── Data/
│ │ │ └── ApplicationDbContext.cs # EF Core DbContext
│ │ ├── Models/
│ │ │ └── Trip.cs # Entity model
│ │ └── Program.cs # API configuration
│ │
│ ├── ResidencyRoll.Web/ # Blazor Frontend
│ │ ├── Components/
│ │ │ ├── Pages/
│ │ │ │ ├── Home.razor # Dashboard
│ │ │ │ ├── ManageTrips.razor # CRUD interface
│ │ │ │ └── Forecast.razor # Trip planning
│ │ │ └── LoginDisplay.razor # Auth UI
│ │ ├── Services/
│ │ │ ├── TripsApiClient.cs # API client
│ │ │ └── ApiAuthenticationHandler.cs
│ │ └── Program.cs # Web app configuration
│ │
│ └── ResidencyRoll.Shared/ # Shared DTOs
│ └── Trips/
│ └── TripDto.cs # Data transfer objects
│
├── tests/
│ └── ResidencyRoll.Tests/ # Unit tests
│
├── Dockerfile # Web container
├── Dockerfile.api # API container
├── docker-compose.yml # Multi-container orchestration
└── README.md # This file
The application implements smart overlap detection:
- If a trip started 370 days ago and ended 350 days ago, only the 15 days within the 365-day window are counted
- The calculation is relative to "Today" and updates automatically
- Days are counted inclusively (both start and end dates included)
- Add Trip: Click "Add Trip" button in the data grid
- Edit Trip: Click the edit icon on any row
- Delete Trip: Click the delete icon on any row
- All changes are immediately persisted to the SQLite database
The application uses a named Docker volume (residencyroll-data) stored in /var/lib/docker/volumes/ to ensure your SQLite database persists across container restarts.
The project uses GitHub Actions to automatically build and push Docker images to GHCR on every push to main.
Using pre-built images:
services:
residencyroll-api:
image: ghcr.io/glenconway/residencyroll-api:latest
# ... configuration
residencyroll-web:
image: ghcr.io/glenconway/residencyroll-web:latest
# ... configurationBoth API and Web containers support versioning:
# Build with specific version
docker build -f Dockerfile.api --build-arg VERSION=1.2.3 -t residencyroll-api:1.2.3 .
# Using git tags in CI/CD
git tag v1.2.3
git push origin v1.2.3 # Triggers versioned buildThe version is logged on startup:
[INF] Starting ResidencyRoll API - Version: 1.2.3
Backup:
docker run --rm -v residencyroll-api-data:/data -v $(pwd):/backup \
alpine tar czf /backup/api-backup.tar.gz -C /data .Restore:
docker run --rm -v residencyroll-api-data:/data -v $(pwd):/backup \
alpine tar xzf /backup/api-backup.tar.gz -C /dataAll configuration can be set via environment variables:
API:
JWT_AUTHORITY- Identity provider URLJWT_AUDIENCE- API identifierJWT_REQUIRE_HTTPS- HTTPS enforcement (true/false)ConnectionStrings__Default- Database connection string
Web:
OIDC_ENABLED- Enable authentication (true/false)OIDC_AUTHORITY- Identity provider URLOIDC_CLIENT_ID- Client identifierOIDC_CLIENT_SECRET- Client secretOIDC_REQUIRE_HTTPS- HTTPS enforcement (true/false)OIDC_API_SCOPE- API scope to requestApi__BaseUrl- API base URL
# View logs
docker compose logs -f
docker compose logs -f residencyroll-api
docker compose logs -f residencyroll-web
# Check container status
docker compose ps
# Access container shell
docker exec -it ResidencyRoll-Api /bin/bash
# Rebuild and restart
docker compose down -v
docker compose up --build# Clean and rebuild
dotnet clean
dotnet restore
dotnet build
# Check .NET 10 SDK is installed
dotnet --version| Issue | Solution |
|---|---|
| 401 Unauthorized from API | Check JWT Authority and Audience match between API and identity provider |
| "IDX10609: Decryption failed" | Auth0 is issuing encrypted tokens (JWE). Configure JWT_CLIENT_SECRET with the same value as OIDC_CLIENT_SECRET. See JWT Token Encryption Guide |
| Login redirect loop | Verify redirect URIs are registered in identity provider |
| Token not forwarded | Check ApiAuthenticationHandler is registered; verify SaveTokens: true in OIDC options |
| CORS errors | Add Web URL to API CORS AllowedOrigins |
| Certificate errors (dev) | Set RequireHttpsMetadata: false in development configuration |
- Docker:
/var/lib/docker/volumes/residencyroll-api-data/_data/residencyroll.db - Local:
./data/residencyroll.db(created automatically)
To reset the database, delete the file or remove the Docker volume:
docker compose down -vMIT