Skip to content

Move your vals off Val Town

Every val on one VM, same code, with its SQLite, blobs, and secrets.

Deploy on exe.dev

Shelley handles the setup. About 1 hour.

Prefer a terminal?
curl -fsSL https://exe.dev/deploy/migrate-val-town/prompt.txt | ssh exe.dev new --prompt=/dev/stdin

Val 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.run redirects. 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 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.
What's running, and where
Private https://my-vals.exe.xyz/<val>/ HTTP vals with PUBLIC=1, through the front door; public once you share the VM
Private https://my-vals.exe.xyz:8001/ one HTTP val on its own port (8001, 8002, …), for you only
Public <val>@my-vals.exe.xyz the address of an email val, once you turn on receiving email
my-vals.exe.xyz exe.dev VM
Deno 2.9 Runs each val's code as it ran on Val Town
The val kit Runs triggers, and stands in for std/sqlite, std/blob, std/email, and std/openai ~/vals-kit
Your vals One directory per val, with its code, .env, and data ~/vals/<val>
systemd A service per HTTP val, a timer per cron val, the mail router, and the front door :8000

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 vals
  • VALS: which vals to move, written user/val; leave empty for all of them
Prefer a terminal?
curl -fsSL \
  https://exe.dev/deploy/migrate-val-town/prompt.txt \
  | ssh exe.dev new --prompt=/dev/stdin
What 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.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#

  1. Shelley exports your code with Val Town's API and sets up every val on the VM. Val Town keeps running; nothing there changes.
  2. 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.
  3. The move: you pause the vals on Val Town, Shelley copies the data again, and you redirect *.val.run and 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.ts is 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:

  1. 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.ts running. If your code is already read-only, copy as close to January 10 as you can instead.
  2. 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.ts and the EXPORT_TOKEN variable from each val, and revoke the API token.
  • On the VM, delete ~/.valtown-api-key and every ~/vals/*/.export-token.
  • Keep ~/valtown-export, and the *.old files pull-data.ts leaves, 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.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:

  1. On the VM, make a token: (umask 077; openssl rand -hex 32 > ~/vals/guestbook/.export-token), and show it with cat.
  2. In the val on Val Town, add an environment variable EXPORT_TOKEN with that value, and a new HTTP file named export-data.ts with 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