A free server is not a small server. It is a server whose disk you do not own. The CPU and RAM usually hold up fine for a side project; what breaks first is state — the upload folder, the SQLite file, the cache/ directory, the queue you wrote to /tmp. Deploy at 11pm, wake up to a 500 at 8am, and the logs will tell you the table does not exist.
That failure mode is cheap to prevent and expensive to discover. This post covers a 20-minute audit you can run before the first deploy, a restart test that proves what survives, and a token ledger that tells you which feature is eating your free model allowance. Everything below is stdlib-only and runs on a laptop.
1. Classify your state before you classify your hosting
Every piece of state in a solo project falls into exactly one of four buckets. Decide the bucket first; the hosting choice becomes obvious afterwards.
| Bucket | What it holds | Safe on ephemeral disk? | Typical fix |
|---|---|---|---|
| Recomputable | Derived caches, thumbnails, embeddings you can regenerate | Yes | Leave it, accept a cold rebuild |
| Committed | Config, seed data, small lookup tables | No, but cheap | Put it in the repo, read-only at boot |
| Client-side | Draft text, UI preferences, last-viewed tab | No |
localStorage / URL params |
| Irreplaceable | Uploads, user rows, payment records, job queues | Never | External database or object store |
Most indie projects that die on a free box put bucket four on the local filesystem because it was one line of code. One line in, one lost weekend out.
2. Scan for write sites with ast, not grep
Grep finds open( and drowns you in read-only calls. A tiny AST pass finds calls that write, and it does not need any third-party dependency.
# state_audit.py — heuristic scan for local-write call sites
import ast, pathlib, sys
WRITE_NAMES = {
"write", "writelines", "mkdir", "makedirs", "dump", "save",
"commit", "insert", "upload", "to_csv", "to_parquet", "executemany",
}
SKIP_DIRS = {".git", ".venv", "venv", "node_modules", "__pycache__", "dist", "build"}
def scan_file(path: pathlib.Path):
try:
tree = ast.parse(path.read_text(encoding="utf-8", errors="ignore"))
except SyntaxError:
return
for node in ast.walk(tree):
if not isinstance(node, ast.Call):
continue
func = node.func
name = getattr(func, "attr", None) or getattr(func, "id", None)
if name in WRITE_NAMES:
yield node.lineno, name
if name == "open" and len(node.args) > 1:
mode = node.args[1]
if isinstance(mode, ast.Constant) and isinstance(mode.value, str):
if any(flag in mode.value for flag in "wax+"):
yield node.lineno, f"open(mode={mode.value!r})"
root = pathlib.Path(sys.argv[1] if len(sys.argv) > 1 else ".")
for py in sorted(root.rglob("*.py")):
if SKIP_DIRS & set(py.parts):
continue
for line, what in scan_file(py):
print(f"{py}:{line}: {what}")
python state_audit.py ./app
# app/api/upload.py:41: write
# app/db.py:12: open(mode='w')
# app/jobs/queue.py:88: dump
This is a heuristic, not a proof. It misses ORM flushes, shell redirection, and every JavaScript write in the repo. It is still faster than reading the whole tree, and it produces a list short enough to triage in one sitting. For a Node project, swap the AST pass for grep -rnE "writeFile|createWriteStream|appendFile" plus a manual pass over fs usage.
3. Run the restart round trip, not the redeploy test
A write that survives a redeploy proves nothing if the platform rebuilds from a mounted volume. Test the cheap case first: restart the service without redeploying.
- Add a throwaway route that writes a marker with a timestamp.
-
POSTit once, thenGETit to confirm the write landed. - Restart the service from the provider dashboard, not from your CLI.
-
GETthe marker again.
curl -sX POST https://<your-app>/debug/marker
curl -s https://<your-app>/debug/marker # {"at": "2026-11-05T09:14:02Z"}
# restart from the dashboard, wait for healthy
curl -s https://<your-app>/debug/marker # 200 with the old value: survives restart
# 404 or empty: ephemeral
Read the result narrowly. Surviving a restart does not mean surviving a deploy, and neither result is a durability guarantee. Only a managed volume or external store gives you that. What the test gives you is a decision: if the file is gone after restart, it is bucket four and it must leave the box today.
4. Meter the free model allowance per feature, not per day
Daily totals hide the thing you need to know. At day 30, the question is not "how many tokens did I use" but "which feature will break first when the allowance stops being free". Tag every call with a feature name and append one line per call.
# ledger.py — append-only usage log, one line per model call
import json, pathlib, time
LEDGER = pathlib.Path("usage.jsonl")
def log_call(feature: str, prompt_tokens: int, completion_tokens: int) -> None:
record = {
"ts": int(time.time()),
"feature": feature,
"in": prompt_tokens,
"out": completion_tokens,
}
with LEDGER.open("a", encoding="utf-8") as fh:
fh.write(json.dumps(record) + "\n")
python - <<'PY'
import collections, json, pathlib
spend = collections.Counter()
for line in pathlib.Path("usage.jsonl").read_text().splitlines():
r = json.loads(line)
spend[r["feature"]] += r["in"] + r["out"]
total = sum(spend.values()) or 1
for feature, tokens in spend.most_common():
print(f"{feature:24s} {tokens:>9d} {tokens / total:6.1%}")
PY
Two honest caveats. Token counts must come from the provider response body; if the endpoint does not return usage numbers, estimate with len(text) // 4 and label the output as an estimate — do not mix measured and estimated rows in one table. And log the ledger file somewhere that survives restarts, or you have just built an ephemeral meter for an ephemeral allowance.
This is where the two free options in MonkeyCode become relevant to the workflow rather than to the pitch. The project advertises free model access and a free server option, which is exactly the setup where this pairing earns its keep: the state audit tells you what the free box cannot hold, and the ledger tells you which feature will hit the allowance wall first. Disclosure: This article was prepared as part of MonkeyCode's product outreach. Treat the advertised figures — the operator currently lists a 10M-token free allowance — as a starting budget to verify on the day you plan around it, not as a contract. Free-tier numbers move, and I did not independently verify them for this post.
5. What to do with the audit output
Sort every write site into the four buckets and apply one rule per bucket: recomputable stays, committed moves into the repo, client-side moves to the browser, irreplaceable moves off the box. Do the two bucket-four items before you write any other feature. In practice, one managed database connection string costs less than a single lost upload, and it removes the entire class of "why is my table missing" bugs from a solo project.
Then re-run the audit after every feature, not once at the start. A free server is fine for a demo, fine for an internal tool with three users, and fine for anything that can rebuild itself from the repo on boot. It is the wrong home for a queue, an upload directory, or a SQLite file you care about. If your project needs durable state on day one, skip the audit and buy the database — the twenty minutes are better spent elsewhere.
If you do try the free route, run the restart round trip before the first real user arrives, and keep the usage.jsonl from call number one. The restart result and the first month of the ledger tell you more about the free tier than any benchmark page will.
Top comments (0)