Troubleshooting

Common issues when setting up and running sfp server.


Server initialization

SymptomCauseFix
Docker registry credentials not found. Please set DOCKER_REGISTRY and DOCKER_REGISTRY_TOKEN.Either DOCKER_REGISTRY or DOCKER_REGISTRY_TOKEN is missing. The CLI validates registry credentials on every init, dev and prod alike, before the tenant .env is written. The server.json sample in Step 1 must include both.Add DOCKER_REGISTRY (e.g. source.flxbl.io) and DOCKER_REGISTRY_TOKEN to secrets.*, or export both as environment variables, then re-run init.
After the failure above, a confusing ENOENT for a .env file that was never writtenOn a fresh install the failure aborts init before .env is created, and the cleanup path then reports the missing file. This only appears on a fresh install (an existing tenant already has a .env).Not an additional defect — solve the registry-credentials error and re-run init.
Tenant '<name>' already existsThe tenant directory already exists.Re-run with --force (🔥 wipes tenant DB + volumes + dir) or use a different tenant name.

General

SymptomCauseFix
denied: denied from the registry loginStale token, wrong registrydocker logout <registry> && echo "<pat>" | docker login <registry> -u <user> --password-stdin
Domain returns NXDOMAINDNS not propagated or A record missingnslookup <domain>; verify A record points at server; allow up to 48h propagation
Need raw container logsCompose project name matches tenantcd /opt/sfp-server/tenants/<tenant> && docker compose -p <tenant> logs -f

TLS / Certificates

SymptomCauseFix
Caddy logs cert file not foundTLS cert not placed or not decodedRe-run init with ORIGIN_CERT/ORIGIN_KEY secrets, or manually place origin.pem + origin-key.pem in {tenantDir}/certs/
Let's Encrypt fails to issue certDNS not publicly resolvable or port 80 blockedVerify dig <domain> returns the server IP from the public internet; open port 80 inbound for the ACME challenge. In cloudflare mode keep the admin subdomain DNS-only.
Certificate format errorCert not PEM-encoded or not base64Certs must be PEM; .crt/.key are typically already PEM — rename and base64-encode: base64 -w 0 origin.pem.

Services

SymptomCauseFix
external volume "…" not foundVolumes not created yetUse sfp server start (not raw docker compose up); it creates external volumes automatically
Server healthy but curl returns 502App server not ready yetWait 30–60s after start; check sfp server logs --service worker; Caddy shows a maintenance page until the backend is up
Supabase containers not startingMissing compose profilesfp server start activates the supabase profile automatically for self-hosted mode; raw docker compose up does not
VaultBootstrapService fetch failedSupabase auth not ready at server bootdocker compose -p <tenant> restart server; auth containers need ~15s to initialize
Update reports Migration container exited with code NThe supabase-migrations one-shot container failed applying migrationsRead docker compose -p <tenant> logs supabase-migrations; update already waits up to 2 minutes and tails 20 lines on failure

SSH

SymptomCauseFix
sfp server init: SSH connection error: Timed out while waiting for handshake then Docker is not installed or not availableTarget port 22 unreachable (firewall, security group, bastion-only network). The "Docker" line is misleading — read the SSH line above it.Open a local tunnel via the bastion: ssh -fNT -L 2222:<target-host>:22 <user>@<bastion>, then sfp server init --ssh-connection <user>@127.0.0.1:2222 --identity-file ~/.ssh/<key>; tear down with pkill -f "ssh -fNT -L 2222".
sfp server init: getaddrinfo EAI_AGAIN <host-alias>--ssh-connection was passed an ~/.ssh/config Host alias. sfp uses Node ssh2, which does not read ~/.ssh/configHost, ProxyJump, IdentityFile, User are all ignored.Pass a literal user@host[:port] plus --identity-file <path>. For ProxyJump-style routing, use the local tunnel recipe above.
sfp server init: Failed to read private key: ENOENT <path>--identity-file path missing or unreadable by the user running sfp.Pass an absolute path: --identity-file /home/<user>/.ssh/<key>. ~/ expands; $HOME does not.
sfp server init: No authentication method providedNeither --identity-file nor --passphrase passed. sfp does not fall back to ssh-agent or ~/.ssh/id_rsa.Always pass --identity-file <path> with --ssh-connection. No --use-agent flag exists.

Proxy / Client IP

SymptomCauseFix
sfp-server logs show wrong client IP (e.g. 127.0.0.1 or a proxy IP instead of the real client)trustProxyHops is too low — sfp-server's Express layer reads X-Forwarded-For only up to the trusted hop countSet TRUST_PROXY_HOPS=3 in .env for a Cloudflare → customer LB → Caddy → app chain (default 2 covers Cloudflare → Caddy → app). Range 010. Restart/recreate the stack.
Rate limiting applies to the wrong addressSame as above — Express-layer IP attribution and rate limits derive from the trusted X-Forwarded-For hopIncrease TRUST_PROXY_HOPS to match the actual proxy-chain depth. All inbound paths must have the same hop count. Note: admin-surface allowlists (ALLOWED_IPS) use remote_ip independently of this setting — see Security best practices.
Monitoring allowlists fail or audit logs attribute actions to a proxy IPtrustProxyHops too low, inconsistent hop counts, or proxies not forwarding headersVerify every proxy preserves X-Forwarded-For/X-Forwarded-Proto/X-Forwarded-Host; then set TRUST_PROXY_HOPS=3 for Cloudflare → customer LB → Caddy → app and restart/recreate the stack. All inbound paths must have the same hop count.

Webhooks

SymptomCauseFix
GitHub webhook delivery times out or reports connection refusedThe webhook URL points to a private, VPN-only, or bastion-only host. GitHub.com cannot use SSH ProxyJump, an operator VPN, or a bastion path.Expose a public HTTPS ingress that forwards privately to the server: public ALB / customer load balancer, managed reverse proxy / tunnel, or API Gateway with VPC Link / private integration. Verify the exact payload URL with GitHub Recent deliveries / Redeliver. See Webhook ingress for private servers.
Firewall rules would require chasing many GitHub source IP rangesGitHub.com webhook delivery IPs change, and source IP is not the authenticity controlDo not make GitHub IP allowlists the primary design. Expose only the public HTTPS ingress, restrict the private backend to the ALB/proxy/tunnel/VPC Link path, and preserve signature validation.
Public ingress exposes the whole sfp UI/API when only webhooks should be publicLoad balancer, tunnel, or API Gateway forwards all paths to CaddyAdd a path/method rule for only POST /sfp/api/repository/webhook; return 403/404 for other public paths unless the full UI/API is intentionally public. Keep the backend private.
Webhook reaches the load balancer but sfp-server sees HTTP or the wrong hostLoad balancer / API Gateway is not preserving forwarded headersForward or set X-Forwarded-Proto: https, X-Forwarded-Host, X-Forwarded-For; then set TRUST_PROXY_HOPS to the trusted-hop count. See Proxy / Client IP.
Webhook through API Gateway returns 503 Service UnavailableVPC Link still provisioning, cannot reach the load balancer listener, or targets an internet-facing load balancerWait until the VPC Link is AVAILABLE; use HTTP API private integration to an internal ALB/NLB listener; allow the VPC Link SG to reach the internal load balancer; verify the internal target group is healthy.
Webhook through API Gateway returns 404 or never reaches the expected routePrivate integration forwards the stage/base path to the backendUse a custom domain/base-path mapping or request-path override so Caddy receives the same path as the payload URL.
Signature validation fails behind a proxy/ALB/API GatewayIngress changed the request body, dropped signature headers, or re-serialized the payloadForward the raw body unchanged and preserve signature/event headers (GitHub: X-Hub-Signature-256, X-GitHub-Event, X-GitHub-Delivery). Remove mapping templates or body rewrites from webhook routes.
GitHub marks delivery failed even though it reached the serverThe ingress or backend waited for workflow execution instead of acknowledging receiptThe webhook must return 2xx quickly. sfp-server dispatches to Hatchet and returns immediately; check ingress timeouts if the request waits for workflow completion.

SELinux (Podman rootless on RHEL / Fedora)

SymptomCauseFix
cannot apply additional memory protection after relocation: Permission denied or RELRO protection failedPodman graphroot has SELinux label user_home_t (non-default home mount)Follow the manual fix in Podman Support → 1.c
restorecon: unable to set context ... Permission denied on overlay diff paths during initRootless UID remapping prevents restorecon from accessing mapped-UID filesExpected and handled — see Podman Support → 1.c

Authentication

SymptomCauseFix
GitHub OAuth callback URL mismatchCallback URL doesn't match domainMust be exactly https://<your-domain>/auth/v1/callback — check the GitHub OAuth App settings
GitHub login fails at github.com with "The redirect_uri is not associated with this application"The redirect_uri GoTrue sent doesn't match the OAuth App's registered callback — usually because AUTH_SUPABASE_EXTERNAL_URL in .env is a host other than your public domain (see below)Set AUTH_SUPABASE_EXTERNAL_URL=https://<your-domain> (or leave it unset), recreate the auth service, and confirm the OAuth App callback is https://<your-domain>/auth/v1/callback

Wrong OAuth redirect_uri domain

Reading GOTRUE_EXTERNAL_GITHUB_REDIRECT_URI from the auth container
Reading the live redirect URI the auth container is using

The redirect_uri GoTrue sends to GitHub/Azure is not built from DOMAIN or GOTRUE_SITE_URL — it is built from AUTH_SUPABASE_EXTERNAL_URL:

GOTRUE_EXTERNAL_GITHUB_REDIRECT_URI: ${AUTH_SUPABASE_EXTERNAL_URL:-https://${DOMAIN}}/auth/v1/callback
GOTRUE_EXTERNAL_AZURE_REDIRECT_URI:  ${AUTH_SUPABASE_EXTERNAL_URL:-https://${DOMAIN}}/auth/v1/callback

If AUTH_SUPABASE_EXTERNAL_URL is set to a different host than the one users reach, GoTrue tells GitHub to redirect to that other host, which the OAuth App doesn't allow. Check the live value:

docker exec <tenant>-supabase-auth-1 printenv GOTRUE_EXTERNAL_GITHUB_REDIRECT_URI
# must be: https://<your-public-domain>/auth/v1/callback

To fix, leave AUTH_SUPABASE_EXTERNAL_URL unset (defaults to https://${DOMAIN}) or set it to your public domain — https://<your-domain> with no trailing /auth/v1 (compose appends /auth/v1). Then recreate the auth service and confirm the GitHub OAuth App callback URL. The same variable drives API_EXTERNAL_URL and the SAML entity ID — see SAML entity ID has the wrong domain.

Cloud Supabase

SymptomCauseFix
init cannot reach Supabase CloudProject IP allowlist or rotated keyVerify allowlist covers the server IP; curl -X GET "<url>/rest/v1/" -H "apikey: <anon>" to probe; check the service key
network is unreachable for IPv6 ([2a05:d014:…])Server has no IPv6 (common on Hetzner)Switch SUPABASE_DB_URL to Session pooler (port 6543) from Supabase Dashboard → Connect
Same as above (alternatives)SameBuy the Supabase IPv4 add-on, or enable host IPv6

Security best practices

AreaAction
Firewall (UFW)sudo ufw allow 22,80,443/tcp && sudo ufw enable — SSH + HTTP redirect + HTTPS only
Host updatesEnable unattended security updates
Secret rotationRotate DOCKER_REGISTRY_TOKEN + Supabase service keys quarterly
Log shippingShip server logs to a central store (don't grep on the box)
Tenant backupsSnapshot <base-dir>/tenants/<tenant>/ — covers .env, generated compose, admin credentials.json
Database backupsSnapshot Supabase (cloud or self-hosted Postgres) on its own schedule — independent of tenant files
Admin dashboardsRestrict the admin surfaces (Hatchet 8080, Studio 3100, registry 4873) to admin IPs via ALLOWED_IPS in .env; in cloudflare mode these move to the DNS-only admin subdomain. Restart the stack after changes.

On this page