Linux Administration·8 min read·

sshd_config Match Blocks Per User and Group

Split sshd policy with Match User, Group, and Address so git, sftp, humans, and CI automation stop sharing one global sshd_config on the VPS.

NB

Netbay Cloud Team

Netbay Engineering

On this page

A single sshd_config that tries to serve humans, a git account, an SFTP dropbox, and a CI key will end up too open for the bot and too tight for the human. Match blocks split the daemon's policy by user, group, address, and local port without running a second sshd. Directives inside a Match apply only when the connection matches. Directives above the first Match remain global. That order is the entire mental model, and it is the part the man page buries.

This is not a hardening starter pack. PasswordAuthentication should already be off. Match is how you stop treating every account as the same admin.

One sshd, four policies HUMAN pty, sudo, keys no agent fwd Match Group sudo GIT ForceCommand git-shell only Match User git SFTP ChrootDirectory internal-sftp Match Group sftp CI BOT from bastion IP no forwards Match Address Global defaults first. Match stanzas last. First matching Match wins per directive set. Test with sshd -T -C user=git,host=example,addr=10.0.0.8 before you reload. A bad Match is a lockout. Keep a second session open.

Global defaults, then Match

Put the boring defaults in a drop-in that loads first. PermitRootLogin no, PasswordAuthentication no, PubkeyAuthentication yes, AllowAgentForwarding no, GatewayPorts no. Then add a numbered file that contains only Match stanzas. Match must be last in the effective config because a later global directive would not apply inside the match, and a Match in the middle of globals is how people lose track of what is in force.

bash
sudo tee /etc/ssh/sshd_config.d/70-match.conf > /dev/null <<'EOF'
Match User git
    AllowTcpForwarding no
    DisableForwarding yes
    PermitTTY no
    ForceCommand /usr/bin/git-shell
    AllowAgentForwarding no

Match Group sftponly
    ChrootDirectory /home/%u
    ForceCommand internal-sftp
    AllowTcpForwarding no
    PermitTTY no
    X11Forwarding no

Match Group sudo
    AllowTcpForwarding yes
    PermitTTY yes

Match Address 10.0.0.8/32
    AllowUsers deploy ci
EOF
sudo sshd -t

Indentation is conventional, not required. Keywords inside Match are a subset: you cannot Match and then change Port, for example. If sshd -t complains that a directive is not allowed in a Match, it is telling the truth. Move that keyword to the global section.

ChrootDirectory for SFTP requires the chroot path to be owned by root and not group-writable. The user's writable directory sits inside that. Getting this wrong produces a silent disconnect after auth, which looks like a key problem and is not.

User, Group, Address, LocalPort

Match User git is exact. Match User deploy,ci is a list. Match Group sudo follows Unix groups at authentication time, so usermod -aG is how you grant the human policy. Match Address 203.0.113.10/32 is the bastion's public IP, or the private IP if the connection arrives on the RFC1918 path. Match LocalPort 2222 is how a break-glass listener on a second port gets a different policy, which the last post in this series will use.

Criteria can combine: Match User ci Address 10.0.0.8/32. All listed criteria must hold. There is no OR inside one Match; you write two Match blocks instead.

ForceCommand overrides anything the client asked to run, including git-system and scp. For the git account that is the feature. For a human account it is a lockout. AuthorizedKeysCommand and authorized_keys options like command= interact with ForceCommand; the sshd option wins. Keep forced commands in one place so you can grep them.

Test the effective config without guessing

sshd -T prints the effective configuration for a dummy connection. Add -C to supply user, host, addr, and lport so Match stanzas evaluate.

bash
sudo sshd -T -C user=git,host=localhost,addr=10.0.0.8 | grep -Ei "forcecommand|allowtcp|chroot|permitty|allowusers"
sudo sshd -T -C user=deploy,host=localhost,addr=10.0.0.8 | grep -Ei "forcecommand|allowtcp|chroot|permitty"
sudo systemctl reload ssh

Read the output. If ForceCommand is still none for git, the Match did not fire — usually a typo in the username or a drop-in that never loaded. Ubuntu 24.04 reads /etc/ssh/sshd_config.d/*.conf in lexical order. 70-match.conf after 50-hardening.conf is the right shape.

Keep a second SSH session open while you reload. Match Address that forgets your current IP is a classic lockout. If you use a jump host, the app node sees the bastion address, not your laptop. Match Address on the app node should list the bastion, not your home ISP.

What not to express with Match

Do not use Match to re-enable PasswordAuthentication for one user. That user will be brute-forced. Do not use Match User root as a way to keep root login; leave PermitRootLogin no. Do not put secrets in Match. Do not rely on Match Host with a DNS name if the client IP is what you actually know — Host is the name the client sent, which is not authentication.

A short takeaway: globals first, Match last, one stanza per role, sshd -T -C before reload. Humans get a tty, git gets git-shell, sftp gets a chroot, CI gets an address allowlist. Apply it on a Netbay Ubuntu 24.04 VPS in Lucknow — up in under 60 seconds at netbayhosts.in.

Keep reading

Follow along on a real VPS

Deploy Linux in under 60 seconds

These guides are written against Ubuntu, Debian, and RHEL-family images — the same ones on NetBay.

Deploy an instance