Self-hosted Gitea on a single e2-small AlmaLinux 10 VM in us-east1, serving gitea.jasonmross.dev. Runtime is podman quadlets (systemd .container/.network/.volume units). Both images are built on Debian 13: Gitea from a GPG-verified release binary, and Caddy from an xcaddy build carrying the Google Cloud DNS provider (ACME DNS-01) and the Coraza WAF with the OWASP CRS embedded. Infrastructure is a Pulumi program in Go against a GCS state backend. Cloud Build handles CI: a push trigger for images, one for infra, and a weekly scheduled rebuild. Everything Cloud Build touches is 2nd gen. Notable design decisions, each documented where it lives: - Quadlets track a floating :prod tag. AutoUpdate=registry compares digests for a tag, so a digest-pinned image silently disables auto-updates. - Git transport and LFS bypass the WAF. With the bypass removed, a plain git push returns 403 -- packfiles trip CRS reliably. - gitea:wafMode drives both SecRuleEngine and whether the fail2ban jail acting on WAF verdicts exists. Banning on detections that were never blocks would turn a tuning false positive into an nftables ban. - fail2ban bans at the nftables prerouting hook. Published container ports are DNAT'd and never traverse INPUT, where the stock actions install their rules. - The DNS zone, backup bucket, and Gitea signing secrets are not Pulumi-owned, so pulumi destroy cannot take them with it. - The podman subnet is pinned because it is what Gitea's REVERSE_PROXY_TRUSTED_PROXIES names. Three update layers: dnf5-automatic for the OS, podman-auto-update with health-gated rollback for containers, and a weekly image rebuild that gives the second layer something to pull. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
65 lines
2.1 KiB
Go
65 lines
2.1 KiB
Go
// Package dns manages records in the PRE-EXISTING Cloud DNS zone.
|
|
//
|
|
// The zone itself is deliberately not a Pulumi resource: it already exists and
|
|
// is delegated, so importing it would put a `pulumi destroy` one keystroke away
|
|
// from deleting live DNS.
|
|
package dns
|
|
|
|
import (
|
|
"github.com/pulumi/pulumi-gcp/sdk/v9/go/gcp/compute"
|
|
"github.com/pulumi/pulumi-gcp/sdk/v9/go/gcp/dns"
|
|
"github.com/pulumi/pulumi/sdk/v3/go/pulumi"
|
|
|
|
"gitea-infra/pkg/config"
|
|
)
|
|
|
|
type DNS struct {
|
|
Zone *dns.LookupManagedZoneResult
|
|
Record *dns.RecordSet
|
|
}
|
|
|
|
// New looks up the existing managed zone, points an A record at the static
|
|
// address, and grants the VM service account permission to write ACME challenge
|
|
// records -- scoped to this one zone rather than the whole project.
|
|
func New(
|
|
ctx *pulumi.Context,
|
|
cfg *config.Config,
|
|
addr *compute.Address,
|
|
vmServiceAccountEmail pulumi.StringOutput,
|
|
deps []pulumi.Resource,
|
|
) (*DNS, error) {
|
|
zone, err := dns.LookupManagedZone(ctx, &dns.LookupManagedZoneArgs{
|
|
Name: cfg.DNSZone,
|
|
Project: &cfg.Project,
|
|
}, pulumi.DependsOn(deps))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
rec, err := dns.NewRecordSet(ctx, "gitea-a", &dns.RecordSetArgs{
|
|
Name: pulumi.String(cfg.FQDN()),
|
|
ManagedZone: pulumi.String(zone.Name),
|
|
Type: pulumi.String("A"),
|
|
// Short enough that a VM rebuild behind a new address is not a long
|
|
// outage, long enough not to hammer the resolvers.
|
|
Ttl: pulumi.Int(300),
|
|
Rrdatas: pulumi.StringArray{addr.Address},
|
|
}, pulumi.DependsOn(deps))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Caddy writes and deletes _acme-challenge TXT records here for DNS-01.
|
|
// Zone-scoped rather than project-wide: a compromised VM should not be able
|
|
// to repoint unrelated domains.
|
|
if _, err := dns.NewDnsManagedZoneIamMember(ctx, "gitea-vm-dns-admin", &dns.DnsManagedZoneIamMemberArgs{
|
|
ManagedZone: pulumi.String(zone.Name),
|
|
Role: pulumi.String("roles/dns.admin"),
|
|
Member: pulumi.Sprintf("serviceAccount:%s", vmServiceAccountEmail),
|
|
}, pulumi.DependsOn(deps)); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &DNS{Zone: zone, Record: rec}, nil
|
|
}
|