Move your vals off Val Town
Every val on one VM, same code, with its SQLite, blobs, and secrets.
Shelley handles the setup. About 1 hour.
Prefer a terminal?
curl -fsSL https://exe-staging.dev/deploy/migrate-val-town/prompt.txt | ssh exe.dev new --prompt=/dev/stdinVal Town is winding down: code goes
read-only on December 10, 2026, and vals stop on January 10, 2027. This
recipe moves your vals to one exe.dev VM without rewriting them. HTTP vals
become services behind exe.dev's HTTPS proxy, cron vals become systemd
timers, and email vals answer mail at <val>@<vm>.exe.xyz. A small kit
stands in for Val Town's standard library, so std/sqlite and std/blob
write to files on the VM, std/email sends through exe.dev's email
gateway, and std/openai goes through exe.dev's LLM gateway, with no API
keys.
Note
Two steps need Val Town before December 10: adding the data export file to your vals (your code is read-only after that), and setting
*.val.runredirects. Copying the data itself can wait until January 9.
Examples move three vals: guestbook, an HTTP val with a Hono app and
std/sqlite; digest, a cron val that emails new Hacker News stories and
remembers them in std/blob; and inbox, an email val that files each
message in SQLite and mails you a one-line summary from std/openai. Use
your own names.
What you get#
- Every val's code exported to
~/valtown-export, with each file's trigger and the names of its environment variables. - HTTP vals running as services that restart on reboot, each on its own private port, and the ones you choose at
https://<vm>.exe.xyz/<val>/. - Cron vals on systemd timers, in UTC, with
lastRunAtcarried from run to run. - Email vals answering mail to
<val>@<vm>.exe.xyz. - std/sqlite, std/blob, std/email, and std/openai working on the VM without code changes.
- A report of each val's schedule, secrets, and data to bring over, and any line still calling Val Town.
What's running, and where
https://my-vals.exe.xyz/<val>/
HTTP vals with PUBLIC=1, through the front door; public once you share the VM
https://my-vals.exe.xyz:8001/
one HTTP val on its own port (8001, 8002, β¦), for you only
<val>@my-vals.exe.xyz
the address of an email val, once you turn on receiving email
.env, and data
Deploy with Shelley#
Click Deploy and Shelley will set up Vals from Val Town on a new VM, then check that it works. No config-file wrangling required.
Have these ready; Shelley asks for them:
VAL_TOWN_API_KEY: a Val Town API token with read access to vals (Settings β API Tokens); only to list and export your vals, and leave it out to move only public valsVALS: which vals to move, writtenuser/val; leave empty for all of them
Prefer a terminal?
curl -fsSL \
https://exe-staging.dev/deploy/migrate-val-town/prompt.txt \
| ssh exe.dev new --prompt=/dev/stdinWhat Shelley is told
VAL_TOWN_API_KEY=<fill-this-in>
VALS=<fill-this-in>
Set up Vals from Val Town on this VM by following the exe.dev recipe at https://exe-staging.dev/deploy/migrate-val-town.md from top to bottom.
The values at the top of this message are mine:
- VAL_TOWN_API_KEY: a Val Town API token with read access to vals (Settings β API Tokens); only to list and export your vals, and leave it out to move only public vals
- VALS: which vals to move, written `user/val`; leave empty for all of them
Ask me for any that still say <fill-this-in> before you need them.
The recipe's example VM name is my-vals. This VM's name is the output of `hostname`: use it wherever the recipe writes my-vals. Skip the step that creates the VM; you are on it.
You are done when all of these are true. Check each one yourself:
- Every val's code exported to `~/valtown-export`, with each file's trigger and the names of its environment variables.
- HTTP vals running as services that restart on reboot, each on its own private port, and the ones you choose at `https://<vm>.exe.xyz/<val>/`.
- Cron vals on systemd timers, in UTC, with `lastRunAt` carried from run to run.
- Email vals answering mail to `<val>@<vm>.exe.xyz`.
- std/sqlite, std/blob, std/email, and std/openai working on the VM without code changes.
- A report of each val's schedule, secrets, and data to bring over, and any line still calling Val Town.
Read the whole recipe, including the closed details, and follow "Move
the vals by hand" in order, skipping VM creation; the walkthrough above
it is for the owner. Install exactly the pinned Deno version.
Put the API key in ~/.valtown-api-key (mode 600) and keep it, the
export token, and every secret value out of your replies and out of
Git. Never change anything on Val Town yourself: adding export-data.ts,
setting variables, pausing vals, and redirects are the owner's steps.
Ask the owner for each cron val's schedule (the API doesn't return it).
Don't run share set-public or share receive-email; give the owner
those commands. Leave vals whose secrets are missing stopped.
If a val uses a Val Town feature this kit doesn't replace, list it in
the report rather than rewriting the val.
Finish with the report from "Check": per val, its triggers, port,
schedule, email address, missing secrets, data still to copy, and
prepare.ts's leftover lines; then the owner's steps in "Make the move".
Finish with the URLs I should open.
Also at /deploy/migrate-val-town/prompt.txt. The recipe it follows is /deploy/migrate-val-town.md.
How the move works#
- Shelley exports your code with Val Town's API and sets up every val on the VM. Val Town keeps running; nothing there changes.
- You add one file to each val that keeps data, and Shelley copies its SQLite tables, blobs, and secrets, then counts both sides. You try the vals on the VM.
- The move: you pause the vals on Val Town, Shelley copies the data
again, and you redirect
*.val.runand update webhooks and email senders.
On the VM, each val gets a directory:
| Path | What it holds |
|---|---|
~/vals/guestbook/ |
The val's files, as on Val Town |
~/vals/guestbook/.env |
Its secrets, and which file runs for which trigger |
~/vals/guestbook/deno.json |
Imports that send std/* to the kit |
~/vals/guestbook/data/ |
sqlite.db and blobs.db, the val's own |
~/vals/.global/ |
The account-wide std/sqlite and std/blob, shared |
| On Val Town | On the VM |
|---|---|
HTTP trigger, *.val.run |
val-http@<val> service on a private port; https://<vm>.exe.xyz/<val>/ with PUBLIC=1 |
| Cron trigger | val-cron@<val>.timer, in UTC |
Email trigger, @valtown.email |
Mail to <val>@<vm>.exe.xyz, through the mail router |
| Environment variables | The val's .env, read by systemd |
std/sqlite |
SQLite file, same @libsql/client API |
std/blob |
SQLite file, same get, set, list, getJSON |
std/email |
exe.dev's email gateway: plain text, to you and people who mail the VM |
std/openai |
exe.dev's LLM gateway, no key |
| Logs | journalctl -u val-http@<val> |
Look around#
When Shelley finishes, open https://my-vals.exe.xyz:8001/ and sign in
to exe.dev: that's your first HTTP val, on its own port. Vals with
PUBLIC=1 are also at https://my-vals.exe.xyz/<val>/, private until
you share the VM. Run a cron val now instead of waiting:
sudo systemctl start val-cron@digest
journalctl -u val-cron@digest -n 20
To try an email val, turn on receiving email, on your computer:
ssh exe.dev share receive-email my-vals on
Then mail inbox@my-vals.exe.xyz and watch journalctl -u vals-mail -f
on the VM. These are your vals with your secrets: anything they do
elsewhere, like posting to Slack, really happens.
Make the move#
First, before December 10:
- In each HTTP val's settings on Val Town, set its redirect to the new
address,
https://my-vals.exe.xyz/<val>or your domain. Once redirected, the val on Val Town stops getting requests, so do this when you're ready. Redirects keep the path and query, and become permanent on December 10. - Make sure
export-data.tsis in every val whose data you still need to copy (see Copy the data).
Then, any time before January 10, for each val with data:
- On Val Town, stop the val writing: once its redirect is set it gets no
HTTP requests; remove its cron and email triggers too. Leave
export-data.tsrunning. If your code is already read-only, copy as close to January 10 as you can instead. - On the VM, stop the val, copy the data again, and start it:
sudo systemctl stop val-http@guestbook
EXPORT_TOKEN=$(cat ~/vals/guestbook/.export-token) \
deno run -A ~/vals-kit/pull-data.ts https://<export-data URL> ~/vals/guestbook
sudo systemctl start val-http@guestbook
Expect pull-data: all counts match. Then, on your computer, open the
front door to everyone and, if you use one, add your domain
(custom domains):
ssh exe.dev share set-public my-vals
ssh exe.dev domain add my-vals app.example.com
Update webhooks (Stripe, GitHub, Slack, Telegramβ¦) to the new URLs, since most don't follow redirects, and give email senders the new addresses.
Day to day#
Add a val: make ~/vals/<name>, write its code and .env, run
prepare.ts, and start its unit as in the nuts and
bolts. Edit code in place, then restart:
sudo systemctl restart val-http@guestbook. Cron and email vals pick up
changes on their next run.
Watch everything at once with journalctl -f -u 'val-*' -u 'vals-*', and
see every timer with systemctl list-timers 'val-cron@*'. Back up
~/vals; the SQLite files are in WAL mode, so copy them with
sqlite3 data/sqlite.db ".backup /path/copy.db" while the val runs.
Clean up#
Once every val has run well on the VM for a while:
- On Val Town, delete
export-data.tsand theEXPORT_TOKENvariable from each val, and revoke the API token. - On the VM, delete
~/.valtown-api-keyand every~/vals/*/.export-token. - Keep
~/valtown-export, and the*.oldfilespull-data.tsleaves, until you're sure.
The nuts and bolts#
These are the steps Shelley follows, in order.
Move the vals by hand
Create the VM#
On your computer:
ssh exe.dev new --name=my-vals
ssh my-vals.exe.xyz
Install Deno and the kit#
On the VM:
curl -fsSL https://deno.land/install.sh | sh -s -- -y v2.9.8
export PATH="$HOME/.deno/bin:$PATH"
mkdir -p ~/vals-kit ~/vals && cd ~/vals-kit
for f in export-vals.ts export-data.ts pull-data.ts pull-env.ts prepare.ts \
imports.json run.ts front.ts mail-router.ts sqlite.ts sqlite-global.ts \
blob-store.ts blob.ts blob-global.ts email.ts openai.ts; do
curl -fsSLO https://exe-staging.dev/deploy/migrate-val-town/$f
done
| File | What it does |
|---|---|
| export-vals.ts | Copies vals' code with Val Town's API, and lists their triggers and variable names |
| prepare.ts | Writes a val's deno.json from imports.json, and lists lines that still use Val Town |
| run.ts | Runs a val's HTTP, cron, or email handler, the way Val Town called it |
| front.ts | The front door on port 8000: /<val>/ and custom domains to vals with PUBLIC=1 |
| mail-router.ts | Hands each email in ~/Maildir/new to the val it's addressed to |
| export-data.ts | Goes in a val on Val Town: read-only, token-protected access to its data |
| pull-data.ts | Copies a val's SQLite and blobs through export-data.ts, and counts both sides |
| pull-env.ts | Copies named environment variables through export-data.ts into .env |
| sqlite.ts, sqlite-global.ts | std/sqlite, on data/sqlite.db and ~/vals/.global/sqlite.db |
| blob.ts, blob-global.ts, blob-store.ts | std/blob, in data/blobs.db and ~/vals/.global/blobs.db |
| email.ts | std/email, through exe.dev's email gateway |
| openai.ts | std/openai, through exe.dev's LLM gateway |
Install the four systemd units every val shares:
sudo tee /etc/systemd/system/val-http@.service >/dev/null <<'EOF'
[Unit]
Description=Val %i (HTTP)
After=network-online.target
[Service]
User=exedev
WorkingDirectory=/home/exedev/vals/%i
EnvironmentFile=/home/exedev/vals/%i/.env
ExecStart=/home/exedev/.deno/bin/deno run --allow-all --config=deno.json /home/exedev/vals-kit/run.ts http
Restart=always
RestartSec=2
[Install]
WantedBy=multi-user.target
EOF
sudo tee /etc/systemd/system/val-cron@.service >/dev/null <<'EOF'
[Unit]
Description=Val %i (cron)
[Service]
Type=oneshot
User=exedev
WorkingDirectory=/home/exedev/vals/%i
EnvironmentFile=/home/exedev/vals/%i/.env
ExecStart=/home/exedev/.deno/bin/deno run --allow-all --config=deno.json /home/exedev/vals-kit/run.ts cron
EOF
sudo tee /etc/systemd/system/vals-mail.service >/dev/null <<'EOF'
[Unit]
Description=Email for vals
After=network-online.target
[Service]
User=exedev
ExecStart=/home/exedev/.deno/bin/deno run --allow-all /home/exedev/vals-kit/mail-router.ts
Restart=always
[Install]
WantedBy=multi-user.target
EOF
sudo tee /etc/systemd/system/vals-front.service >/dev/null <<'EOF'
[Unit]
Description=Front door for vals
After=network-online.target
[Service]
User=exedev
ExecStart=/home/exedev/.deno/bin/deno run --allow-all /home/exedev/vals-kit/front.ts
Restart=always
[Install]
WantedBy=multi-user.target
EOF
sudo systemctl daemon-reload
sudo systemctl enable --now vals-front vals-mail
Export the code#
Save your API key where only you can read it, then export every val, or name the ones you want:
(umask 077; cat > ~/.valtown-api-key) # paste the token, then Ctrl-D
deno run -A ~/vals-kit/export-vals.ts
deno run -A ~/vals-kit/export-vals.ts alice/guestbook alice/digest alice/inbox
Each val's files land in ~/valtown-export/<val>/. The script prints each
trigger, and ~/valtown-export/vals.json keeps them with each val's
variable names:
guestbook: 2 files
http main.ts https://alice--4f2e9c1a.web.val.run
digest: 2 files
cron main.ts
inbox: 2 files
email main.ts alice-7b3d0e52@valtown.email
Val Town's API doesn't return cron schedules: read each one from the val's trigger settings.
Set up each val#
Copy a val into ~/vals, under a lowercase name of letters, digits, -
and _ (it becomes the path and the email address), and point its
std imports at the kit:
cp -r ~/valtown-export/guestbook ~/vals/guestbook
deno run -A ~/vals-kit/prepare.ts ~/vals/guestbook
prepare.ts ends with nothing left that uses Val Town, or lists lines to
look at, such as imports of other vals (esm.town/v/alice/...; copy
those vals in and import them by path) or *.val.run URLs.
Then write the val's .env. These variables tell the kit what to run;
the val's own secrets go below them:
| Variable | Meaning |
|---|---|
VAL_NAME |
The val's name, for the default email subject |
VAL_HTTP |
The file with the HTTP handler |
PORT |
Its private port, from 8001 up, one per HTTP val (3000-9999 are reachable at https://<vm>.exe.xyz:PORT/) |
PUBLIC |
1 to serve it at https://<vm>.exe.xyz/<val>/ |
HOSTS |
Custom domains for it, comma-separated |
VAL_CRON |
The file with the cron handler |
VAL_EMAIL |
The file with the email handler |
One val can have all three triggers. For the examples:
printf 'VAL_NAME=guestbook\nVAL_HTTP=main.ts\nPORT=8001\nPUBLIC=1\n' > ~/vals/guestbook/.env
printf 'VAL_NAME=digest\nVAL_CRON=main.ts\n' > ~/vals/digest/.env
printf 'VAL_NAME=inbox\nVAL_EMAIL=main.ts\n' > ~/vals/inbox/.env
chmod 600 ~/vals/*/.env
HTTP vals start with one command each:
sudo systemctl enable --now val-http@guestbook
sleep 5 # the first start downloads the val's packages
curl -s localhost:8001/
The handler gets a Request whose URL has the public host, as on
val.run. Behind the front door at /guestbook/, paths still start at
/ (the prefix is in the X-Forwarded-Prefix header), which suits APIs
and webhooks; a site with links like href="/about" wants its own
domain in HOSTS.
Cron vals get a timer each. Translate the schedule:
| Val Town cron | OnCalendar= |
|---|---|
*/15 * * * * |
*:0/15 |
0 * * * * |
hourly |
0 9 * * * |
*-*-* 09:00:00 UTC |
30 8 * * 1-5 |
Mon..Fri *-*-* 08:30:00 UTC |
0 0 1 * * |
*-*-01 00:00:00 UTC |
| Every 3 hours (interval) | 0/3:00 |
sudo tee /etc/systemd/system/val-cron@digest.timer >/dev/null <<'EOF'
[Unit]
Description=Val digest (cron */15 * * * *)
[Timer]
OnCalendar=*:0/15
Persistent=true
[Install]
WantedBy=timers.target
EOF
systemd-analyze calendar '*:0/15'
sudo systemctl daemon-reload
sudo systemctl enable --now val-cron@digest.timer
systemd-analyze prints the next run, to check the translation. The
handler gets { lastRunAt }, kept in data/cron-last-run.
Persistent=true catches up a run missed while the VM was off.
Email vals need only their .env. Mail to inbox@<vm>.exe.xyz, or
inbox+anything@, goes to ~/vals/inbox; the handler gets Val Town's
Email object, with attachments as Files. Messages land in
~/Maildir/cur once handled, or ~/Maildir/failed. exe.dev takes
messages up to 1 MB, with no spam filtering. The owner turns receiving on:
ssh exe.dev share receive-email my-vals on.
Copy the data#
A val's SQLite database and blobs can only be read from inside the val, so the owner adds one file to each val that keeps data, in Val Town:
- On the VM, make a token:
(umask 077; openssl rand -hex 32 > ~/vals/guestbook/.export-token), and show it withcat. - In the val on Val Town, add an environment variable
EXPORT_TOKENwith that value, and a new HTTP file namedexport-data.tswith the contents of export-data.ts. Copy the file's URL.
Then copy:
EXPORT_TOKEN=$(cat ~/vals/guestbook/.export-token) \
deno run -A ~/vals-kit/pull-data.ts https://<export-data URL> ~/vals/guestbook
table entries: 1202 rows here, 1202 on Val Town
integrity_check: ok
blobs: 2 (12 bytes)
pull-data: all counts match
Tables, indexes, triggers, views, and AUTOINCREMENT counters come
across with every value exact (64-bit integers, blobs, text with NULs).
Virtual tables such as FTS are skipped and named: rebuild them from
their source table. For the account-wide std/sqlite and std/blob, add
--global and use ~/vals/.global as the directory; any one val's
export-data.ts serves them. Run it again whenever you like: earlier
files are kept as *.old.
Copy the secrets#
vals.json lists each val's variable names. Copy their values through
the same file, without printing them:
EXPORT_TOKEN=$(cat ~/vals/guestbook/.export-token) \
deno run -A ~/vals-kit/pull-env.ts https://<export-data URL> ~/vals/guestbook SLACK_TOKEN GITHUB_TOKEN
It won't copy EXPORT_TOKEN or Val Town's own valtown key. For a val
without export-data.ts, copy values from its settings into .env with
an editor: NAME=value, or NAME='value' with spaces or $. Restart
the val after.
Keys the val only sends to one API can stay off the VM: an exe.dev HTTP proxy integration adds the key on the way out. On your computer:
ssh exe.dev integrations add http-proxy --name github \
--target https://api.github.com --bearer "$GITHUB_TOKEN" --attach vm:my-vals
Then change the val to call https://github.int.exe.xyz/... with no key.
Check#
systemctl --no-pager is-active vals-front vals-mail 'val-http@*'
systemctl list-timers 'val-cron@*' --no-pager
for d in ~/vals/*/; do deno run -A ~/vals-kit/prepare.ts "$d" | tail -1; done
For the report, per val: its triggers, port and address, schedule and
next run, email address, secrets named in vals.json but not in .env,
whether its data is copied, and what prepare.ts still lists.
Troubleshooting
has no default export. Val Town runs a file's default export, or its
only export. Add export default to the handler.
Email or Interval is not defined. Those are type names from Val
Town's editor; deno run doesn't type-check, so this only comes up in
deno check. Declare them in the file or drop the annotation.
An email val gets nothing. Run ssh exe.dev share receive-email my-vals on again, check that the val's directory name matches the
address, and read journalctl -u vals-mail. exe.dev stops delivering if ~/Maildir/new
passes 1,000 messages, so keep vals-mail running.
exe.dev email error. The gateway only sends to you, your team,
people you share the VM with, and people who've emailed it. HTML is
turned into plain text; from and custom headers are dropped.
std/openai says the model doesn't exist. Use a model the LLM
gateway offers.
pull-data says MISMATCH. Something wrote during the copy: pause the
val on Val Town and run it again.
Frequently asked questions#
When does Val Town shut down?
New signups ended on November 10, 2026. On December 10 your code goes read-only, though vals keep running and SQLite and blobs stay writable. On January 10, 2027 everything else stops. Val Town plans to open-source itself on February 1, 2027. See the announcement.
Do I have to rewrite my vals?
Usually not. They're Deno modules, and Deno runs them on the VM. A deno.json beside each val sends its esm.town/v/std/... imports to the kit, so sqlite, blob, email, and OpenAI keep their methods. prepare.ts lists anything left, such as vals importing other vals by URL, which stop working on January 10.
Isn't Deno winding down too?
Its team joined Cloudflare and promises a year of security and bug-fix releases, to about October 2027. A Deno binary on your VM keeps working after that, but plan to port vals to Node or Bun within the year; the kit's files are small and the ports are mostly mechanical.
How many vals fit on one VM?
Dozens. An idle HTTP val is one Deno process of about 60 MB; cron and email vals use memory only while they run. All of them share your VM plan.
What about my *.val.run links and webhooks?
Before December 10, set a redirect for each HTTP val in Val Town (val settings, or Custom Domains for the account) to its new address; on December 10 they become permanent. Browsers and links follow them; most webhook senders don't, so update those to the new URL directly. Email to @valtown.email addresses can't be forwarded: tell senders the new address.
Can I use my own domain?
Yes. Point a CNAME at my-vals.exe.xyz, run ssh exe.dev domain add my-vals app.example.com, and add the domain to the val's HOSTS. Email still goes to @my-vals.exe.xyz addresses: exe.dev doesn't receive mail for custom domains.
What about smallweb or Windmill?
smallweb runs a folder of Deno apps, one subdomain each, close to how Val Town feels; on exe.dev it needs a wildcard custom domain (*.example.com). Windmill suits vals that are really workflows. This recipe keeps your code as it is and needs neither.
Updated Nov 13, 2026 · Markdown · manifest.json · Something wrong? Tell us