AN
Posts Projects

Self-Hosting Elixir Applications: A Personal Approach

My personal approach to self-hosting Elixir apps using Hetzner, GitHub Actions, Ansible, and Traefik.

Cover image

Self-hosting your applications can be a rewarding journey for developers who enjoy having full control over their infrastructure. As an Elixir enthusiast with a background in DevOps, I’ve spent years refining my self-hosting setup. In this post, I’ll share my current approach to hosting Elixir/Phoenix applications, covering the tools I use and my deployment process. Whether you’re considering self-hosting or just curious about how others manage their apps, I hope you’ll find something useful here.

Disclaimer This post outlines my personal approach to self-hosting small to medium-sized Elixir applications. While this setup works well for my personal projects and some professional use cases, it’s important to note that it may not be suitable for large-scale applications or scenarios requiring high availability.

Why I chose to Self-Host

The main reason I opt for self-hosting my applications is my familiarity with DevOps. Over the years, I’ve become comfortable with server management and infrastructure setup. This background means that handling my own servers isn’t a big challenge for me – it’s actually something I enjoy doing.

While managed hosting solutions like Fly.io or Gigalixir offer convenience, they often come with limitations and higher costs for growing applications. Self-hosting allows me to:

  1. Control: Have full control over my applications and their environments. This means no unexpected changes from third-party platforms and the ability to customize everything to my needs.
  2. Cost-effectiveness: Get more resources for lower costs. It’s simply more efficient for my projects.
  3. Flexibility: Easily adapt my infrastructure to specific project needs without platform constraints.

Of course, self-hosting isn’t always straightforward. There’s a learning curve, and you need to be ready to troubleshoot issues yourself.

My Current Self-Hosting Stack

Over the past 5 years of self-hosting, I’ve experimented with various setups and technologies. These ranged from complex configurations like a full Nomad cluster with dozens of Docker containers, Consul, my own Docker registry, and other intricate components, to simpler approaches. Through trial and error, I’ve settled on a setup that strikes a balance between simplicity and effectiveness. Here’s a breakdown of my current stack:

Hetzner Servers

Although there’s been a surge in popularity for Hetzner over the past year or so, I’ve been with them for around 2 years now, primarily motivated by their excellent price-performance ratio.

GitHub Actions

GitHub Actions plays a crucial dual role in my deployment process. On every push to the main branch, it springs into action to both build and deploy the application. The build phase centers around the mix release command, a game-changing Elixir tool that deserves a closer look. After building, GitHub Actions then triggers the deployment to the production server.

mix release is Elixir’s built-in way of packaging an application for deployment. It’s not just a simple bundler - it’s a sophisticated tool that showcases Elixir’s strengths in creating robust, production-ready applications.

While mix release offers numerous benefits, here are the key features that make it particularly powerful for my use case:

  1. Self-contained : Packages your application, dependencies, and the Erlang VM into a single directory. No need for Elixir or Erlang on production servers.
  2. Runtime configuration : Allows you to configure your application at runtime, making it easy to adapt to different environments without rebuilding.
  3. Built-in CLI : Releases come with a CLI for starting, stopping, and managing your application, making it easy to integrate with system tools like systemd.

This combination of GitHub Actions and mix release ensures a smooth, efficient pipeline from code push to production deployment.

Ansible

Ansible plays a crucial role in both server configuration and application deployment. Here’s what it handles:

  • Setting up environment variables
  • Creating or updating the systemd service for the application
  • Uploading the freshly built release to the server
  • Running database migrations
  • Restarting the application

Traefik

While I could serve applications directly from the app server, Traefik adds a layer of flexibility and features:

  • SSL termination with automatic certificate generation via Let’s Encrypt
  • Load balancing for multiple instances of the same app
  • Custom route redirection rules
  • Custom request middlewares

What about Docker ?

Docker has become a staple in modern web development, but I’ve chosen not to use it in my current setup. This decision is based on my experience with Elixir applications and my specific development needs.

For my projects, Docker often introduces complexity that I find unnecessary. Running Elixir applications directly on the host simplifies my deployment process and makes debugging more straightforward. Additionally, the Ansible-based setup provides sufficient environment consistency without the overhead of containerization.

Performance is also a factor in my decision. Running Elixir applications on bare metal can still offer some advantages in resource utilization and startup times, particularly for smaller projects.

Docker certainly has its place, especially for teams juggling diverse technology stacks or working with microservices. However, for my self-hosted Elixir setup, I’ve found that the benefits don’t outweigh the added complexity.

The Deployment Process

One of the beauties of a well-configured self-hosting setup is the smooth deployment process. Let’s walk through how I deploy both existing and new applications.

Deploying Updates to Existing Applications

For an application already running on a server, the process is straightforward:

  1. A push to the main branch triggers a custom GitHub Actions workflow.
  2. The workflow builds a new release using mix release.
  3. Ansible takes over to deploy the application, handling tasks like uploading the release, setting environment variables, and restarting the service.

The workflow usually takes 3-5 minutes to complete depending on the application compile time.

Ansible Deployment Steps Ansible Deployment Steps

Setting Up and Deploying New Applications

For a new application, I need a few additional steps:

  1. Create a new Hetzner server (I usually do this via hcloud CLI).
  2. Create the GitHub Actions workflow file. Here’s a typical example:
name: Deploy
on:
  push:
    branches:
      - master
jobs:
  deploy:
    name: Deploy
    runs-on: ubuntu-24.04
    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Deploy Application
        uses: andrielfn/deploy-action@master
        with:
          app_name: app_name
          elixir_version: "1.17.3"
          otp_version: "27.0.1"
          port: 4000
          deploy_host: "111.111.111.111"
          vault_password: ${{ secrets.VAULT_PASSWORD }}
  1. Create a secrets.yml file with the necessary environment variables. This file is read by Ansible during the deployment process to set up the environment variables. It’s also encrypted using Ansible Vault, so I can safely store it in the repository.
  2. Trigger the workflow by pushing to the main branch.
  3. Apply a new Traefik rule for the new application. Here’s an example configuration:
traefik_apps:
  - name: my_app
    domains:
      - www.my-app-domain.com
      - my-app-domain.com
    middlewares:
      - analytics
    servers:
      - 10.0.0.12:4000

When Traefik loads this new rule, it automatically generates a certificate for the new domain and starts routing traffic to the new application. Traefik also handles automatic certificate renewal every 3 months.

Database

Following the same self-hosting philosophy, I also manage and provision my own PostgreSQL database using Ansible. Why bother, you might ask? Well, there are a couple of compelling reasons:

  1. Cost-effectiveness: Managed database services often come with a hefty price tag. By self-hosting, I can significantly cut down on these costs, especially for projects that don’t require the scalability of cloud-hosted solutions.

  2. Customization: Self-hosting gives me complete control over the database configuration. This means I can fine-tune performance settings , adjust security parameters, and even tweak the number of allowed connections to suit my specific needs. While these customizations might seem minor, they can make a significant difference in certain scenarios.

  3. Learning Opportunity: Managing my own database has been an invaluable learning experience. It’s helped me understand database operations at a deeper level, which in turn improves my overall application design and troubleshooting skills.

As a personal preference, I also use this setup to install custom extensions like pg-ulid , a PostgreSQL extension I created that adds a custom type for ULID (Universally Unique Lexicographically Sortable Identifier) to the database. While this might not be a universal advantage of self-hosting, it showcases the flexibility this approach offers for specific use cases.

The Ansible playbook handles everything from installing PostgreSQL to setting up users, databases, and configurations.

Pro tip: While self-hosting a database can be rewarding, it’s crucial to have a rock-solid backup strategy. Don’t be the person who learns this the hard way!

Monitoring

I currently use Monit to monitor the servers and application processes. While there are more feature-rich monitoring solutions available, I chose Monit for its simplicity and low resource footprint, which aligns well with my goal of maintaining a lean infrastructure.

For each server, I have rules to monitor things like CPU, memory, disk i/o, network, load average and file system. These basic metrics give me a good overview of the server’s health.

For the application processes, I basically just check if the process is running and responding to requests. This at least ensures that the applications are up and functioning as expected.

When any of these checks fail, Monit triggers a Discord message to notify me. This allows me to quickly respond to any issues that arise.

Monit Notification Monit Notification

This setup provides enough information to keep me informed without being overly complex or resource-intensive.

Challenges and Limitations

While my current self-hosting setup works well for my needs, it’s important to acknowledge its limitations and potential issues:

Single Point of Failure: The Traefik instance is by far the most critical component of this setup. It’s just another Hetzner server. If it goes down for any reason, all applications using the proxy are impacted. There are definitely ways to mitigate this, but for my current needs, the trade-off between complexity and reliability is acceptable.

Manual Interventions: Although most of the time things just work, there are occasions when the deployment process fails for some reason, and I eventually need to SSH into the server and troubleshoot the issue. This could be due to various factors like network issues, unexpected server states, or edge cases in the deployment scripts. While these instances are rare, they do require some DevOps knowledge to resolve.

Scalability Considerations: While this setup works well for small to medium-sized applications, it may require significant modifications to handle large-scale, high-traffic applications. For instance, you might need to implement a more robust load balancing solution or consider a multi-region setup for better reliability and performance.

Brief Downtime During Deployment: For applications running on a single server, there’s a short downtime of a few seconds when systemd restarts the app. While there are techniques to achieve zero-downtime deployments, I’ve found that for my current needs, this brief interruption is acceptable.

Conclusion

Self-hosting Elixir / Phoenix applications has been a rewarding journey for me. It’s provided full control over my environments, cost-effectiveness, and a deeper understanding of DevOps practices.

While this approach comes with challenges like manual interventions and infrastructure management, the benefits have outweighed the drawbacks for my use cases. It’s important to note that this setup may not suit everyone, especially for large-scale or high-availability needs.

Ultimately, self-hosting has enhanced both my development and operational skills, making it a valuable learning experience beyond just application deployment.

Have any questions or thoughts? Drop a message on the tweet for this post .