Docker

The shortest path to a running instance. One Compose project holds PostgreSQL, the backend and the frontend, with named volumes for everything that must outlive a container.

Prerequisites

  • Docker Engine with the Compose plugin (docker compose version must answer).
  • Two host names pointing at the machine, and a reverse proxy holding the certificate.
  • The four secrets from the shared prerequisites.

The Compose file

This is the shape the project's own deploy/compose/remote.compose.yml uses, reduced to what a first installation needs. Save it as compose.yml.

services:

  front:
    container_name: aetheus-front
    image: aetheus-front:latest
    # Loopback only: the reverse proxy is the sole entry point. Publishing on 0.0.0.0 would
    # expose the app in cleartext, and Docker's iptables rules bypass a host firewall.
    ports:
      - "127.0.0.1:10025:8080"
    environment:
      - API_BASE_URL=https://api.example.com
      - APP_VERSION=1.0.0
    depends_on:
      back:
        condition: service_healthy
    mem_limit: 256m
    restart: unless-stopped

  back:
    container_name: aetheus-back
    image: aetheus-back:latest
    ports:
      - "127.0.0.1:10026:8080"
    environment:
      - ASPNETCORE_ENVIRONMENT=Production
      # The entrypoint owns migration execution, so the application must not run them again.
      - Database__SkipMigrations=true
      - AETHEUS_RUN_MIGRATIONS=true
      - DataProtection__KeyPath=/app/data/dp-keys
      - ConnectionStrings__Default=Host=aetheus-database;Port=5432;Database=aetheus;Username=aetheus;Password=${DB_PASSWORD}
      - Auth__JwtKey=${JWT_KEY}
      - Auth__AdminPassword=${ADMIN_PASSWORD}
      - Auth__EncryptionKey=${ENCRYPTION_KEY}
      - Auth__EncryptionSalt=${ENCRYPTION_SALT}
      - Aetheus__PublicApiBaseUrl=https://api.example.com
      - Cors__Origins__0=https://app.example.com
    volumes:
      - git-repos:/app/data/git-repos
      - dp-keys:/app/data/dp-keys
      - artifacts:/app/data/artifacts
      - packages:/app/data/packages
    depends_on:
      database:
        condition: service_healthy
    healthcheck:
      test: ["CMD", "curl", "-sf", "http://127.0.0.1:8080/health/live"]
      interval: 30s
      timeout: 5s
      retries: 3
      start_period: 120s
    mem_limit: 1g
    networks:
      - default
      - backend
    restart: unless-stopped

  database:
    container_name: aetheus-database
    image: postgres:18-alpine
    environment:
      - POSTGRES_DB=aetheus
      - POSTGRES_USER=aetheus
      - POSTGRES_PASSWORD=${DB_PASSWORD}
      - PGDATA=/var/lib/postgresql/data
    volumes:
      - db-data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U aetheus -d aetheus"]
      interval: 5s
      timeout: 3s
      retries: 10
    # Not on the default network: the database is reachable by the backend and by nothing else.
    networks:
      - backend
    restart: unless-stopped

networks:
  backend:
    driver: bridge

volumes:
  db-data:
  git-repos:
  dp-keys:
  artifacts:
  packages:

The .env file

Compose reads .env from the same directory. Keep it out of version control and readable only by the account that runs Docker.

DB_PASSWORD=...
JWT_KEY=...
ADMIN_PASSWORD=...
ENCRYPTION_KEY=...
ENCRYPTION_SALT=...
chmod 600 .env

Getting the images

Build them from a checkout of the repository, in a POSIX shell at the repository root. Both Dockerfiles expect the repository root as their build context, and neither compiles the application: they copy a publish made on the host into .pipeline-publish/ (Dockerfile.back copies .pipeline-publish/backend, Dockerfile.front copies .pipeline-publish/static-server and .pipeline-publish/frontend/wwwroot). On a fresh clone that directory does not exist and a bare docker build fails at that COPY, so publish first, with the script the project's own pipelines use:

DOTNET="$(sh deploy/scripts/ensure-dotnet-sdk.sh)"
DOTNET="$DOTNET" APP_VERSION=1.0.0 \
    sh deploy/scripts/publish-application.sh .pipeline-publish

ensure-dotnet-sdk.sh prints the path of the .NET SDK whose version global.json declares, and installs that exact version under ~/.aetheus/dotnet when the host does not already resolve it (it needs curl, bash and sha256sum). publish-application.sh installs the wasm-tools workload when it is missing and refuses a frontend published without the WebAssembly optimizer. Then build the images:

SOURCE_COMMIT="$(git rev-parse HEAD)"

docker build \
    --file deploy/docker/Dockerfile.back \
    --build-arg APP_VERSION=1.0.0 \
    --build-arg SOURCE_COMMIT="$SOURCE_COMMIT" \
    --tag aetheus-back:latest .

docker build \
    --file deploy/docker/Dockerfile.front \
    --tag aetheus-front:latest .

The backend image still builds the EF migrations bundle and the two agent packages inside Docker, and stamps the agent release manifest with SOURCE_COMMIT, which must be a full commit hash: without it that stage fails.

The frontend image is a small static server that rewrites wwwroot/appsettings.json at startup from API_BASE_URL. That is why the frontend's API address is an environment variable here, while the binaries route has to edit the file by hand.

First start

docker compose up -d
docker compose logs -f back

On a fresh database the backend entrypoint applies the migrations before the application starts, so the first start takes longer than later ones. That is what the 120-second start_period on the healthcheck accounts for.

When the backend is healthy:

curl -sf http://127.0.0.1:10026/health/ready

Then point your reverse proxy at 127.0.0.1:10025 for the app host and 127.0.0.1:10026 for the API host, and sign in as admin.

The volumes, and why they matter

VolumeHolds
db-dataPostgreSQL. Everything.
git-reposInternally hosted Git repositories, including pipeline definitions.
artifactsBuild artefacts, and therefore the ability to redeploy or roll back.
dp-keysData-protection keys. Losing them invalidates sessions and protected payloads.
packagesPackages served by the internal package registry.

A docker compose down keeps named volumes. A docker compose down -v deletes them, and there is no undo.

Upgrading

  1. Back up the database and the volumes.

    docker compose exec -T database \
        pg_dump -U aetheus aetheus > aetheus-$(date +%Y%m%dT%H%M%SZ).sql
  2. Publish and build the new images as in Getting the images, or pull them, and tag them.

  3. Recreate the services. Migrations run in the backend entrypoint as it comes up.

    docker compose up -d
  4. Confirm readiness before sending traffic back.

    curl -sf http://127.0.0.1:10026/health/ready

Zero-downtime upgrades

The layout above restarts the containers in place, so there is a short outage. The repository also carries a blue-green Compose file, deploy/compose/remote-bluegreen.compose.yml, where two coloured stacks share one database and the reverse proxy switches between them. That is how Aetheus deploys itself, and it is what the pipeline route drives.