Skip to content

Feature Flags

Feature flags let you ship code that’s disabled by default and enable it for specific users or globally — without a redeploy. st8 makes this simple with a single document that your app reads at request time.

Create a feature_flags document in your namespace:

Terminal window
cat > feature_flags.json <<'EOF'
{
"new_checkout": false,
"dark_mode": true,
"ai_search": false,
"max_upload_mb": 10
}
EOF
st8ctl apply -f feature_flags.json \
--namespace myapp/prod \
--message "initial feature flags"
package flags
import (
"context"
"encoding/json"
"sync"
"time"
st8 "github.com/geeper-io/st8/client"
)
// Flags holds the current feature flag values.
// Add new fields here as you add new flags.
type Flags struct {
NewCheckout bool `json:"new_checkout"`
DarkMode bool `json:"dark_mode"`
AISearch bool `json:"ai_search"`
MaxUploadMB int `json:"max_upload_mb"`
}
// Client fetches and caches feature flags from st8.
type Client struct {
st8 st8.Client
scope st8.Scope
mu sync.RWMutex
flags Flags
}
func NewClient(serverURL, token, namespace string) *Client {
return &Client{
st8: st8.NewHTTP(serverURL, st8.WithToken(token), st8.WithTimeout(2*time.Second)),
scope: st8.Scope{Namespace: namespace, Branch: "main"},
flags: Flags{MaxUploadMB: 10}, // safe defaults
}
}
// Current returns the cached flags — no network call.
func (c *Client) Current() Flags {
c.mu.RLock()
defer c.mu.RUnlock()
return c.flags
}
// Refresh fetches the latest flags from st8.
func (c *Client) Refresh(ctx context.Context) error {
result, err := c.st8.Get(ctx, c.scope, 0, "")
if err != nil {
return err
}
raw, ok := result.Objects["feature_flags"]
if !ok {
return nil
}
var f Flags
if err := json.Unmarshal([]byte(raw), &f); err != nil {
return err
}
c.mu.Lock()
c.flags = f
c.mu.Unlock()
return nil
}
// Poll refreshes flags every interval until ctx is cancelled.
func (c *Client) Poll(ctx context.Context, interval time.Duration) {
go func() {
t := time.NewTicker(interval)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
_ = c.Refresh(ctx)
}
}
}()
}
type Server struct {
flags *flags.Client
// ...
}
func (s *Server) Upload(w http.ResponseWriter, r *http.Request) {
f := s.flags.Current() // reads from cache, no network call
maxBytes := int64(f.MaxUploadMB) << 20
r.Body = http.MaxBytesReader(w, r.Body, maxBytes)
// handle upload...
}
func (s *Server) Search(w http.ResponseWriter, r *http.Request) {
f := s.flags.Current()
if f.AISearch {
s.aiSearch(w, r)
} else {
s.legacySearch(w, r)
}
}
Terminal window
# Enable AI search for everyone
cat > feature_flags.json <<'EOF'
{
"new_checkout": false,
"dark_mode": true,
"ai_search": true,
"max_upload_mb": 10
}
EOF
st8ctl apply -f feature_flags.json \
--namespace myapp/prod \
--message "enable ai search"

Your app picks up the change within seconds (based on the poll interval). No deploy, no restart.

The same mechanism works as a kill switch — set enabled: false to immediately disable a misbehaving feature:

Terminal window
st8ctl apply -f feature_flags.json \
--namespace myapp/prod \
--message "kill switch: disable ai search (latency spike)"

where feature_flags.json contains:

{
"new_checkout": false,
"dark_mode": true,
"ai_search": false,
"max_upload_mb": 10
}

Or use rollback if you have a checkpoint:

Terminal window
st8ctl rollback --checkpoint stable --message "rollback: ai search causing latency"

Use namespaces to maintain separate flags per environment:

Terminal window
# Staging gets the flag first
cat > feature_flags.json <<'EOF'
{"ai_search": true}
EOF
st8ctl apply -f feature_flags.json --namespace myapp/staging \
--message "enable ai search in staging"
# After validation, enable in production
st8ctl apply -f feature_flags.json --namespace myapp/prod \
--message "enable ai search in production"
func main() {
flagClient := flags.NewClient("https://st8.internal", os.Getenv("ST8_TOKEN"), "myapp/prod")
// Block on initial load; fall back to defaults if st8 is unreachable
ctx := context.Background()
if err := flagClient.Refresh(ctx); err != nil {
log.Printf("warning: could not load feature flags: %v", err)
}
// Poll every 5 seconds in background
flagClient.Poll(ctx, 5*time.Second)
// ... start server
}