Building an In-Browser IDE the Hard Way

Long rubbery arms snaking their way into a phone screen and through the ether for the fingers to reach a laptop keyboard hovering in the void, surrounded by leaves and berries in a moody palette.

Fly.io upgrades containers to full-fledged virtual machines running on our hardware around the world, connected with WireGuard to a global Anycast network. This post is about one fun thing you can run on a VM. Check us out: your app can be running in minutes.

"Remote development environment!"

Whether you reacted with a thrill of enthusiasm, a surge of derision or a waft of indifference, we're not really here to change your mind. That phrase means a lot of different things at this point in history. The meaning we pick today is "nerd snipe."

Let's set up a remote in-browser IDE, configured for Elixir / Phoenix development, the hard way—that is: using the command line and a Dockerfile.

What Is This?

We've leaned away from blog posts with a lot of code blocks in them. Too many code blocks make our eyes glaze over. But we really wanted to show off a fun way to play with Fly Machines, which are VMs that you manage directly. And a personal remote development environment is just the ticket: for individual use, we don't need load-balancing, or lots of instances, or always-on—in fact, it's better if we can turn it on and off.

So here we are. Once we got those code blocks flowing, we didn't stop until we'd deployed, step-by-step, not one, but two separate apps on Fly.io.

What You're in For

If you perform the ritual to completion, you'll have deployed an Elixir / Phoenix / SQLite hello-world app to Fly.io, from your own personal Elixir / Phoenix development environment that you've configured and deployed on Fly.io, complete with code-server IDE. You'll use the power of Machines to get the VM to go to sleep when you're not using it, making it cheaper to run.

The steps to get there look roughly like:

  1. Configure and deploy the development environment on a Fly.io Machine:
    1. Clone a repo with the files needed; optionally, read these files and some explanation of what they do.
    2. Create an app. Give it a storage volume and an IP address; set secrets.
    3. Create and start the new VM, associated with the app. Pow! Remote development environment!
  2. Log into the remote dev environment. Mime developing, and actually deploy, a hello-world Elixir / Phoenix app:
    1. Test the sample app on a dev server running on the remote VM; visit it in the browser.
    2. Create an app. Set a secret. Give it a storage volume. Deploy!

Costs

Between the remote development VM and the Phoenix app we'll deploy as part of the demo, we're planning to provision two VMs. The Phoenix app fits into the Fly.io free allowance for compute. We recommend giving the remote development environment 1GB of RAM, which takes it past our free allowance: it would cost a few dollars a month to run it full-time. We'll provision two volumes with a total of 3GB capacity, which exactly matches the free storage allowance on our Hobby Plan.

Destroying both new apps after finishing the tutorial (if you have no further use for them) will keep costs minimal.

The Thing We Want to Make

As we've mentioned, code-server, an in-browser VS Code, is our IDE of choice for today's exercise. It's far from the only possibility, looking at the range of VS Code-flavoured possibilities alone (starting with zero amount of VS Code, and stopping short of fully-managed services). Say we're connecting over SSH. A perfectly good remote dev environment, according to some people, would be tmux and Vim over SSH. VS Code and an SSH tunnel is a more comfortable option for most of us. If we can't, or don't want to, install VS Code on our local device, code-server makes it into a web app. We can get at code-server over SSH too.

We can also get code-server in the browser over an HTTPS connection (with a Let's Encrypt certificate and a TLS-terminating proxy), and put a password in front of it. Since Fly.io will provide us the cert and proxy almost without us noticing, that's the instant-gratification route. We'll go that way today, and skip over questions of SSH and WireGuard. If you want to talk about SSH and Fly.io remote dev setup, go see Amos.

We'll build from a Dockerfile. Our Docker image will hold a clean-slate (but mostly-configured) development environment. If we ever get mired in dependency hell, or our SSD goes fizzle, we can deploy a fresh machine using that.

Working files will live on a persistent storage volume. Nothing stops us checking our work into a remote Git repository regularly too.

We want to shut it down when it's not in use (because we don't get charged for CPU or RAM while the Fly Machine is stopped). Machines can be started and stopped manually using their REST API or flyctl, but here we'll also run a proxy called Tired Proxy that shuts the server down if it doesn't get any HTTP requests for some time. As for waking it up: Fly's proxy itself tries to wake machines for HTTP requests (and TCP connections).

Begin Construction

Let's get our hands dirty, starting by walking through the three files we use to build our image. If you don't want to type them in, you can clone the repo: git clone https://github.com/fly-apps/code-server-dev-environment.git

If you really don't want to bash out the commands, we do have a Code Server Launcher. It will immediately deploy the same remote dev environment we're creating in this demo.

Dockerfile

FROM lubien/tired-proxy:2 as proxy
FROM hexpm/elixir:1.12.3-erlang-24.1.4-debian-bullseye-20210902-slim

# install build dependencies
RUN apt-get update -y \
    && apt-get install -y build-essential git unzip curl \
    && apt-get clean && rm -f /var/lib/apt/lists/*_* \
    && curl -fsSL https://code-server.dev/install.sh | sh

# prepare build dir
WORKDIR /app

# Use bash shell
ENV SHELL=/bin/bash

RUN curl -L https://fly.io/install.sh | sh \
    && echo 'export FLYCTL_INSTALL="/root/.fly"' >> ~/.bashrc \
    && echo 'export PATH="$FLYCTL_INSTALL/bin:$PATH"' >> ~/.bashrc \
    && code-server --install-extension elixir-lsp.elixir-ls

# Apply VS Code settings
COPY settings.json /root/.local/share/code-server/User/settings.json

# Use our custom entrypoint script first
COPY entrypoint.sh /entrypoint.sh

COPY --from=proxy /tired-proxy /tired-proxy

ENTRYPOINT ["/entrypoint.sh"]

To summarize that:

It's a multi-stage build that gets the code for Tired Proxy from its public Docker image, and uses an official Elixir base image to save us the trouble of finding things like Elixir, Mix, etc., that every Elixir dev environment should have.

Over that, it installs some other things we know we want, including code-server (our IDE), flyctl (so we can deploy apps from the code-server terminal), and the Elixir Language Server extension for code-server (to make developing Elixir apps more comfy).

It copies in two other files from the local working directory: settings.json (just to get the dark theme in VS Code) and entrypoint.sh (a shell script which encapsulates all the things the VM should do every time it starts up). The Tired Proxy executable is copied from the first stage.

Finally, it sets ENTRYPOINT to run /entrypoint.sh.

entrypoint.sh

#!/bin/bash

TIME_TO_SHUTDOWN=3600

mkdir -p /project

# In case fly volumes put something there
rm -rf '/project/lost+found'

if [ -z "$(ls -A /project)" ]; then
    echo "Preparing project"
    rm -rf /project
    git clone $GIT_REPO /project

    cd /project

    echo "Setting up Elixir environment"

    mix local.hex --force
    mix local.rebar --force
    mix deps.get
fi

code-server --bind-addr 0.0.0.0:9090 /project &
    /tired-proxy --port 8080 --host http://localhost:9090 --time $TIME_TO_SHUTDOWN

Here's what that does:

First, it sets the TIME_TO_SHUTDOWN environment variable to 3600 seconds (1 hour). This is used in the tired-proxy command later on.

It creates a folder for the Elixir project to live in, if one doesn't already exist. The -p tag prevents errors in the case that /project already exists (as it should the second time you start the VM).

It initializes the environment, if that hasn't already happened, by cloning project files from the repo indicated in the GIT_REPO environment variable (which we'll set when we run the VM), installing Hex and Rebar locally (non-interactively, with the --force flag), and getting project dependencies.

Finally, the trick we have up our sleeve: it spawns a code-server, with the /project folder open, listening on port 9090—but we don't expose this port directly. Tired Proxy maps port 8080 to 9090, and if there's no incoming HTTP connection for $TIME_TO_SHUTDOWN seconds, it exits. That's it. That's the whole trick.

settings.json

We provide a settings.json just to get the dark theme in our IDE.

If you're a VS Code user, you can provide your own preferences in this file.

{
    "workbench.colorTheme": "Default Dark+",
    "git.postCommitCommand": "sync",
    "git.enableSmartCommit": true,
    "git.confirmSync": false,
    "git.autofetch": true
  }

Over to Flyctl

If you're new to Fly.io, install flyctl, the Fly.io CLI tool, and run fly auth signup. If you already have flyctl installed, it's worth making sure it's up to date with fly version update.

flyctl vs fly

This is more of a secret than it should be, but flyctl uses fly as an alias for flyctl. So if I type fly secrets and the docs are for flyctl secrets, that's all that's about.

Prepare the Fly Machines App

"Fly Machines App?" Let's back up just a bit.

Machines are basically just one level lower than apps. They're VMs you can create, destroy, start, and stop directly through a REST API or with flyctl. The Fly.io platform still needs to keep track of these VMs—who they belong to, where to route requests, all that. A machines app is where machine VMs store their important documents: their passports, Sears, Roebuck & Co. stock certificates, public IP addresses, etc. A machines app doesn't run your code unless it has at least one Machine to provide the code—not to mention the CPU and RAM.

This is not so different from the kind of app we're used to talking about: the kind that our orchestrator keeps running, to the best of its ability, until you scale to zero or destroy it. In both cases, you have VMs, and you have some centrally-stored information. Conceptually, it's probably not helpful to hold onto a distinction between "machines apps" and "app apps". But for the moment, there's a practical difference in the way these two cases are implemented, so when it's time to register a new app on Fly.io to shuffle paperwork for your code-server VM, you need to create a Machines App.

Register a new machines app on Fly.io with a name and an organization. The remote IDE URL will be <your-app-name>.fly.dev, so choose well.

fly apps create <your-app-name> --machines
Machines, and flyctl, are evolving so fast that gingerly configuring everything step-by-step like this is going to look positively quaint any minute now. We're chasing a moving target here.

Create a new volume called storage, tied to this app, with size 2GB. You can choose to make it smaller. The VM will be tied to the hardware this volume is on.

fly volumes -a <your-app-name> create storage --size 2 
? Select region:  [Use arrows to move, type to filter]
  Amsterdam, Netherlands (ams)
  Paris, France (cdg)
  Dallas, Texas (US) (dfw)
  Secaucus, NJ (US) (ewr)
  Frankfurt, Germany (fra)
> São Paulo (gru)
  Hong Kong, Hong Kong (hkg)
  Ashburn, Virginia (US) (iad)
  Los Angeles, California (US) (lax)
  London, United Kingdom (lhr)
  Chennai (Madras), India (maa)
  Madrid, Spain (mad)
  Miami, Florida (US) (mia)
  Tokyo, Japan (nrt)
  Chicago, Illinois (US) (ord)

By default, Fly.io apps have private IPV6 addresses for use within their organization's WireGuard network. If you want to access this app without a WireGuard tunnel, it needs a public IP.

Since you're not running fly deploy on this app, you need to allocate this manually.

fly ips allocate-v4 -a <your-app-name>

Use fly secrets to pass in secret environment variables. Note the --stage flag, which is needed (at this time) because setting secrets on a machines app triggers a deploy by default, and we don't want that in our case.

fly secrets set -a <your-app-name> \
PASSWORD=<mypassword> \
FLY_API_TOKEN=$(fly auth token) \
--stage
Secrets are staged for the first deployment

code-server asks for a password when you first open it. It will generate its own random password on installation if the PASSWORD environment variable isn't set. You can still ssh into the VM later and extract it from a config file, but it's easier to set it ahead.

Providing your fly auth token allows you to deploy other apps to Fly.io from within this app. Trippy!

Run the Machine for the First Time

Here's the invocation to bring the code-server VM into being! Your app will be discoverable on the Internet as soon as the VM is up and listening for requests.

fly machine run . \
  -p 443:8080/tcp:tls \
  -p 4000:4000/tcp:tls \
  --memory 1024 \
  --region <your-region> \
  --volume storage:/project \
  --env FLY_REMOTE_BUILDER_HOST_WG=1 \
  --env GIT_REPO=https://github.com/fly-apps/hello_elixir_sqlite.git \
  -a <your-app-name>
  • fly machine run .: Run a new Fly Machine VM. The first argument is the image or the path to the Dockerfile. In this case it's the current folder (don't miss out the .).
  • -p 443:8080/tcp:tls: Map port 8080 to the external HTTPS port (443) so we can access it via your-app-name.fly.dev. Remember this port belongs to the Tired Proxy so the server shuts down if no one uses it for a while.
  • -p 4000:4000/tcp:tls : Elixir Phoenix uses port 4000 for development. Open this port so you can visit the Phoenix app's dev server on <your-app-name>.fly.dev:4000. Note: This does mean the world can see it, too.
  • --memory 1024: For the best experience (i.e. to avoid OOM crashes), we'll go for the recommended mimimum of 1GB. You can try a lower value, but keep this choice in mind if you have to debug a VM crash!
  • --region <your-region>: In order to mount the volume you provisioned earlier, the three-letter code <your-region> must match the volume's region.
  • --volume storage:/project: Mount the volume called storage to the path /project. That's where your project files will live.
  • --env FLY_REMOTE_BUILDER_HOST_WG=1: This machine is already on your organization's WireGuard private network, so it doesn't need to create a userland WireGuard tunnel to reach a remote builder. We use the FLY_REMOTE_BUILDER_HOST_WG environment variable to tell flyctl to use native WireGuard, which means it'll be faster to deploy apps from code-server.
  • --env GIT_REPO=https://github.com/fly-apps/hello_elixir_sqlite.git: Store the URL for the repo to be cloned to /project. We're using a prepared Phoenix / SQLite example for fun (and so we can focus on deployment here, and not databases).
  • -a <your-app-name>: Tell flyctl to deploy the VM under the dev server app.

The above command should result in output close to this:

Success! A machine has been successfully launched, waiting for it to be started
 Machine ID: d5683003add8e9
 Instance ID: 01G3XMBKS6F004P5W0Q51V466A
 State: starting
Waiting on firecracker VM...
Waiting on firecracker VM...
Waiting on firecracker VM...
Machine started, you can connect via the following private ip
  fdaa:0:3335:a7b:1f61:d2d:3742:2

Time to (Pretend to) Code!

Now we can visit your freshly-minted remote development environment at <your-app-name>.fly.dev. It'll ask for the password you set earlier with fly secrets set. Fear not the light theme. Once settings.json is read in, it'll switch to dark.

Once you're in, you'll see the /project folder open, complete with the cloned hello_elixir_sqlite project files. README.md contains instructions for building, previewing, and deploying that app. In case you're just following along in your imagination, we'll repeat them here.

Open the integrated VS Code terminal with ^+`.

A code-server workspace, looking a lot like normal VS Code, with the files and terminal panels open.

Run mix phx.server to compile the app and start up the dev server. When that finishes, you can check it out in the browser at <your-app-name>.fly.dev:4000. You'll recall you exposed port 4000 when you created the machine with fly machine run. This does mean everybody can see the dev server at that address, because this Phoenix app doesn't have any password protection.

After dutifully clicking the button to run migrations, you should see something like this:

The "Welcome to Phoenix" hello-world Phoenix starter app landing page running.

Reverse CTA

It was tempting to link back to this post, but you're already here. Read the Machines announcement to see more about why we're stoked.

Get stoked about Machines too  

Flyctl Round Two: Deploy to Fly.io From Fly.io

In the VM's Dockerfile, you installed flyctl, so you can run any fly command from the integrated terminal. You're authed, because you set the FLY_API_TOKEN secret, which the CLI will read from the environment if it's available.

It's time for the second round of app-configuration, secret-setting, and volume-creation with flyctl, this time all for your Phoenix app.

Register the new app, but don't deploy it. New app, new name. Use the fly.toml provided by the project repo. Deploy it wherever you like—it's a whole independent app.

fly launch --no-deploy
An existing fly.toml file was found
? Would you like to copy its configuration to the new app? Yes
Creating app in /project
Scanning source code
Detected a Dockerfile app
? App Name (leave blank to use an auto-generated name): <your-phoenix-app-name>
? Select organization: Lubien (personal)
? Select region: gru (São Paulo)
Created app phoenix-from-fly-vscode in organization personal
Wrote config file fly.toml
Your app is ready. Deploy with `flyctl deploy`

Generate a new secret for the Phoenix app.

mix phx.gen.secret
AWNbSPHIDuHpIXwLp0vaLJPRA8QSB0X363xiAEWPSS+7bI6n6rcqZltGKQBU1DoE

Set that as the secret SECRET_KEY_BASE that the app will have access to.

fly secrets set SECRET_KEY_BASE=AWNbSPHIDuHpIXwLp0vaLJPRA8QSB0X363xiAEWPSS+7bI6n6rcqZltGKQBU1DoE
Secrets are staged for the first deployment

This sample app is configured to use an SQLite database, so you need some storage.

Provision a 1GB volume in the same region as the Phoenix app.

fly volumes create database_data --size 1 --region gru
        ID: vol_w1q85vgl7xprzdxe
      Name: database_data
       App: <your-phoenix-app-name>
    Region: gru
      Zone: 2824
   Size GB: 1
 Encrypted: true
Created at: 01 Jun 22 13:32 UTC

That's it! Deploy the Phoenix app.

fly deploy

It'll go live at https://your-phoenix-app-name.fly.dev.

You can stop the machine using fly machine stop, and revive it just by visiting the app in your browser (or with fly machine start). If you close the tab, it will just sleep after an hour without activity.

Delete your Code Server app if you're done with it. Also destroy the Phoenix app if you don't want that!

Whew

This project was built to demonstrate our new Fly Machines feature, and how simple it can be to launch an app like code-server with it.

The Dockerfile serves as an example of how you can customize your setup ready to do some work, and it does some heavy lifting: cloning the repo, installing flyctl, and installing an entire Elixir developer environment. It wouldn't be hard to swap out the Elixir bits for Ruby ones, if that's your bag!

Fly machines are very keen to start themselves if someone reaches them over HTTP (thanks, fly-proxy), but they won't stop by themselves with the code-server process running. You can reuse our Tired Proxy to send a VM to sleep, so you don't get billed for it 24/7. One caveat: with this simple setup, if a random bot hits your port 8080, fly-proxy will treat that the same as you opening up the app in a tab—and try to wake the machine. Oh. Two caveats: Our experiments indicate that leaving a code-server tab open with the terminal pane active may keep it alive too.

You can also start and stop and remove machines using flyctl or the REST API; you can write a little app with "start" and "stop" buttons for your machines if you want to play. Obviously, the idea is that you could use the Machine API to write much bigger, more interesting apps than that. We leave that as an exercise for the reader!