How to deploy a bot to Fly.io

Copy Markdown View Source

Most of this guide is generic and can be applied to other providers, but since fly.io has a free tier that we can use to run bots it's a great way to start into deploying bots.

Setup Fly App

If you already have the app running in Fly, you can skip this section.

The free tier on fly.io allows you to have 3 machines with size shared-cpu-1x@256MB, for this example setup we'll create one for the elixir application and one for postgresql.

First we need to install the fly command utility, follow the instructions for your platform: https://fly.io/docs/hands-on/install-flyctl/

We want to use our own Dockerfile, because we have more control in how we deploy our application, here is the Dockerfile that I use:

In this example the application is called my_bot, change the path in the CMD command with your app's name

  • Dockerfile

    FROM hexpm/elixir:1.16.2-erlang-26.2.3-alpine-3.19.1 as base
    
    RUN mkdir /app
    WORKDIR /app
    
    RUN apk --no-cache add g++ make git && mix local.hex --force && mix local.rebar --force
    
    FROM base as test
    COPY . /app
    
    FROM base AS app_builder
    ENV MIX_ENV=prod
    
    # copy only deps-related files
    COPY mix.exs mix.lock ./
    COPY config config
    RUN mix deps.get --only $MIX_ENV
    COPY config/config.exs config/${MIX_ENV}.exs config/
    RUN mix deps.compile
    # at this point we should have a valid reusable built cache that only changes
    # when either deps or config/{config,prod}.exs change
    
    COPY priv priv
    COPY lib lib
    COPY config/runtime.exs config/
    # COPY rel rel # could contain rel/vm.args.eex, rel/remote.vm.args.eex, and rel/env.sh.eex
    RUN mix release
    
    FROM alpine:3.19.1 as app
    
    RUN apk add --no-cache bash openssl libgcc libstdc++ ncurses-libs
    
    RUN adduser -D app
    COPY --from=app_builder /app/_build .
    RUN chown -R app:app /prod
    USER app
    CMD ["./prod/rel/my_bot/bin/my_bot", "start"]
  • .dockerignore

    # flyctl launch added from .elixir_ls/.gitignore
    .elixir_ls/**/*
    
    # flyctl launch added from .gitignore
    # The directory Mix will write compiled artifacts to.
    _build
    
    # If you run "mix test --cover", coverage assets end up here.
    cover
    
    # The directory Mix downloads your dependencies sources to.
    deps
    
    # Where third-party dependencies like ExDoc output generated docs.
    doc
    
    # Ignore .fetch files in case you like to edit your project deps locally.
    .fetch
    
    # If the VM crashes, it generates a dump, let's ignore it too.
    **/erl_crash.dump
    
    # Also ignore archive artifacts (built via "mix archive.build").
    **/*.ez
    
    # Ignore package tarball (built via "mix hex.build").
    **/my_bot-*.tar
    
    # Temporary files, for example, from tests.
    tmp
    
    # flyctl launch added from .lexical/.gitignore
    .lexical/**/*
    fly.toml

Now we'll execute fly launch --no-deploy to generate our base fly.toml.

We're about to launch your app on Fly.io. Here's what you're getting:

Organization: <Name>                 (fly launch defaults to the personal org)
Name:         my-bot                 (derived from your directory name)
Region:       <Region>               (this is the fastest region for you)
App Machines: shared-cpu-1x, 1GB RAM (most apps need about 1GB of RAM)
Postgres:     <none>                 (not requested)
Redis:        <none>                 (not requested)
Sentry:       false                  (not requested)

? Do you want to tweak these settings before proceeding? (y/N)

We want to edit this values, let's select y, this will open a tab in your browser to finish configuring your application, the values that I have changed are:

  • App name: Write whatever app name you want
  • VM Memory: 256MB, I want to use the free tier, so I have to use the 256MB VMs
  • Postgres: Setup a postgres database, pick whatever name you want, and select the "Development" configuration in order to have only one machine and keep it in the free tier.

That's all I changed, but feel free to tweak what you want.

Now I changed the fly.toml to only have one instance of my app instead of two, and to not stop the machines when idle, but you can keep it at two:

app = <your-app>
primary_region = <your-region>

[build]

[http_service]
  internal_port = 8080
  force_https = true
  auto_stop_machines = false
  auto_start_machines = false
  min_machines_running = 0
  processes = ['app']

[[vm]]
  size = 'shared-cpu-1x'
  count = 1

Now, everytime we want to deploy the application, we just need to run fly deploy.

Updating the bot to webhook

If you have the bot setup to use polling, you can already deploy the application and it will work right away, but if you want to use the benefit of having the application deployed, you will want to use the webhook mode to improve performance and use less resources.

For that, first we need to change the config files, I want to keep polling on development/testing and webhook will be used only on production.

  • config/config.exs

    import Config
    
    config :ex_gram, adapter: ExGram.Adapter.Req
    
    config :my_bot, MyBot.Bot,
    token: "YOUR_BOT_TOKEN",
    method: :polling,
    polling: [allowed_updates: []] 
    
    import_config "#{config_env()}.exs"
  • config/dev.exs

import Config

config :ex_gram, token: "YOUR_BOT_TOKEN"

config :my_bot, MyBot.Bot,
  token: "YOUR_BOT_TOKEN",
  method: :polling,
  polling: [allowed_updates: []]
  • config/prod.exs
import Config
  • config/runtime.exs
import Config

if config_env() == :prod do
  config :ex_gram, token: System.get_env("BOT_TOKEN")
    
  config :my_bot, MyBot.Bot,
    token: System.get_env("BOT_TOKEN"),
    method: :webhook,
    webhook: [
      allowed_updates: [],
      drop_pending_updates: false,
      max_connections: 50,
      secret_token: System.get_env("WEBHOOK_SECRET_TOKEN"),
      url: "https://#{System.get_env("FLY_APP_NAME")}.fly.dev/",
      # path: "/custom/path"  # Optional: customize the webhook path (default: "/telegram")
    ]
end
  • config/test.exs
import Config

config :ex_gram, token: "NOTHING", adapter: ExGram.Adapter.Test

config :my_bot, MyBot.Bot, 
  token: "test_token",
  method: :test,
  username: "testbot",
  setup_commands: false

The webhook configuration is on runtime.exs, and we can see that we are using two environment variables, let's set them up in our Fly application:

fly secrets set BOT_TOKEN=YOUR_BOT_TOKEN --stage
fly secrets set WEBHOOK_SECRET_TOKEN=WHATEVER_SECRET_TOKEN_YOU_WANT --stage

Now we need to add a couple of dependencies to listen on the port we want and setup the webhook plug.

  • mix.exs
    # ...
    
    defp deps do
      [
        # ...
        # Add this two:
        {:plug_cowboy, "~> 2.7"},
        {:plug, "~> 1.15"}
      ]
    end

We need to create a router, and plug the ExGram.Plug to route the updates:

  • lib/my_bot/router.ex
defmodule MyBot.Router do
  use Plug.Router

  # If you configured a custom path in the webhook options, pass it here too:
  # plug(ExGram.Plug, path: "/custom/path")
  plug(ExGram.Plug)

  plug(:match)
  plug(:dispatch)

  get("/", do: send_resp(conn, 200, "Welcome"))
  match(_, do: send_resp(conn, 404, "Oops, wrong path!"))
end

And finally we just need to update our application.ex to add the router and get the new bot config

  • lib/my_bot/application.ex

  @impl true
  def start(_type, _args) do
    bot_config = Application.get_env(:my_bot, MyBot.Bot)

    children = [
      ExGram,
      {MyBot.Bot, bot_config},
      {Plug.Cowboy, scheme: :http, plug: MyBot.Router, port: 8080}
    ]

    opts = [strategy: :one_for_one, name: MyBot.Supervisor]
    Supervisor.start_link(children, opts)
  end