Migrating Heroku Backend to Render
5 min read
2
Background
Manage-Node is a recently developed node management system, with the backend using FastAPI + Uvicorn and the database powered by Supabase. The system has been hosted on Heroku, but since Heroku's free tier has been discontinued, the pay-as-you-go model has made the monthly costs increasingly unattractive (I was charged $5.37 in less than a month). Fortunately, Render offers a free plan that is friendly to personal projects, so I decided to migrate the backend to Render.
Why Render
Before the migration, I compared several mainstream cloud platforms and ultimately chose Render. Here is a summary of the comparison:
| Platform | Free Tier | Cold Start | Docker Support | Notes |
|---|---|---|---|---|
| Render | Yes (Web Service) | Yes | Yes | Free tier limitations are acceptable |
| Railway | Limited quota | No | Yes | Additional payment required after quota is used up |
| Fly.io | Yes (Limited) | No | Yes | Configuration is slightly more complex |
| Heroku | No | No | Yes | Free tier discontinued, pay-as-you-go model is expensive |
The main issue with Render's free plan is that it goes to sleep after 15 minutes of inactivity, and the next request requires a cold start, which can take about 30-60 seconds. This issue will be discussed in detail in the following sections.
Migration Process
1. Writing the Dockerfile
Render supports direct deployment using a Dockerfile without the need for additional buildpack configurations. Here is the content of the Dockerfile:
FROM python:3.11-slim
WORKDIR /app
RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/*
COPY backend/requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
RUN chmod +x backend/bin/mihomo backend/bin/xray 2>/dev/null || true
RUN mkdir -p backend/logs
ENV PORT=10000 \
PYTHONPATH=/app \
PYTHONUNBUFFERED=1
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD curl -f http://localhost:${PORT}/health || exit 1
EXPOSE ${PORT}
CMD ["sh", "-c", "cd /app && uvicorn backend.main:app --host 0.0.0.0 --port ${PORT}"]2. Writing the render.yaml
To declare the service configuration, I used a render.yaml file, which makes the configuration clearer and easier to reproduce. Here is the content of the render.yaml:
services:
- type: web
name: manage-node-backend
env: docker
dockerfilePath: ./Dockerfile
plan: free
healthCheckPath: /health
envVars:
- key: SUPABASE_URL
sync: false
- key: SUPABASE_KEY
sync: false
- key: SECRET_KEY
sync: false
- key: SCHEDULER_MODE
value: lightSCHEDULER_MODE=light is a strategy specifically configured for low-resource environments. The free tier only runs core scheduled tasks and does not execute resource-intensive scanning tasks.
3. Fixing Dependencies
During the first deployment, I encountered a common issue:
ModuleNotFoundError: No module named 'loguru'
The reason was that loguru was installed in the local environment through the system environment, but it was not included in the requirements.txt file. The solution was to add loguru to requirements.txt:
loguru>=0.7.2
4. Health Check Path Mismatch
After deployment, the service consistently showed as Unhealthy. Checking the logs revealed that the /api/health path was returning a 404 error.
The issue was that the health check path configured in the Render console was /api/health, but the actual route in the backend was only /health (FastAPI is mounted at the root path, and the /api prefix is a business route prefix, not including the health check).
I made two changes:
render.yaml
healthCheckPath: /health # Changed from /api/healthDockerfile
CMD curl -f http://localhost:${PORT}/health || exit 1Additionally, to maintain compatibility with old configurations or external probes that might still be hitting /api/health, I added a compatible route in the backend:
@app.get("/health")
async def health_check():
return {"status": "healthy", "version": config.APP_VERSION}
@app.get("/api/health")
async def health_check_api():
return {"status": "healthy", "version": config.APP_VERSION}Handling Cold Start Experience
After migrating to Render's free tier, the cold start issue cannot be completely avoided, but it can be mitigated through frontend optimizations to improve the user's waiting experience.
Solution: Show a Hint if Loading Exceeds 6 Seconds
I added useEffect + setTimeout in the global route guard (ProtectedRoute) and the login page. If the loading process exceeds 6 seconds, showWakeHint becomes true, and an Alert is rendered to inform the user:
useEffect(() => {
if (!loading) {
setShowWakeHint(false);
return;
}
const timer = setTimeout(() => setShowWakeHint(true), 6000);
return () => clearTimeout(timer);
}, [loading]);{showWakeHint && (
<Alert severity="info">
The backend might be waking up. The first visit usually takes 30-60 seconds, please wait.
</Alert>
)}Additionally, I modified the timeout error message to be more specific:
const isTimeout = err.code === 'ECONNABORTED';
const message = isTimeout
? 'Request timeout: The backend might be waking up, please try again later'
: (err.response?.data?.detail || 'Login failed');Through these optimizations, users will at least know what the system is doing while they wait, and they won't mistakenly think the system is broken due to a lack of feedback.
Summary
During the entire migration process, I encountered the following main issues:
- Incomplete Dependencies: The system ran fine locally, but missing dependencies in the container caused deployment failures. The solution was to ensure all dependencies are listed in
requirements.txt. - Health Check Path Mismatch: The health check path configured in the Render console did not match the actual path in the backend, causing the service to consistently show as Unhealthy. The solution was to modify the configuration and add a compatible route.
- Cold Start Issue: Cold starts are an inherent limitation of Render's free tier and cannot be completely eliminated. However, by providing frontend hints, the user experience can be significantly improved.
Overall, Render's free tier is sufficient for personal projects, and the cold start issue is acceptable with frontend hints. If traffic increases in the future, it might be worth considering upgrading to the Starter tier to remove the sleep restriction.