Skip to content

Deployment

Overview

CustomsHive ships as a container image. A tag on docker-X.Y.Z builds the image, publishes it to the container registry, and the environment runs it at a pinned tag — nothing auto-updates.

flowchart TD
    dev(["git tag docker-3.2.0<br/>git push --tags"])
    dev --> ci

    subgraph ci["Forgejo Actions"]
        build["dotnet publish"] --> img["docker buildx<br/>linux/amd64 + linux/arm64"]
        img --> push["push to ghcr.io"]
    end

    push --> reg["ghcr.io/rousseauxy/customshive:3.2.0"]
    reg --> deploy

    subgraph deploy["Container host"]
        compose["compose pins the image tag"] --> app["customshive container"]
        app --> db["SQL Server container"]
    end

The build runs on a self-hosted runner, which is also what gives the job a Docker daemon to build with. Images are multi-architecture, so the same tag runs on both x86 and arm64 hosts.

Deployment itself is deliberately manual: the compose file names an exact version, so upgrading is a one-line change and a docker compose up -d. There is no watcher that pulls new versions on its own.


Releasing

  1. Bump the version and commit.
  2. Tag docker-X.Y.Z and push the tag.
  3. The workflow builds and publishes ghcr.io/rousseauxy/customshive:X.Y.Z.
  4. Update the image tag in the environment's compose file and recreate the container.

The image is tagged from the git tag (docker-3.2.03.2.0, 3.2, 3), so an environment can pin as loosely or as tightly as it wants. Pin the full version unless you have a reason not to.


History: IIS and Azure DevOps

Earlier versions deployed to IIS via an Azure DevOps pipeline, with a self-hosted agent extracting a zip and switching the site's physical path, and a gMSA app-pool identity. All of it is gone — the pipeline trigger went in August 2026 and the workflow, the Deploy-IIS.ps1 script and the setup instructions were deleted in September 2026 (B-16). Nothing has run on IIS since the move to containers; the instructions were kept "just in case" long enough to start reading as a second supported path.


dv — Docker / NAS deployment

This section applies to the dv (development / self-hosted) environment only. It uses GitHub Container Registry and Docker — no IIS, no ADO, no Windows required.

How it works

Every tag matching docker-* triggers .github/workflows/docker.yml, which builds a multi-stage .NET 10 Docker image and pushes it to ghcr.io:

git tag docker-0.6.0
git push origin docker-0.6.0
  -> GitHub Actions builds image
  -> pushes ghcr.io/rousseauxy/customshive:latest
  -> pushes ghcr.io/rousseauxy/customshive:<version>
  -> pushes ghcr.io/rousseauxy/customshive:<major>.<minor>

The NAS either pulls manually or uses Watchtower to pick up the new image automatically.

Cost: GitHub Actions free tier provides 2,000 minutes/month. A full .NET build takes ~2-4 minutes; layer caching (type=gha) reduces repeat builds to ~30-60 s. Normal commit frequency stays well within the free allowance.

Persistent volumes

Container path What goes there
/app/uploads Uploaded PDFs / XLSXes and temp files

Mount /app/uploads so files survive image updates.

One-time NAS setup

1. Authenticate to ghcr.io (required for private repos)

Create a GitHub PAT with the read:packages scope, then on the NAS:

docker login ghcr.io -u rousseauxy -p <YOUR_PAT>

2. Create persistent directories

mkdir -p /volume1/docker/customstuf/uploads

3. Create a .env file (never commit this to git)

# /volume1/docker/customstuf/.env
MSSQL_SA_PASSWORD=your-sql-password
AZURE_TENANT_ID=your-tenant-id
AZURE_CLIENT_ID=your-client-id
AZURE_CLIENT_SECRET=your-client-secret

4. Create the compose file

# /volume1/docker/customstuf/docker-compose.dv.yml
services:
  customstuf:
    image: ghcr.io/rousseauxy/customshive:latest
    restart: unless-stopped
    ports:
      - "8080:8080"
    volumes:
      - /volume1/docker/customstuf/uploads:/app/uploads
    environment:
      ASPNETCORE_ENVIRONMENT: Production
      Auth__Provider: EntraId
      ConnectionStrings__DefaultConnection: Server=your-sql-host,1433;Database=customstuf;User Id=sa;Password=${MSSQL_SA_PASSWORD};TrustServerCertificate=true
      ConnectionStrings__TarbelConnection: Server=your-sql-host,1433;Database=tarbel;User Id=sa;Password=${MSSQL_SA_PASSWORD};TrustServerCertificate=true
      AI__Provider: AzureOpenAI
      AI__AzureOpenAI__Endpoint: https://your-resource.openai.azure.com/
      AI__AzureOpenAI__DeploymentName: gpt-4o
      AI__AzureOpenAI__UseKeylessAuth: "true"
      DocumentIntelligence__Endpoint: https://your-resource.cognitiveservices.azure.com/
      DocumentIntelligence__UseKeylessAuth: "true"
      Descartes__Smf__ValidateXsd: "true"
      Descartes__Smf__XsdFolder: /app/schemas/smf
      Azure__TenantId: ${AZURE_TENANT_ID}
      Azure__ClientId: ${AZURE_CLIENT_ID}
      Azure__ClientSecret: ${AZURE_CLIENT_SECRET}

/app/schemas/smf is bundled in the image during publish and contains SMF.xsd and included schema files.

5. Start

cd /volume1/docker/customstuf
docker compose -f docker-compose.dv.yml up -d

Updating manually

docker compose -f docker-compose.dv.yml pull
docker compose -f docker-compose.dv.yml up -d

Auto-updating with Watchtower (optional)

Watchtower polls ghcr.io every 5 minutes and restarts the container when a new latest is published:

docker run -d \
  --name watchtower \
  --restart unless-stopped \
  -v /var/run/docker.sock:/var/run/docker.sock \
  -e WATCHTOWER_CLEANUP=true \
  containrrr/watchtower \
  --interval 300 \
  customstuf

For private registries, also pass:

  -e REPO_USER=rousseauxy \
  -e REPO_PASS=<YOUR_PAT>

Configuration reference

All settings follow ASP.NET Core's Section__Key env-var convention.

Variable Default in image Description
ASPNETCORE_ENVIRONMENT Production Environment name (Development loads appsettings.Development.json)
Descartes__Smf__ValidateXsd true Enables SMF XSD validation in the wrapper service
Descartes__Smf__XsdFolder /app/schemas/smf Folder containing SMF.xsd and included XSDs
ASPNETCORE_URLS http://+:8080 Listening address inside the container
ConnectionStrings__DefaultConnection (empty) Required SQL Server connection string for main app DB
ConnectionStrings__TarbelConnection (empty) Required SQL Server connection string for Tarbel DB
Storage__UploadFolder /app/uploads Upload and temp file root
AI__Provider AzureOpenAI AzureOpenAI or OpenAI
AI__AzureOpenAI__Endpoint (empty) Azure OpenAI resource endpoint URL
AI__AzureOpenAI__DeploymentName (empty) Deployment name (e.g. gpt-4o)
AI__AzureOpenAI__UseKeylessAuth true true = use Azure__* credentials; false = use ApiKey
AI__AzureOpenAI__ApiKey (empty) Required when UseKeylessAuth is false
AI__OpenAI__BaseUrl (empty) Optional OpenAI-compatible endpoint when using OpenAI provider
AI__OpenAI__ApiKey (empty) OpenAI API key when using OpenAI provider
AI__OpenAI__Model gpt-4o-mini Model name for OpenAI provider
DocumentIntelligence__Endpoint (empty) Azure Document Intelligence resource endpoint URL
DocumentIntelligence__UseKeylessAuth true true = use Azure__* credentials; false = use ApiKey
DocumentIntelligence__ApiKey (empty) Required when UseKeylessAuth is false
DocumentIntelligence__ModelId prebuilt-layout Document model for OCR analysis
Azure__TenantId (empty) App registration tenant ID (shared by OpenAI + DI keyless auth)
Azure__ClientId (empty) App registration client ID
Azure__ClientSecret (empty) App registration client secret