This commit is contained in:
mark committed 2026-09-19 13:10:34 -04:00
1 parent ea1afaf833
commit e0db317146
12 files changed
+444 -56

No files matched your search

+75
View File
@@ -0,0 +1,75 @@
# Infrastructure
The site on Railway: one project, `bible`, with a `production` and a `dev`
environment, and one service, `site`, that runs the repo's `Dockerfile` in
Virginia (`us-east4-eqdc4a`). Railway holds the certificates; `server.py` does
the routing there as it does locally. DNS stays on Cloudflare.
| env | Railway environment | URL |
|------|---------------------|-------------------------------|
| prod | `production` | https://bible-2.aerlinn.net |
| dev | `dev` | https://bible-dev.aerlinn.net |
```
railway/ project, environments, service, custom domains, and their
CNAME + TXT records on Cloudflare
railway-cli/ Railway's CLI as a pinned image (theirs is x86-only)
```
## Tools
Nothing to install but Docker. Terraform and Railway's CLI are services in
`compose.yaml` (profile `tools`), reading tokens from `.env`. From the repo
root:
```
docker compose run --rm terraform -chdir=infra/railway plan
docker compose run --rm railway status
```
Commit `.terraform.lock.hcl`; it pins the provider builds.
## First-time setup
1. **Tokens.** A Railway workspace token (account settings -> Tokens, with the
workspace selected; a project token cannot make projects) and the
Cloudflare token. `cp .env.example .env` and fill both in.
2. **Project and domains.**
```
docker compose run --rm terraform -chdir=infra/railway init
docker compose run --rm terraform -chdir=infra/railway apply
```
3. **Deploy**, below. Railway issues each certificate once it sees the CNAME;
the first HTTPS requests can fail for a minute or two until then.
## Deploying
```
python3 utils/compile_bible.py # only when source/ changes
./deploy.sh dev
./deploy.sh prod
```
`deploy.sh` copies what the Dockerfile needs into `.build/` and uploads that
with `railway up`; Railway builds it and switches over when the new container
is running. Not the repo itself: the CLI skips gitignored paths, which
includes `bible/`, and telling it not to would send `.env` and `source/` too.
The build logs stream to the terminal, and the dashboard has them as well.
Terraform never deploys, and the provider cannot set anything Railway calls
config-as-code (healthchecks, start commands); Railway's defaults do.
## State
Local: `infra/railway/terraform.tfstate`, gitignored. Back it up. If it is
lost, nothing on Railway goes away; `terraform import` each resource back
(IDs from the dashboard URLs; the Cloudflare records as `<zone_id>/<id>`).
## Cost
Hobby plan: $5 a month, which includes $5 of usage. The service idles at a
few tens of MB and next to no CPU in each environment ($10 per GB-month of
memory, $20 per vCPU-month), so both fit in the included usage. Egress is
$0.05/GB, about $0.0001 per new reader (the whole library, ~2.2 MB).
+17
View File
@@ -0,0 +1,17 @@
# Railway's CLI, pinned. Their own image is x86-only; the release binaries are
# static, so this just fetches the one for the platform being built.
FROM alpine:3.22
ARG VERSION=5.57.12
ARG TARGETARCH
RUN case "$TARGETARCH" in \
arm64) triple=aarch64-unknown-linux-musl ;; \
amd64) triple=x86_64-unknown-linux-musl ;; \
*) echo "unsupported: $TARGETARCH" >&2; exit 1 ;; \
esac \
&& wget -qO- "https://github.com/railwayapp/cli/releases/download/v$VERSION/railway-v$VERSION-$triple.tar.gz" \
| tar -xz -C /usr/local/bin railway \
&& railway --version
ENTRYPOINT ["railway"]
+41
View File
@@ -0,0 +1,41 @@
# This file is maintained automatically by "terraform init".
# Manual edits may be lost in future updates.
provider "registry.terraform.io/cloudflare/cloudflare" {
version = "5.25.0"
constraints = "~> 5.25"
hashes = [
"h1:uOE/ctTNSIjD6mEvrKz81eLB7hVfAAa8TsMbDhJlHts=",
"zh:403d477c0e4c05a9f9886ef7e750dcc3488be7cd2210207e98b4a731bc499159",
"zh:4a2a4659c3273ddb1437a34865eac21823a117557b050786ba510ac5f34047cf",
"zh:6e4a2f6ed105b5353c7b56962d00f923873dd68af6509b9b9f595057edba1915",
"zh:aadd2051c1eb01e7b518d4808de7f5192b2ecbc841d70924785ee3385a889b9e",
"zh:ab0322bcb0d4e465f8603b39f7e743c836b22ae775c03ed677ad999a6a1b8bf4",
"zh:c47e38911c92e0b2ffe7be1d36d96b79dd2af79bc61745d6afe5a730cae9f287",
"zh:df3f13ae57104ce2f7619cc6c050ab90a5a3299470cdbd2ab8dd3809b84edcb8",
"zh:f809ab383cca0a5f83072981c64208cbd7fa67e986a86ee02dd2c82333221e32",
"zh:fb7de9d22b02367036c146bd683c8968ec4bbb56aca3185fa84146d118a2bf96",
]
}
provider "registry.terraform.io/terraform-community-providers/railway" {
version = "0.6.2"
constraints = "~> 0.6.2"
hashes = [
"h1:4Az1FULORPb4P5ZtZ6D/tR6PcqE1AY+hCJW+sJuXjxs=",
"zh:2165a426b2e346c2debb05517e3993f87253ddcdf145d94401ea2ab9178d35ab",
"zh:2f3f0f3423564c88ada05238f136034fc35634fc7978efc503b984ba35e83724",
"zh:3bb0618cddccca0d0c690044f7338e4cd103b45114ffbcca8769e864a1b2554b",
"zh:50fd5dc5cf749d8ecd10e5e4ee5cef04b541beb522aab6d4e1845ee812e0c088",
"zh:6a52b2c60121a695151d6a91e61c3fecabc0a40bf75d6b530d828eef0dc0619d",
"zh:7356f98cef110a5ca1a261fddde4b53c74b6a22848bdca642e2094f7708b3626",
"zh:7b47792310e502d0a0b4427133b161114dce2323bc61bf40dc6c4d67858b7629",
"zh:7e8740d239d76a869e0d8eb5dc6c1ec62459603ea6a057333006f8de1c12e2f0",
"zh:890df766e9b839623b1f0437355032a3c006226a6c200cd911e15ee1a9014e9f",
"zh:99d7a179fe16211beb790e19a8f65d570a23ed4f6b752b5bd6d073195fc4cf9d",
"zh:9f31f26d0d93067fca796a718a9b39ebfb1fce4d24e382c215f33e0dde7b3420",
"zh:adb29c332488b29cc483f8cac00a0cc54cacd4cfe5bbd818a55a1c1b4394ebcd",
"zh:d16148e673900550273cbbb12f7dbd7f96ec0cfaa863ec93ec5b108258415637",
"zh:da40431bcd65e85bec7f301611cad5b4b772dae250e9622556072b6a3a7503b6",
]
}
+127
View File
@@ -0,0 +1,127 @@
# The site on Railway: one project, with an environment each for prod and dev,
# and one service that runs the repo's Dockerfile. server.py does the routing,
# as it does locally; Railway holds the certificates.
#
# Terraform makes the project, the service and the domains, and puts their
# records on Cloudflare. It never deploys: deploy.sh uploads the site with
# Railway's CLI. State is local (terraform.tfstate here, gitignored).
terraform {
required_version = ">= 1.10"
required_providers {
railway = { source = "terraform-community-providers/railway", version = "~> 0.6.2" }
cloudflare = { source = "cloudflare/cloudflare", version = "~> 5.25" }
}
}
# Reads RAILWAY_TOKEN (compose.yaml sets it from RAILWAY_API_TOKEN in .env):
# a workspace token.
provider "railway" {}
# Reads CLOUDFLARE_API_TOKEN: DNS edit and zone read, on aerlinn.net only.
provider "cloudflare" {}
locals {
zone = "aerlinn.net"
# Railway Metal, Virginia.
region = "us-east4-eqdc4a"
}
resource "railway_project" "bible" {
name = "bible"
description = "Offline Bible reader"
default_environment = {
name = "production"
}
}
resource "railway_environment" "dev" {
name = "dev"
project_id = railway_project.bible.id
}
resource "railway_service" "site" {
name = "site"
project_id = railway_project.bible.id
regions = [{
region = local.region
num_replicas = 1
}]
# Made after both environments, so it is in both.
depends_on = [railway_environment.dev]
}
locals {
environments = {
production = {
id = railway_project.bible.default_environment.id
domain = "bible-2.${local.zone}"
}
dev = {
id = railway_environment.dev.id
domain = "bible-dev.${local.zone}"
}
}
}
resource "railway_custom_domain" "site" {
for_each = local.environments
domain = each.value.domain
environment_id = each.value.id
service_id = railway_service.site.id
}
data "cloudflare_zone" "site" {
filter = {
name = local.zone
}
}
# DNS only, not proxied: Railway issues the certificate itself and has to see
# the CNAME as it is.
resource "cloudflare_dns_record" "site" {
for_each = railway_custom_domain.site
zone_id = data.cloudflare_zone.site.id
name = each.value.domain
type = "CNAME"
content = each.value.dns_record_value
ttl = 1
proxied = false
comment = "terraform: railway bible ${each.key}"
}
# Railway's proof that the domain is ours.
resource "cloudflare_dns_record" "verification" {
for_each = railway_custom_domain.site
zone_id = data.cloudflare_zone.site.id
name = "${each.value.verification_host_label}.${each.value.zone}"
type = "TXT"
content = "\"${each.value.verification_record_value}\""
ttl = 1
comment = "terraform: railway bible ${each.key} verification"
}
output "project_id" {
value = railway_project.bible.id
}
output "service" {
value = railway_service.site.name
}
output "domains" {
value = {
for name, d in railway_custom_domain.site : name => {
domain = d.domain
host_label = d.host_label
zone = d.zone
target = d.dns_record_value
verification_host = d.verification_host_label
verification = d.verification_record_value
}
}
}