# ADR-004: Deployment strategy: quick push now, true implementation later

Date: 2026-08-31
Status: Accepted

## In plain words

This page records how the site got hosted when the free trial refused the planned setup. The smallest hosting that would accept the container went first, and the full production design stays as settings in one template file (Bicep) that can be switched on later.

What that is worth: a developer sees each refusal measured and worked around without rewriting the app, and the organization can change hosting by changing a subscription and a few parameters, because the same container image runs anywhere.

## Context

The author's framing, day one: the plan is a quick push first and the true
implementation second, and Docker was added now precisely so the hosting can
change without the application changing. The container image is the
invariant: the same theyard:v1 runs identically on localhost, Container Apps,
or App Service, so hosting is a subscription decision rather than an
engineering one.

## What the first deployment day measured (free-trial subscription)

1. Azure Front Door: refused outright. Verbatim: "Free Trial and Student
   account is forbidden for Azure Frontdoor resources."
2. App Service compute: quota of zero VMs at every tier tried (B1 and F1) in
   two regions ("SubscriptionIsOverQuotaForSku, Current Limit (Total VMs): 0").
3. Container Apps: the environment provisions, but revision provisioning died
   with "Operation expired" across three environment attempts in two regions
   (eastus2 twice, westus2 once), warm and cold, using Microsoft's own
   hello-world image. Config was ruled out; the subscription class is the
   remaining variable.

## Decision

Phase the delivery and parameterize the phases instead of rewriting:

- Phase 1, quick push: the smallest hosting that accepts the container image,
  currently blocked by the trial subscription itself.
- Phase 2, true implementation: the ADR-001 target, App Service B1 behind
  Front Door with the origin locked to the Front Door ID header.

infra/main.bicep carries computeKind, skuName, enableFrontDoor, and
minReplicas so each phase is a parameter set, not a branch or a rewrite.

## Consequences

The repo, image, tests, CI, and infrastructure-as-code are complete and
portable today; going live is gated on one account decision (upgrade to
pay-as-you-go, or wait). Every wall above was measured, recorded, and
parameterized around the same day it was hit, which is the honest story this
repository tells an interviewer.
## Addendum: phase 1 realized on Container Instances

With Front Door and App Service off the table and Container Apps expiring
revisions, phase 1 ships on Azure Container Instances: one container group,
public FQDN, plain HTTP on port 8080, pulled from ACR with a user-assigned
identity. No TLS and no edge is acceptable for a demo artifact and is exactly
the gap phase 2 closes.

## Addendum, 2026-09-01: the custom domain and the registrar wall

Goal of the day: serve this app at https://theyard.stevenstout.biz with a valid
certificate, changing nothing else about the build.

What we hit. The domain stevenstout.biz was bought at Wix. Wix, as a registrar,
does not allow changing a domain's nameservers at all; their help center says so
and the dashboard's NS section is read-only. That closed the original plan
(Cloudflare's free tier in front, which requires pointing nameservers at
Cloudflare). The ICANN 60-day lock closes the other exit, a registrar transfer,
until about 2026-10-30. Neither wall is the trial's fault; one is Wix product
policy and the other is ICANN policy on new registrations.

What we did instead. Same architecture shape as ADR-001, an edge terminating TLS
in front of the origin, but the phase-1 edge is Netlify's free tier, because it
attaches to a plain CNAME record, which Wix does allow. The entire edge is three
files in this repository: netlify.toml, edge/_redirects (one proxy rule to the
ACI origin on 8080), and edge/README.md. Netlify deploys them from GitHub on
every push. The application never moved; Azure serves every request.

The small choices, so they are not re-litigated later:
- DNS TTLs are 30 minutes (Wix's shortest) during cutover so future changes
  propagate fast; lengthen once the setup is boring.
- A TXT record named subdomain-owner-verification proves domain ownership to
  Netlify for a subdomain of a domain the Netlify account does not own.
- The Netlify project is public on purpose; the platform's private-by-default
  would ask every visitor to log in.
- The Cloudflare zone for stevenstout.biz is fully configured and dormant:
  Flexible SSL, proxied CNAME to the origin, a port-8080 origin rule, and a
  root-and-www redirect. Transferring the domain to Cloudflare once the ICANN
  lock expires activates all of it and retires Netlify with a single DNS change.
- The certificate is Let's Encrypt via Netlify, auto-renewing. Nothing was
  purchased, and no secret lives anywhere in this setup.

Phase 2 is unchanged by any of this: upgrade the subscription, deploy App
Service plus Front Door with the origin lock by flipping the Bicep parameters,
and point the same subdomain at it. The resume URL never changes. That
permanence is the entire point of the domain layer.

## Decision update, later on 2026-09-01

The paid phase-2 deployment is declined, deliberately and indefinitely, for
this demo. The free stack, the ACI origin behind a free TLS edge, is the
permanent hosting for TheYard as a portfolio piece. infra/main.bicep remains
the documented production design, served inside the app under Hosting,
Infrastructure (Bicep), so the target architecture stays reviewable without
running a bill. The same day, the bare domain and www were pointed at the edge
and 301-forwarded to the primary URL, so a trimmed or retyped address still
lands on the app. The resume distributes theyard.stevenstout.biz; the forward
is the safety net.

## Completion note, later still on 2026-09-01

The bare-domain forward finished the same afternoon. One Let's Encrypt
certificate now covers stevenstout.biz, www.stevenstout.biz, and
theyard.stevenstout.biz, and every variant of the bare and www names
redirects to the app with a valid padlock.

The delay taught one operational lesson worth keeping: certificate issuance
validates every attached name, and resolvers that cached the old DNS records
keep serving them until their TTL runs out. A renewal attempted inside that
cache window fails and tells you nothing. Wait out the longest old TTL,
renew once, and it works on the first try.

## Addendum, 2026-09-20: phase 2's compute arrived, in the region that would take it

Superseded in part on 2026-09-20, as 1.0.0.156. Two things above stopped being true that day. The
decision update of 1 September called the container group "the permanent hosting"; both sites now
run as web apps on one App Service plan, which is this record's phase 2 without Front Door. And
the first day's measurement, App Service refused at a quota of zero, turned out to be a fact about
regions: asked again at four sizes in seven regions, West US 2 and four others still answer zero
and West US 3 and Central US validate every size. The plan is in West US 3, beside the relational
server, which West US 2 had also refused.

Front Door is still refused, so the edge is still Netlify and the origin is still open to the
internet, now over HTTPS. The container groups are stopped and kept for a week as the way back.
What was priced, what was measured on the plan, and why it is B1 are in
ADR: One plan, two sites.

With 1.0.0.157 the template followed. `infra/main.bicep` carried `computeKind` so that each phase
was a parameter set, and two of its three branches described platforms nothing runs on: Container
Instances, stopped that day, and Container Apps, which never got a revision to start. Both branches
are removed, and the file is the plan, the two sites, and Front Door behind a parameter that is off.

## Where it sits

No ring: this is a deployment record about which Azure service runs the container and how the phases were sequenced around the trial's limits, so the onion and SOLID do not apply.

## Files

- [`infra/aci-theyard.yaml`](https://github.com/SteveStout/TheYard/blob/main/infra/aci-theyard.yaml): what runs, the container group template
  every deploy renders; the container itself is shown live below.
- [`infra/main.bicep`](https://github.com/SteveStout/TheYard/blob/main/infra/main.bicep): what would run after an upgrade, the App
  Service and Front Door target of ADR: Front Door origin, deliberately
  undeployed.
- [`.github/workflows/deploy.yml`](https://github.com/SteveStout/TheYard/blob/main/.github/workflows/deploy.yml): the roll, `az container create`
  from the rendered template (ADR: The deploy pipeline).
- [`docs/hosting/HOSTING.md`](https://github.com/SteveStout/TheYard/blob/main/docs/hosting/HOSTING.md): the chain a request follows today.

```yaml
# The image line is a placeholder the pipeline rewrites per roll; everything
# else is the shape of the container group. This file is the rollback path
# too: `az container create -f` with it restores a known-good group.
containers:
- name: theyard
  properties:
    configMap:
      keyValuePairs: {}
    environmentVariables:
    # Filled in at roll time by the deploy workflow, which reads the value
    # from Azure with the credential it already has (ADR-024). The literal
    # placeholder below is what a manual rollback would set, and the app
    # treats anything starting with __ as "no telemetry", so a rollback runs
    # clean rather than sending to nowhere.
    - name: APPLICATIONINSIGHTS_CONNECTION_STRING
      secureValue: __APPINSIGHTS_CONNECTION_STRING__
    # Azure SQL Database, substituted the same way (ADR: The SQL Server
    # backend). What lands here is a server, a database and
    # `Authentication=Active Directory Managed Identity`: no password, because
    # the server has no SQL login to have one. It is still written as a
    # secureValue, because the setting is where a secret would go if the
    # authentication model ever changed, and a placeholder left behind by a
    # failed substitution means the container falls back to SQLite rather than
    # crash-looping.
    - name: ConnectionStrings__YardSql
      secureValue: __YARD_SQL_CONNECTION__
    # The session signing key, filled at roll time from the repository secret
    # of the same name, so both containers sign with one key and a roll does
    # not end every session (ADR: Three readers with no memory of the
    # project). The only secret in the pipeline, and it never appears in a
    # log: GitHub masks it, and the roll substitutes it in python. A
    # placeholder left by a missing secret reads as "no key": the process
    # invents one at startup and logs that it did, which was every roll's
    # behaviour until 1.0.0.111.
    - name: Auth__SigningKey
      secureValue: __YARD_AUTH_SIGNING_KEY__
    # The operator's key for the Admin tab's visitor rows, filled at roll
    # time from the repository secret ADMIN_KEY the same way. A placeholder
    # left by a missing secret reads as "no key", and the endpoint the key
    # guards answers 404 to everybody until there is one (ADR: Site
    # activity, and the line an address does not cross).
    - name: Admin__Key
      secureValue: __ADMIN_KEY__
    # Azure Cosmos DB, beside the SQL setting rather than instead of it: this
    # container runs both stores and a visitor picks one with the toggle at
    # the top of the page (ADR: One container, both stores). An endpoint is a
    # URL, the database is a name, and the credential is the container's own
    # managed identity, so all three are plain values: there is no key on the
    # account to have leaked. The default names the store a request gets when
    # it names none, which on the live site is the relational one.
    - name: Cosmos__AccountEndpoint
      value: https://cosmos-theyard-ss.documents.azure.com:443/
    - name: Cosmos__Database
      value: theyard
    - name: Cosmos__Credential
      value: managed-identity
    - name: Store__Default
      value: sql
    # The one email this site sends, the password reset link, through Azure
    # Communication Services as the containers' own identity: an endpoint
    # and a sender address on its Azure-managed domain, both plain values,
    # no key (ADR: Accounts and per-user bids, addendum). Unset, the
    # "Forgot password" button says so and the operator's link is the way.
    - name: Email__Endpoint
      value: https://acs-theyard-ss.unitedstates.communication.azure.com
    - name: Email__From
      value: DoNotReply@ae4d5c47-28c9-49e2-8d3a-a859abd0f7df.azurecomm.net
    # Warm the other store right after the default one is serving, so the
    # first visitor to toggle does not pay for its catalogue. Off outside a
    # deploy, where ten test applications at once would each warm two.
    - name: Store__WarmOthers
      value: "true"
    # The other container, for the Admin tab's comparison card (ADR: Backends,
    # side by side). A public origin, read server side with a short patience,
    # and a plain value because a URL is not a secret.
    - name: Peer__Url
      value: http://theyard-cosmos-ss-zmnetj67bn5h2.westus2.azurecontainer.io:8080
    # The same container as a visitor reaches it, for the Store bar's link
    # to the other site: its own name under the domain, behind the same edge
    # since 1.0.0.100 (ADR: A permanent address for the second site).
    - name: Peer__Site
      value: https://theyard-cosmos.stevenstout.biz
    # This site as a visitor reaches it, for the links the site writes into an
    # email: behind the edge the request's own host is the origin, which is
    # not an address anybody should be sent (ADR: Accounts and per-user
    # bids, addendum of 14 September).
    - name: Site__Url
      value: https://theyard.stevenstout.biz
    image: __IMAGE__
    ports:
    - port: 8080
    resources:
      requests:
        cpu: 1.0
        memoryInGB: 1.5
```
*Live from [`infra/aci-theyard.yaml`](https://github.com/SteveStout/TheYard/blob/1d6b50b/infra/aci-theyard.yaml#L20-L114), region `container`, read from this build at 1d6b50b.*
