This post walks through setting up a personal Gitea instance on Fly.io. By the end we should have a custom domain, e.g. https://git.luketurner.org, providing a “Github-lite” experience including:

  1. Public HTTPS front end (with Let’s Encrypt certificate)
  2. An admin user with r/w access to repos over SSH
  3. Outgoing webhooks
  4. And more!

Gitea is a multi-user system (like Github or Gitlab), but we will disable public registration and only configure a single “admin” user for ourselves to use. Anonymous viewers will have read-only access to the site and will be able to clone repositories but be unable to contribute, login, or create accounts.

Assumed background knowledge:

You’re not assumed to have prior experience with Fly or Gitea, but familiarity with similar tools will help. I recommend:

  • Basics of Git (repositories, Github, SSH keys)
  • Basics of containerized hosting (Docker, private networking, etc.)

If the sentence “I want to deploy a Docker container based on my Git repo” makes sense to you, then you’re part of the target audience!

This is a kinda long post. If you already know about Fly, and just want to get a Gitea instance deployed quickly, peep this Quick Reference!

(If you don’t know Fly, then don’t worry if this doesn’t make sense yet. I’ll explain it all further below.)

  1. Create a fly.toml:
app = "my-gitea-app"
kill_signal = "SIGINT"
kill_timeout = 5
processes = []

[build]
  image = "gitea/gitea:latest"

[env]
  APP_NAME = "My site title"
  GITEA__security__INSTALL_LOCK = true
  GITEA__service__DISABLE_REGISTRATION = true
  GITEA__server__ROOT_URL = "https://git.example.com/"
  GITEA__server__DOMAIN = "git.example.com"
  GITEA__server__SSH_DOMAIN = "git.example.com"
  GITEA__server__LANDING_PAGE = "explore"
  GITEA__server__OFFLINE_MODE = true

[experimental]
  allowed_public_ports = []
  auto_rollback = true

[mounts]
  destination = "/data"
  source = "gitea_data"

[[services]]
  http_checks = []
  internal_port = 3000
  processes = ["app"]
  protocol = "tcp"
  script_checks = []
  [services.concurrency]
    hard_limit = 25
    soft_limit = 20
    type = "connections"

  [[services.ports]]
    force_https = true
    handlers = ["http"]
    port = 80

  [[services.ports]]
    handlers = ["tls", "http"]
    port = 443

  [[services.tcp_checks]]
    grace_period = "1s"
    interval = "15s"
    restart_limit = 0
    timeout = "2s"

[[services]]
  http_checks = []
  internal_port = 22
  processes = ["app"]
  protocol = "tcp"
  script_checks = []
  [services.concurrency]
    hard_limit = 10
    soft_limit = 5
    type = "connections"

  [[services.ports]]
    port = 22
  1. Run shell commands:
# Launch app so we can attach volumes and secrets
fly launch --no-deploy

# Create persistent volume for repo data
# 3 GB is max for free tier
fly vol create gitea_data --region sea --size 3

# Generate Gitea internal secrets
fly secrets set \
  "GITEA__security__SECRET_KEY=$(openssl rand -hex 12)" \
  "GITEA__security__INTERNAL_TOKEN=$(openssl rand -hex 12)"

# Deploy the app so we can connect and login
fly deploy --remote-only

# Create certificate for Fly's automatic TLS termination
# Must have either CNAME or A/AAAA records set. See:
# https://fly.io/docs/app-guides/custom-domains-with-fly/
fly certs create git.example.com

# opens SSH session to the running container
fly ssh console

# In the SSH session in the container, run:
su git

gitea admin user create \
  --username MYNAME \
  --email MYEMAIL@example.com \
  --admin \
  --random-password \
  --must-change-password

Once you’ve made your admin account, visit https://git.example.com and login with your new credentials. You’ll be prompted to change your password.

Recommended next steps in the Gitea Web UI:

  1. Enable 2FA for your admin account.
  2. Add any SSH keys / GPG keys to your account.

OK — end of Quick Reference! Now let’s get to the long-form version.

Fly.io setup

Fly.io is a Platform-as-a-Service (PaaS) provider with a lot of cool functionality. We’ll be using it to host a containerized Gitea instance in the cloud.

All the resources we’ll be creating today fall within the Fly.io free tier. Without the free tier, total cost would be about $2.50/month according to my little PaaS price calculator.

The initial Fly setup is very well-documented in Fly’s official docs. Follow the steps in this page and report back once you have flyctl set up and an account created:

https://fly.io/docs/hands-on/

… Oh, you’re done? Cool! Let’s get a move on.

First steps — the simplest possible deployment

First, create a repository for the project:

mkdir git-on-fly
cd git-on-fly
git init .

In order to deploy our project (which in Fly.io terms is called an app), we need a configuration file that tells Fly.io how to build it. We could write it by hand, but it’s recommended to use the fly launch command to generate one for us.

Think of fly launch as analogous to something like npm init, in that it scaffolds a new project for us. But, fly launch will also create the application in Fly’s system, and even deploy it if we want to.

One important benefit of fly launch: Fly app names are globally unique across all users. If you author your own fly.toml, it’s possible your app name will conflict with someone else’s. The fly launch command helps ensure you pick an app name that’s not already taken.

When we call fly launch, we’ll tell it to use the gitea/gitea:latest image as described in Gitea’s Installation with Docker guide. We’ll also ask it create the app, but not deploy it yet (--no-deploy).

fly launch --image "gitea/gitea:latest" --no-deploy

Follow the prompts to finish the setup. A few notes:

  1. Fly.io application names are globally unique (like S3 buckets). You’ll have to pick one nobody else is using. The name doesn’t really matter but it should be memorable to you. If you really don’t care, fly launch can generate one Heroku-style.
  2. We’ll be using SQLite as our database, so select No when prompted for any database options like Postgres or Redis. (If you’re worried about data loss, don’t worry — we’ll be using a persistent volume for the SQLite data and repository data. We’ll get there!)

Once finished, we can view the status of the app with:

fly status

Since we passed --no-deploy when we ran fly launch, our app should be shown in a Pending state, meaning Fly knows about our app but no containers are deployed yet. This is convenient because we can “stage” all our app’s configuration, like secrets, before deploying anything.

We can also look at the fly.toml generated by fly launch. It should look something like this:

# fly.toml file generated for example-app on 2022-11-19T00:04:57-08:00

app = "example-app"
kill_signal = "SIGINT"
kill_timeout = 5
processes = []

[build]
  image = "gitea/gitea:latest"

[env]

[experimental]
  allowed_public_ports = []
  auto_rollback = true

Looks all good! We don’t have anything to “stage” at the moment; let’s just tell Fly to deploy it:

fly deploy --remote-only

Hey, it deployed the app! Pretty cool!

We passed the --remote-only flag to tell fly to use a remote server to build the image instead of building it on our PC. In this case, we shouldn’t be building any images at all — we’re using Gitea’s pre-baked one from Docker Hub — but I still like to call out the --remote-only flag as a way to promote more consistent development experience across machines.

It’s important to note, the default Gitea configuration is not safe to expose to the Internet.

Our fly.toml has no ports exposed, so we should be okay, but be very careful deploying Gitea on the public Internet without a “hardening” configuration!

(We’ll get to the hardening in a few minutes, before we expose our site to the public — for now, we’re using the insecure defaults and relying on Fly’s private networking.)

Before we go further, let’s test our app’s current functionality. Since we don’t have public ports exposed, we’ll need to use the fly proxy command to connect to our running container:

# forward container port 3000 to localhost:3000
fly proxy 3000:3000

Then open http://localhost:3000/ in the browser.

We should see an installation screen for administrators to configure the new Gitea instance.

We don’t want this screen to be available on the Internet, since anyone who sees it can effectively “take control” of the Gitea instance. We’re looking at it now, but only because of our proxy. Regular folks shouldn’t be able to see it. We can confirm we’re safe by running:

fly open /

This will try to open the app using a public-facing IP. But, it should fail. Phew, right?

So, what’s next? Well, following the installation screen instructions to setup the instance would work alright. But there’s a couple problems with using the installation page:

  1. Some important settings are easy to miss.
  2. We’ll need to adjust the setup again once we want to configure a custom domain, which would require editing the generated app.ini file in the container.
  3. Any changes we make will be lost when the app is re-deployed.

Luckily, Gitea is incredibly configurable and all these settings are exposed via environment variables as well. Using environment variables for configuration will solve all our problems. Mostly. The “real” solution for (3) will come when we add a persistent volume. We’ll get there soon!

For now, let’s add some environment variables to our instance in preparation for exposing on the public Internet.

Gitea Configuration

Gitea has an enormous array of configuration options. They can be specified in an app.ini file, but the official Gitea Docker image also provides syntax for setting any option using an environment variable (see docs), which is what we’ll be using.

The syntax is:

GITEA__<config-section>__<config-name>=<config-value>

For example, an environment variable that sets the INSTALL_LOCK setting in the [security] section to true would be:

GITEA__security__INSTALL_LOCK=true

Not the prettiest, but it works! Some variables have shorter variants, like APP_NAME, but most follow the generalized pattern.

The nice thing about using environment variables is we can easily specify them in our fly.toml in the [env] section, and Fly will automatically inject them into the Gitea container.

The fly launch command should have generated an empty [env] section for us already, so we just need to add the variables to it.

I think this is a reasonable baseline of settings for a “private” Gitea instance:

[env]

  # Disable the Gitea installation screen
  GITEA__security__INSTALL_LOCK = true

  # Disable user self-registration
  GITEA__service__DISABLE_REGISTRATION = true

  # Disable third-party integrations (Gravatar and CDN)
  GITEA__server__OFFLINE_MODE = true

  # Configure URLs/domains used for clone URLs, links, etc.
  # These are temporary values, we'll change them
  # when we set up a custom domain later.
  GITEA__server__DOMAIN = "localhost"
  GITEA__server__SSH_DOMAIN = "localhost"
  GITEA__server__ROOT_URL = "http://localhost:3000/"

  # Some recommended UX adjustments
  APP_NAME = "My gitea instance!"
  GITEA__server__LANDING_PAGE = "explore"

Now we need to tell Fly to redeploy our app with the new settings:

fly deploy --remote-only

There are a couple more variables we need for Gitea to work. These variables are secret, so we handle them a little differently. Instead of adding them to our fly.toml for all to see, we’ll use fly secrets set to inject them into our containers (see Fly secrets docs):

fly secrets set \
  "GITEA__security__SECRET_KEY=$(openssl rand -hex 12)" \
  "GITEA__security__INTERNAL_TOKEN=$(openssl rand -hex 12)"

Notice that fly secrets set automatically redeploys the app with the new variables, so we don’t need to run fly deploy after changing secrets.

Normally, Gitea generates these secret values internally as part of the installation process, and users don’t have to worry about them. But since we’ve disabled the installation screen, we need to set them manually.

Now let’s visit http://localhost:3000 again. (You might need to re-run fly proxy 3000:3000 if you closed your last proxy already.)

Instead of seeing the installation screen, we’re taken to an empty Explore page (because we’ve set GITEA__server__LANDING_PAGE="explore"). Looks like our settings took effect!

We’ll also notice there’s a Sign In button, but no way to register a user. That’s good — we don’t want regular visitors to register accounts. But now how do we get one for ourselves?

Helpfully, Gitea provides administrative command-line tools we can use! The gitea admin user create command is exactly what we want here.

The only problem is, if we created an admin user right now, it would just be in our app container’s ephemeral storage and would disappear on next deployment. Our lack of long-term persistent storage is really starting to hamper us.

So, before we get further, it’s finally time to set up a persistent volume!

We’ll get back to the admin user setup at the end of the post, but if you want to skip ahead, here’s a link: Create an admin user.

Persistent data with Fly volumes

On Fly.io, a volume is a slice of an SSD that’s allocated to our app and persists across deployments and restarts.

Volumes introduce some constraints to our design — each volume can only be mounted by at most one container, so we can’t scale our app to 2+ containers if we use a volume to store all our data. Also, the container has to be in the same region as the volume. This might be an issue if we were building a multi-user site, but for a personal project, we needn’t be concerned.

Fly takes daily snapshots of volume contents, so if we do lose the data, we can restore from those.

Even still, I recommend maintaining a separate mirror of all your Git repositories — e.g. on a hosted site like Github, on your local computer, on a backup service like tarsnap, etc. — in case the volume is deleted.

Don’t put all your eggs in one basket!

The first step is to create our volume, which we can do with fly vol (a short alias of fly volumes):

fly vol create gitea_data --region sea --size 3

Here, gitea_data is the name of the volume. It could be whatever we want. We’ll be using it in a minute to mount the volume in our app.

The --size 3 says we want 3 GB of space. This might not seem like very much, but 3 GB is the free tier limit for persistent storage; additional storage is $0.15/GB-month. The size of a volume can be increased later, but not decreased, so starting with a smaller volume isn’t a bad idea.

The --region sea says to create the volume in Seattle. This is just an example; we should match the region our app was initially configured with fly launch.

As with fly secrets and other commands, fly vol detects the app to create the volume based on your current directory’s fly.toml. The resulting volume is tied to this app and can’t be mounted by other apps in your organization.

Now we have the volume, we need to make it available for Gitea to store data in. This is called “mounting” the volume, and conceptually it works just like mounting a block device in Linux: you specify (1) the volume name and (2) a directory, and then the contents of the volume are made available in the filesystem under that directory.

In this case, Gitea stores all the data we might want to persist under the /data directory already. So, to make all that data persist across redeployments, we just need to mount our volume to the /data directory.

Add the following [mounts] section to the fly.toml:

[mounts]
  destination = "/data"
  source = "gitea_data"

Next time we deploy the app, the persistent storage be used. Let’s do it:

fly deploy --remote-only

Sweet! From this point on, any changes Gitea makes to files in /data will be persisted across redeployments.

If we forgot to create the volume first, or we have more than one Gitea container, we’d get an error here. Remember, we need exactly one volume per container!

So, what’s next? Well, we’re still not connected to the public Internet, are we? Wouldn’t it be nice to use a real URL like https://git.example.com or https://my-gitea-app.fly.dev? I think so!

Publishing on the Web

Cool beans — we’ve got our app, our secure config, and our persistent storage! We’re ready to open some ports to the public Interwebs.

The first step here is to expose ports, without using any custom domain. Fly will give us a my-app-name.fly.dev DNS name. Then, we can layer our custom domain configuration on top — not that there’s much to it!

Let’s add the following to fly.toml:

[[services]]
  http_checks = []
  internal_port = 3000
  processes = ["app"]
  protocol = "tcp"
  script_checks = []
  [services.concurrency]
    hard_limit = 25
    soft_limit = 20
    type = "connections"

  [[services.ports]]
    force_https = true
    handlers = ["http"]
    port = 80

  [[services.ports]]
    handlers = ["tls", "http"]
    port = 443

  [[services.tcp_checks]]
    grace_period = "1s"
    interval = "15s"
    restart_limit = 0
    timeout = "2s"

There’s a lot there, but it’s just the usual boilerplate for exposing an HTTPS service on Fly. A couple callouts:

  1. The internal_port value should match the port where our Gitea container is listening for HTTP traffic. Gitea listens on port 3000 by default.
  2. The [[services.ports]] block is defined twice — once for accepting HTTPS on port 443, and once for an HTTP->HTTPS redirect on port 80.
  3. We’ve defined (optional) TCP health checks (the [[services.tcp_checks]] section) and limited the number of concurrent requests ([services.concurrency]).

Let’s try it:

fly deploy --remote-only

Now we can browse to our site through the good old public Internet. We can use the fly open command so we don’t have to remember our app’s DNS name:

# should open gitea in our browser, with HTTPS!
fly open /

You don’t need to use fly open, of course. Just browse to the URL on your smartphone or anywhere else and it will work fine. No longer shall we need to have fly proxy running to connect to our app!

But wait, we wanted to use a custom domain, right? That’s way cooler. Let’s add it!

Custom domain

There are a couple approaches to custom domain setup. For this walkthrough we’re using the “CNAME method” described here.

If you are configuring an apex domain, or want other records e.g. MX on the same subdomain, use the “A/AAAA method” from that page instead.

The only difference is what records you set in your DNS provider — everything else is the same.

Don’t want to use a custom domain? No problem! As we’ve already seen, Fly provisions every application with a DNS name: appname.fly.dev, which is already set up with certificates and TLS termination by default. Just skip this whole section!

To provide a concrete example, suppose you own the example.com domain and want to host your Gitea instance at https://git.example.com. And suppose you named your Fly app my-gitea-app.

First, you’ll need to login to your DNS provider and add the following CNAME record:

CNAME git.example.com my-gitea-app.fly.dev

Once you’ve got that record created, run the following in the same directory as the my-gitea-app’s fly.toml:

fly certs create git.example.com

Somehow, that’s all we need! Fly will automatically request a certificate from Let’s Encrypt and configure TLS termination for your site. Test it out by opening https://git.example.com in your browser!

(Remember git.example.com is just a placeholder for your own custom domain. If you want to see a “demo” installation, you can check out https://git.luketurner.org.)

Not satisfied with the “Somehow, that’s all we need” non-explanation? Read more in the Fly docs:

https://fly.io/docs/app-guides/custom-domains-with-fly

https://fly.io/docs/reference/services/#tls

Hey, we’re almost done! Take a minute to bask in the fact that you’ve got your own personal Gitea instance out there on the Web. You can show it to your friends. It’s got a signed certificate. It’s almost like you’re a real person!

Basking… Basking…

Okay, enough basking. We still have a few steps left, so let’s put our nose to the grindstone for another 15 minutes.

Accessing repos over SSH

Setting up SSH access to repositories is optional but strongly recommended. Assuming we enable 2FA for our Gitea user, we won’t be able to use HTTP user/password auth to push to our repos.

At a high level, we want to expose our Git repos in three ways:

  1. HTTP read-only access for anonymous users.
  2. SSH read-only access for CI/CD tools.
  3. SSH read/write access for logged-in users (i.e. us!)

The Gitea Docker image already supports all these methods, which is great! In the background, Gitea runs and manages the configuration of the SSH server automatically.

The only problem is, SSH clients have to connect on port 22, which we haven’t exposed to the Internet. We could still connect, but we’d have to use fly proxy. Not great.

To expose SSH to the public Internet, add the following to your fly.toml:

[[services]]
  http_checks = []
  internal_port = 22
  processes = ["app"]
  protocol = "tcp"
  script_checks = []
  [services.concurrency]
    hard_limit = 10
    soft_limit = 5
    type = "connections"

  [[services.ports]]
    port = 22

Then deploy the changes:

fly deploy --remote-only

This is a new [[service]] section, not an addition to the existing [[service]] section we made for HTTP(S) access. When we’re done our fly.toml should have two blocks, like this:

[[service]]
# ... HTTP/HTTPS service config

[[service]]
# ... SSH service config

Tada, we can read/write repositories over SSH! Assuming, of course, that we’re authenticated.

Setting up SSH authentication in the Gitea dashboard is easy as it is in Github — just open your user profile and paste in your public keys. We’ll get to that in the next step.

When a server opens SSH to the Internet on port 22, I’ve been told it’s common to get log spam from failed sign-in attempts. Gitea has a page about fail2ban setup that might help if this becomes a problem for us.

We could also change the public SSH port to a less commonly-used value (by changing the [[services.ports]] port in our fly.toml) and it might stave off the lower-effort attempts.

At least so far, though, I haven’t had to deal with this problem.

Create an admin user

Okay, now we can finally pull it all together by creating a user, logging in, and adding SSH keys!

As I mentioned earlier, Gitea has a gitea admin user create command we’ll use to create the user. The command has to be run in our Gitea container, so we’ll use fly ssh console to open an SSH connection to the app:

# issue an SSH key and connect to the running container
# (only run `fly ssh issue` once per system)
fly ssh issue
fly ssh console

# switch session to Gitea's user
# (the fly ssh console starts as `root` user)
su git

# this needs to be run as git user
gitea admin user create \
  --username MYNAME \
  --email MYEMAIL@example.com \
  --admin \
  --random-password \
  --must-change-password

Now, visit your Web UI and:

  1. Login as your admin user.
  2. Change the password to a secure value when prompted.
  3. (recommended) Enable 2fa for the admin user (in Settings -> Security)
  4. (recommended) Add SSH authorized keys (in Settings -> SSH and GPG keys)

At this point, you should have a cozy single-user Gitea instance to experiment with.


We’ve reached the end of the post, but here are some next steps to consider:

Create a test repo

Try creating a repository called test-repo in the Gitea UI and then push to it. This tests your local SSH key setup:

mkdir testing
cd testing
git init .
git commit -m "test commit" --allow-empty
git remote add origin git@mydomain:myuser/test-repo.git
git push origin

Configure CI/CD

Although outside the scope of this post, Gitea supports outgoing webhooks for CI/CD integration.

I’m currently working on a little script for integrating self-hosted Gitea with Fly builders, so expect a potential follow-up post there.

Update 2022-11-18 — The little script is done! Ironically, it’s hosted on Github: gitea-fly-connector. Click the link for more details and setup instructions. There’s also a blog post about it.

Configure Mailer

Also outside the scope of this post, but if you want to send outgoing emails from your Gitea instance, you’ll need to add some more configuration. See Gitea’s docs on email setup:

https://docs.gitea.io/en-us/email-setup/

Add Collaborators

Want to add a friend to your Gitea instance so they can collaborate on your work, but don’t want to enable self-registration globally?

Run the following:

fly ssh console
su git
gitea admin user create \
  --username FRIENDNAME \
  --email FRIENDEMAIL@example.com \
  --random-password \
  --must-change-password

Now we can send our friend an email with their temporary password and they’ll be prompted to change it on first login.

The only difference with doing this for our friend versus ourselves is we’ve omitted the --admin flag for our friend’s account.

Learn more: Gitea

Learn more about configuring Gitea and read through their config cheatsheet.

Experiment with settings by adding them to your fly.toml and redeploying.

Learn more: Fly

Read Fly’s docs about fly.toml and flyctl.