Forgejo on Railway with 2FA

I run my own Forgejo on Railway. It just works. The web UI is fast, deploys are easy, and most days I forget it is even there.

Then I turned on 2FA. Seemed like the obvious thing to do, and the UI made it a two-click job. What it did not say is that Git over HTTPS has no clue about MFA. Once 2FA is on, Forgejo stops taking your account password for pushes, same as Gitea. So my next push just failed.

Here is the error I got:

Plain Text
remote: Credentials are incorrect or have expired. Retry your command or see
https://codeberg.org/forgejo/forgejo/issues/2809 for more information

That thread is full of people hitting the same wall. The fix is to use a Personal Access Token (PAT) instead of your password.

The HTTPS detour: Personal Access Tokens

A PAT is straightforward. Generate one in Forgejo under Settings → Applications, give it write:repository scope, and paste it as your password when Git prompts. For a moment, it worked. I configured Git to cache the credential, and life moved on.

But it bugged me. I had a token sitting in a credential store. I could revoke it, but it was still one more secret to manage. SSH felt cleaner, a key pair, no passwords, no tokens, same setup as every other Git host.

The catch is that Railway only exposes the HTTP port for a service by default. Nothing outside can reach the SSH daemon in the container. The way around it is Railway’s TCP Proxy feature. Heads up, I replaced my real proxy address with <tcp-host> and <tcp-port> below, yours will look different.

The TCP proxy and the OpenSSH impostor

A TCP proxy in Railway forwards a public port to an internal container port. The first attempt was obvious: point the proxy at port 22, where the system’s SSH daemon is listening. I created the proxy, pointed it at :22, and connected.

It connected, but auth failed.

Shell
ssh -T forgejo
pyk@<tcp-host>: Permission denied (publickey).

Verbose output gave it away:

Shell
ssh -vT forgejo 2>&1 | grep "remote software version"
debug1: Remote protocol version 2.0, remote software version OpenSSH_10.2

That is the system OpenSSH daemon, not Forgejo’s built-in SSH server. Forgejo’s own server identifies as SSH-2.0-Go. The system daemon knows nothing about the SSH keys stored in Forgejo’s database. I was knocking on the wrong door.

So I stopped fighting the system daemon and turned on Forgejo’s own SSH server on its own internal port.

Turning on Forgejo’s built-in SSH server

Forgejo, like Gitea, can run its own SSH server. This is how you are supposed to do it in containers when you want key-based Git access. The settings live under the server section.

I added these to my Railway environment:

Shell
FORGEJO__server__START_SSH_SERVER="true"
FORGEJO__server__SSH_LISTEN_PORT="2222"
FORGEJO__server__SSH_LISTEN_HOST="0.0.0.0"
FORGEJO__server__SSH_DOMAIN="<tcp-host>"
FORGEJO__server__SSH_PORT="<tcp-port>"
FORGEJO__server__SSH_ROOT_PATH="/data/gitea/ssh"

The important one is START_SSH_SERVER. Without it, Forgejo never opens a listening socket. The SSH_LISTEN_PORT is the internal port. I picked 2222 so it does not clash with the system daemon on 22. The SSH_DOMAIN and SSH_PORT are just for display, they set the clone URLs shown in the web UI.

After redeploying, the logs showed it was up:

Plain Text
modules/ssh/init.go:30:Init() [I] SSH server started on 0.0.0.0:2222.

Next, I updated the Railway TCP proxy to forward the public port to :2222 instead of :22.

The git user gotcha

With the proxy fixed, I tried again. The banner looked right this time:

Shell
ssh -vT forgejo 2>&1 | grep "remote software version"
debug1: Remote protocol version 2.0, remote software version Go

But auth still failed. My ~/.ssh/config had User pyk. The server rejected the key before it ever looked it up.

Normally the Forgejo SSH server runs as one system user, usually git. Your Forgejo username is not the SSH login. Forgejo just matches the SSH key you offer against the keys in its database. The SSH layer only sees the git user, then the app maps it to your account.

The fix was one line:

Shell
Host forgejo
    HostName <tcp-host>
    Port <tcp-port>
    User git
    IdentityFile ~/.ssh/id_ed25519
    IdentitiesOnly yes

With that, it worked:

Shell
ssh -T forgejo
Enter passphrase for key '/home/pyk/.ssh/id_ed25519':
Hi there, pyk! You've successfully authenticated with the key named ubuntu, but
Forgejo does not provide shell access.

The message says Hi there, pyk! even though I logged in as git. That is exactly how it works: the git user is the system account, and Forgejo maps the key to your identity.

The final remote

With SSH working, I set the remote to:

Shell
git remote set-url origin git@forgejo:pyk/<repo>.git

The git@forgejo is the SSH connection. The pyk/<repo> is the repository path resolved by Forgejo. No tokens, no passwords, no credential caching. The push just works.

Fun detour. The HTTPS and PAT path works, but it always felt like a hack around 2FA. SSH is the natural fit for Forgejo, and with Railway’s TCP proxy it is reachable from the outside. The lesson is that the SSH user is not your username, and the system OpenSSH daemon is not Forgejo’s SSH server. Pushes just work now, no tokens involved.