Building a Self-Resetting Demo Environment with RapidForge
When building developer tools, providing a live demo environment is crucial for potential users to experience your product without commitment. However, demo environments come with unique challenges: preventing abuse, managing data persistence and ensuring a consistent experience for every visitor.
In this post, I'll walk through how we built a self-resetting demo environment for RapidForge using RapidForge itself a perfect example of "eating your own dog food."
The Challenge
We wanted to create a demo environment for RapidForge that:
- Requires no signup - Users should be able to try it immediately with known credentials
- Resets automatically - Data should refresh every 30 minutes to maintain consistency
- Prevents destructive actions - Users shouldn't be able to delete the demo account or break the environment
- Shows clear warnings - Visitors should understand this is temporary and not for production use
The Solution: Demo Mode
We implemented a dedicated "demo" mode in RapidForge that's activated simply by setting RF_ENV=demo. This approach keeps the demo logic cleanly separated from production code without requiring separate environment variables.
Step 1: Adding Demo Mode Detection
First, we added a simple helper method to our configuration:
// filepath: config/config.go
func (c *Config) IsDemoMode() bool {
return c.Env == "demo"
}
This allows us to check config.Get().IsDemoMode() anywhere in the codebase.
Step 2: Creating the Demo User
In demo mode, we always want a user with credentials test/test to exist. We modified our user creation logic to handle this:
// filepath: models/user.go
func (s *Store) CreateAdminUserIfNoUserExists() (*User, error) {
if config.Get().IsDemoMode() {
demoUser := &User{
Username: "test",
PasswordHash: "test",
Role: AdminRole,
}
existingUser, _ := s.GetUserByUsername("test")
if existingUser == nil {
if err := s.InsertUser(demoUser); err != nil {
return nil, err
}
} else {
demoUser.ID = existingUser.ID
if err := s.UpdateUser(demoUser); err != nil {
return nil, err
}
}
return demoUser, nil
}
// Normal mode: create admin user if no users exist
// ... existing code ...
}
This ensures that every time the demo environment starts (or restarts), the test user exists with the correct password.
Step 3: Restricting Destructive Operations
Demo environments need guardrails. We added checks to prevent users from:
- Creating new users
- Updating existing users (including changing passwords)
- Deleting users
- Accessing the terminal (security risk)
Here's an example from the user update handler:
// filepath: handlers.go
func (env *Env) updateUserHandler(c *gin.Context) {
if config.Get().IsDemoMode() {
c.HTML(http.StatusForbidden, "users.html", gin.H{
"alertBox": utils.AlertBox(utils.Error, "User management is disabled in demo mode"),
})
return
}
// ... existing code ...
}
We applied similar guards to createUserHandler, deleteUserHandler, and the terminal handlers.
Step 4: Adding Visual Indicators
Users need to know they're in a demo environment. We added a prominent warning banner on the login page:
<!-- filepath: views/login.html -->
<sl-alert variant="warning" open style="margin-bottom: 2rem;">
<sl-icon slot="icon" name="arrow-repeat"></sl-icon>
<strong>Demo Environment</strong><br>
This environment automatically resets every 30 minutes.<br>
Login with: <strong>test</strong> / <strong>test</strong><br>
<small>⚠️ All changes are temporary and will not persist.</small>
</sl-alert>
Step 5: Deploying to Fly.io
We created a separate Fly.io configuration specifically for the demo environment:
# filepath: fly.demo.toml
app = 'rapidforge-demo'
primary_region = 'lhr'
[env]
RF_ENV = "demo"
RF_PORT = ":8080"
RF_DOMAIN = "rapidforge-demo.fly.dev"
RF_CLOUD = "true"
RF_TERM = "false" # Disable terminal in demo
[http_service]
internal_port = 8080
force_https = true
auto_stop_machines = 'off'
auto_start_machines = true
min_machines_running = 0
[[vm]]
memory = '1gb'
cpu_kind = 'shared'
cpus = 1
Deployment is straightforward:
# Create the app
fly apps create rapidforge-demo
# Create storage volume
fly volumes create rapidforge_demo_data --region lhr --size 1 --app rapidforge-demo
# Deploy
fly deploy --config fly.demo.toml
The Meta Solution: Using RapidForge to Manage RapidForge
Here's where it gets interesting. We needed a way to restart the demo environment every 30 minutes to reset the database. Instead of using external cron services or GitHub Actions, we used RapidForge itself to manage this task.
Creating the Restart Script
We created a bash script that uses the Fly.io API to restart the demo machine:
# we configured FLY_TOKEN via UI credentials
# scree and RapidForge injects it as env variable
FLY_API_TOKEN="${CRED_FLY_TOKEN}"
APP_NAME="rapidforge-demo"
echo "$(date): Starting restart process for $APP_NAME"
MACHINES=$(curl -s -X GET \
-H "Authorization: Bearer $FLY_API_TOKEN" \
"https://api.machines.dev/v1/apps/$APP_NAME/machines")
MACHINE_IDS=$(echo "$MACHINES" | jq -r '.[].id')
for MACHINE_ID in $MACHINE_IDS; do
echo "Restarting machine: $MACHINE_ID"
curl -X POST \
-H "Authorization: Bearer $FLY_API_TOKEN" \
"https://api.machines.dev/v1/apps/$APP_NAME/machines/$MACHINE_ID/restart"
done
echo "$(date): Demo restart completed"
Setting Up the Periodic Task
In our production RapidForge instance, we:
- Stored the Fly.io API token as a credential named
FLY_TOKEN - Created a periodic task with:
- Schedule:
*/30 * * * *(every 30 minutes) - Script: The restart script above
- Script Type: Bash
- Schedule:
Now RapidForge automatically restarts its own demo environment every 30 minutes. No external dependencies, no additional services - just RapidForge managing RapidForge.
Optional: Configure an "On Fail" handler to notify on restart failures
If the restart script itself fails (for example Fly.io API errors or networking issues), you can configure an On Fail script on the periodic task to send a Discord notification so you get alerted immediately.
RapidForge provides these environment variables to the on-fail script: FAILURE_EXIT_CODE, FAILURE_OUTPUT, FAILURE_ERROR, and TASK_ID.
Paste the following into the periodic task's On Fail script field (make sure DISCORD_WEBHOOK is configured in your block credentials):
ERROR_MSG="${FAILURE_ERROR:-$FAILURE_OUTPUT}"
ERROR_TRUNC=$(printf "%s" "$ERROR_MSG" | head -c 1800)
if [ -n "$DISCORD_WEBHOOK" ]; then
curl -X POST -H 'Content-type: application/json' \
--data "{\"content\":\"❌ **Demo Restart Failed**\\n**Task ID:** ${TASK_ID}\\n**Exit Code:** ${FAILURE_EXIT_CODE}\\n\\n\`\`\`${ERROR_TRUNC}\`\`\`\"}" \
"$DISCORD_WEBHOOK"
fi
Key Takeaways
- Environment-based configuration is powerful - Using
RF_ENV=demoinstead of a separate flag keeps things simple - Self-hosting demo management is viable - Using your own product to manage its demo is both practical and validating
- Guard rails are essential - Restricting destructive operations prevents abuse while maintaining functionality
- Clear communication matters - Visual indicators help users understand what they're working with
- SQLite + machine restarts = easy resets - No need for complex snapshot/restore logic
The entire implementation took just a few hours and gives potential users a safe, consistent way to explore RapidForge without any commitment.
Try It Yourself
Want to see it in action? Visit our demo at https://rapidforge-demo.fly.dev/blocks/