This folder captures the current working host and deployment setup from this server and turns it into a committable bootstrap bundle. The target state is:
- Debian 13 host
tailscaledenabled, SSH reachable only overtailscale0nftablesdefault-drop input policy80/443only reachable from Cloudflare IP rangesfail2banprotectingsshdwith nftables actions- rootful Docker with IPv4 and IPv6 enabled
- restricted deploy user
gha-ssh - app stack under
/home/docker/deploy - Caddy proxy on host networking in front of loopback-only
appandapi
The live host state was used as the source of truth. A stricter staged ForceCommand deploy gateway existed in /etc/ssh/sshd_config.stage.d, but it is not currently active, so this bundle reproduces the working live model instead of switching deploy behavior.
bootstrap-host.sh: host-level provisioning for a fresh Debian 13 machineconfigure-stack.sh: lays down deploy files and starts the app stack once secrets are presenttemplates/: committed config assets installed by the scripts
bootstrap-host.sh:
- installs required packages
- configures the Tailscale apt source
- creates
dockerandgha-ssh - installs a rootful Docker daemon config with IPv6 enabled
- installs sysctl, journald, SSH, fail2ban, nftables, and Cloudflare refresh assets
- enables
tailscaled,nftables,fail2ban,ssh, the Cloudflare refresh timer, and rootful Docker
configure-stack.sh:
- creates
/home/docker/deploy - installs
compose.yaml, Caddy config, and env files - copies example env files into real env paths if they do not exist yet
- refreshes the generated Cloudflare allowlists
- optionally logs in to GHCR and starts the stack
- Copy this folder onto the new Debian 13 host.
- Run
sudo ./bootstrap-host.sh. - Join the host to Tailscale:
sudo tailscale up --ssh - Install the deploy public key into
/home/gha-ssh/.ssh/authorized_keys. - Run
sudo ./configure-stack.sh. - Fill the real env files in
/home/docker/deploy/. - Start the stack:
sudo ./configure-stack.sh --start --pull
These values are required but should not be committed:
/home/gha-ssh/.ssh/authorized_keys/home/docker/deploy/registry.env/home/docker/deploy/images.env/home/docker/deploy/proxy.env/home/docker/deploy/app.env/home/docker/deploy/api.env
Current env keys used by the running stack:
images.envAPP_IMAGEAPI_IMAGE
proxy.envAPP_DOMAINAPI_DOMAINACME_EMAILAPP_UPSTREAMAPI_UPSTREAM
registry.envGHCR_USERNAMEGHCR_TOKEN
app.envNEXT_PUBLIC_API_URLNEXT_PUBLIC_SUPABASE_URLNEXT_PUBLIC_SUPABASE_ANON_KEYNEXT_PUBLIC_SENTRY_DSN
api.envASPNETCORE_ENVIRONMENTDOTNET_ENVIRONMENTConnectionStrings__DatabaseSupabase__UrlSupabase__AnonKeySupabase__JwtIssuerSupabase__JwtAudiences__0Cors__AllowedOriginsCsvSentry__Dsn
This host does not expose public SSH. A GitHub-hosted runner must join Tailscale first, then SSH to gha-ssh.
Required GitHub secrets:
TS_OAUTH_CLIENT_IDTS_OAUTH_SECRETDEPLOY_HOSTDEPLOY_SSH_KEY
Recommended repository variables or secrets:
DEPLOY_USER=gha-sshTS_TAGS=tag:ci
The example workflow is in:
templates/github-actions/deploy.yml.example
The remote execution contract is:
ssh gha-ssh@$DEPLOY_HOST 'sudo -u docker /bin/bash -se --'That keeps root SSH disabled while still allowing CI to run Docker commands and read the deploy env files as the docker user.
Host:
systemctl is-enabled tailscaled nftables fail2ban sshsystemctl is-active tailscaled nftables fail2ban sshsystemctl is-enabled update-cloudflare-nft-sets.timertailscale statusnft list rulesetfail2ban-client status sshd
Docker:
systemctl is-enabled docker.service containerd.service docker.socketsystemctl is-active docker.service containerd.service docker.socketdocker info
Stack:
sudo -u docker docker compose --env-file /home/docker/deploy/images.env -f /home/docker/deploy/compose.yaml psdocker run --rm postgres:16-alpine sh -lc 'PGPASSWORD=<password> pg_isready -h db.<project>.supabase.co -p 5432 -U postgres -d postgres'curl -I https://$APP_DOMAINcurl -X GET -I https://$API_DOMAIN/<known-get-endpoint>
- The firewall trusts Cloudflare source IPs for
80/443and Tailscale only for SSH. - The Docker daemon is configured for IPv6-enabled bridge networking, explicit public DNS resolvers, and IPv6 address pools for Compose-created networks.
- The firewall and Cloudflare refresh flow are written to coexist with Docker's own nftables and iptables-nft rules instead of flushing them away.
- Caddy terminates public TLS directly on the host with
network_mode: host, while theappandapicontainers only publish to127.0.0.1. This avoids edge issues from public Docker port publishing while keeping the upstream services private to the host. APP_UPSTREAMandAPI_UPSTREAMare topology defaults for this host, not secrets. The baseline values are127.0.0.1:3000and127.0.0.1:8080.- Public TLS is managed by Caddy via ACME. Let’s Encrypt is the intended default issuer for this setup. If issuance is temporarily rate-limited during recovery, you can pin
acme_cato ZeroSSL as an operational fallback until Let’s Encrypt becomes available again. - The current live deploy model uses the
gha-sshsudo bridge. If you later want a stricter forced-command SSH gateway, use the staged pattern from/etc/ssh/sshd_config.stage.das a separate hardening step rather than baking it into this baseline. - Official references used while shaping the proxy and CI templates:
- Caddy trusted proxies: https://caddyserver.com/docs/caddyfile/options
- Caddy reverse proxy: https://caddyserver.com/docs/caddyfile/directives/reverse_proxy
- Tailscale GitHub Action: https://tailscale.com/docs/integrations/github/github-action