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>
105 lines
3.8 KiB
Go
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
|
|
}
|