Files
JMR-devandClaude Opus 5 c0382d5d31 Gitea on GCE: podman quadlets, Pulumi, Cloud Build
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>
2026-08-18 21:45:33 -05:00

105 lines
3.8 KiB
Go

// Package network builds the VPC, its firewall rules, and the static address the
// DNS A record points at.
package network
import (
"github.com/pulumi/pulumi-gcp/sdk/v9/go/gcp/compute"
"github.com/pulumi/pulumi/sdk/v3/go/pulumi"
"gitea-infra/pkg/config"
)
// Tag is applied to the instance and targeted by every firewall rule below.
const Tag = "gitea"
// IAPRange is the fixed source range Google's IAP TCP forwarding connects from.
// It is the only path to port 22 -- there is no other SSH ingress.
const IAPRange = "35.235.240.0/20"
type Network struct {
Network *compute.Network
Subnetwork *compute.Subnetwork
Address *compute.Address
}
func New(ctx *pulumi.Context, cfg *config.Config, deps []pulumi.Resource) (*Network, error) {
opts := pulumi.DependsOn(deps)
net, err := compute.NewNetwork(ctx, "gitea-vpc", &compute.NetworkArgs{
Name: pulumi.String("gitea-vpc"),
// Custom mode: auto mode would create a subnet in every region, which is
// a lot of unused surface for a single VM.
AutoCreateSubnetworks: pulumi.Bool(false),
Description: pulumi.String("Gitea deployment network"),
}, opts)
if err != nil {
return nil, err
}
subnet, err := compute.NewSubnetwork(ctx, "gitea-subnet", &compute.SubnetworkArgs{
Name: pulumi.String("gitea-subnet"),
Network: net.ID(),
IpCidrRange: pulumi.String("10.10.0.0/24"),
Region: pulumi.String(cfg.Region),
PrivateIpGoogleAccess: pulumi.Bool(true),
}, opts)
if err != nil {
return nil, err
}
addr, err := compute.NewAddress(ctx, "gitea-ip", &compute.AddressArgs{
Name: pulumi.String("gitea-ip"),
Region: pulumi.String(cfg.Region),
AddressType: pulumi.String("EXTERNAL"),
Description: pulumi.String("Static address for gitea-vm; the DNS A record depends on it"),
}, opts)
if err != nil {
return nil, err
}
if _, err := compute.NewFirewall(ctx, "gitea-allow-web", &compute.FirewallArgs{
Name: pulumi.String("gitea-allow-web"),
Network: net.ID(),
Allows: compute.FirewallAllowArray{
&compute.FirewallAllowArgs{Protocol: pulumi.String("tcp"), Ports: pulumi.ToStringArray([]string{"80", "443"})},
// HTTP/3.
&compute.FirewallAllowArgs{Protocol: pulumi.String("udp"), Ports: pulumi.ToStringArray([]string{"443"})},
},
SourceRanges: pulumi.ToStringArray([]string{"0.0.0.0/0"}),
TargetTags: pulumi.ToStringArray([]string{Tag}),
// Port 80 exists only for the HTTP->HTTPS redirect. ACME uses DNS-01, so
// certificates never depend on inbound 80 and it can be dropped if wanted.
Description: pulumi.String("Public HTTPS (and HTTP redirect) for Gitea"),
}, opts); err != nil {
return nil, err
}
if _, err := compute.NewFirewall(ctx, "gitea-allow-git-ssh", &compute.FirewallArgs{
Name: pulumi.String("gitea-allow-git-ssh"),
Network: net.ID(),
Allows: compute.FirewallAllowArray{
&compute.FirewallAllowArgs{Protocol: pulumi.String("tcp"), Ports: pulumi.ToStringArray([]string{"2222"})},
},
SourceRanges: pulumi.ToStringArray([]string{"0.0.0.0/0"}),
TargetTags: pulumi.ToStringArray([]string{Tag}),
Description: pulumi.String("git over SSH -- Gitea's built-in server, not host sshd"),
}, opts); err != nil {
return nil, err
}
if _, err := compute.NewFirewall(ctx, "gitea-allow-iap-ssh", &compute.FirewallArgs{
Name: pulumi.String("gitea-allow-iap-ssh"),
Network: net.ID(),
Allows: compute.FirewallAllowArray{
&compute.FirewallAllowArgs{Protocol: pulumi.String("tcp"), Ports: pulumi.ToStringArray([]string{"22"})},
},
SourceRanges: pulumi.ToStringArray([]string{IAPRange}),
TargetTags: pulumi.ToStringArray([]string{Tag}),
Description: pulumi.String("Admin SSH via IAP TCP forwarding only"),
}, opts); err != nil {
return nil, err
}
return &Network{Network: net, Subnetwork: subnet, Address: addr}, nil
}