LUNAROPS · OPERATIONAL UPLINK 100% UPTIME 1,247d POSTS 893 JEFF.MOON@LUNAROPS.DEV UTC --:--:--

Teleport: Zero-Trust Access for Infrastructure

securitysshkuberneteszero-trustteleporthomelabdevops

Most infrastructure access is held together with the digital equivalent of sticky notes. SSH keys copied to dozens of servers, nobody sure which ones are still active. Database passwords shared in a Slack thread six months ago. Kubernetes kubeconfigs with long-lived tokens sitting in ~/.kube/config forever. A VPN that grants access to everything once you’re inside.

Teleport replaces all of it with short-lived certificates, identity-aware proxying, and a complete audit trail. Every connection goes through the Teleport proxy. Every session is logged. Every credential expires in hours, not years. Access is granted based on who you are and what role you hold — not whether your static key happens to be on the server.

This guide builds a complete Teleport deployment: SSH access to Linux nodes, Kubernetes access, database access, web app proxying, CI/CD with Machine ID, and RBAC with just-in-time access requests.


The Problem with Traditional Infrastructure Access

SSH keys don’t expire, don’t carry identity, and proliferate. An engineer adds their public key to 30 servers on their first week. When they leave, someone has to remember to remove it from all 30. The answer is usually “we’ll get to it.” You have no idea who accessed which server, when, or what they ran.

Database credentials are shared. The DB_PASSWORD in your .env file has been unchanged for three years. Your junior engineer, your senior engineer, and your CI pipeline all use the same credentials. If it leaks, you can’t tell who did what.

Kubernetes kubeconfigs contain long-lived service account tokens. They work forever until explicitly revoked. They’re copied between machines, backed up to Dropbox, emailed to people who need “just temporary” access.

VPNs solve the wrong problem. Once inside the VPN, you have access to everything on the internal network. There’s no per-service authorization, no MFA per resource, no session recording.

Teleport’s model: every credential is a short-lived certificate, issued only after authentication and MFA, carrying your identity and roles, valid for hours, and automatically expiring. The proxy records everything.


Architecture

┌─────────────────────────────────────────────────────────────┐
│                    Teleport Control Plane                    │
│                                                             │
│  ┌──────────────────┐       ┌─────────────────────────────┐ │
│  │   Auth Service    │       │      Proxy Service          │ │
│  │                  │       │                             │ │
│  │  Certificate CA  │◄─────►│  SSH/HTTPS/K8s/DB routing  │ │
│  │  RBAC engine     │       │  Web UI (:3080)             │ │
│  │  Audit storage   │       │  Agent tunnel (:3024)       │ │
│  │  User identity   │       │  Public endpoint            │ │
│  └──────────────────┘       └─────────────────────────────┘ │
└──────────────────────────────┬──────────────────────────────┘
                               │ reverse tunnels (outbound)
          ┌────────────────────┼────────────────────┐
          │                    │                    │
   ┌──────▼──────┐    ┌────────▼──────┐    ┌───────▼───────┐
   │  SSH Agent  │    │  K8s Agent    │    │  DB Agent     │
   │  (per node) │    │  (per cluster)│    │  (per DB)     │
   │  port 3022  │    │               │    │               │
   └─────────────┘    └───────────────┘    └───────────────┘

User workflow:
  tsh login → SSO/MFA → short-lived certificate (12h)
  tsh ssh / tsh kube login / tsh db connect → routed via proxy

Auth Service — The certificate authority. Issues short-lived X.509 certs encoding the user’s identity and roles. Stores all audit events. Validates SSO tokens and MFA. Never exposed directly to users.

Proxy Service — The public entry point. Routes SSH, HTTPS, Kubernetes API, and database connections. Hosts the Web UI. Agents connect to it via outbound reverse tunnels — no inbound firewall rules needed on your servers.

Agents — Teleport processes running alongside your protected resources (Linux nodes, K8s clusters, databases, apps). They establish outbound tunnels to the Proxy and enforce access policy locally.

tsh — The CLI clients use. Handles login, certificate storage in ~/.tsh/, and all resource access commands.


How Certificate Auth Works

The fundamental difference from SSH keys: certificates are short-lived and carry identity.

1. tsh login --proxy=proxy.example.com --user=alice
   ↓
2. Proxy redirects to SSO provider (Okta, GitHub, Google, etc.)
   ↓
3. Alice authenticates + completes MFA
   ↓
4. Auth Service receives confirmation, evaluates Alice's roles
   ↓
5. Auth Service issues X.509 certificate:
   - Subject: alice
   - Roles: developer, db-reader
   - Logins: ubuntu, deploy
   - Valid for: 12 hours
   ↓
6. Certificate stored in ~/.tsh/keys/proxy.example.com/
   ↓
7. tsh ssh ubuntu@web-01
   → Presents certificate to Proxy
   → Proxy validates cert (signed by its CA, not expired)
   → Proxy checks RBAC: does 'developer' role allow access to web-01?
   → Forwards to SSH Agent on web-01
   → Agent verifies cert, starts session
   → Everything logged to audit trail

When the certificate expires, access stops. No revocation lists. No “remove the key from the server.” It just stops working and Alice has to re-login. If you terminate someone at 9am, their access is gone before lunch.


Self-Hosted Deployment (Docker Compose)

Directory setup

1
2
mkdir -p /opt/teleport/data
cd /opt/teleport

docker-compose.yml

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
services:
  teleport:
    image: public.ecr.aws/gravitational/teleport-ent:16
    hostname: teleport
    ports:
      - "3023:3023"   # SSH client connections
      - "3024:3024"   # SSH reverse tunnel (agents connect here)
      - "3025:3025"   # Auth Service (internal)
      - "3026:3026"   # Kubernetes client port
      - "3080:3080"   # Web UI + HTTPS API
    volumes:
      - ./teleport.yaml:/etc/teleport.yaml:ro
      - ./data:/var/lib/teleport
    restart: unless-stopped
    healthcheck:
      test: ["CMD", "tctl", "status"]
      interval: 30s
      retries: 3

teleport.yaml

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
version: v3

teleport:
  nodename: teleport
  data_dir: /var/lib/teleport
  log:
    output: stderr
    severity: INFO

auth_service:
  enabled: yes
  cluster_name: homelab
  listen_addr: 0.0.0.0:3025

  # Authentication method
  authentication:
    type: local          # Use local users + optional SSO
    second_factor: otp   # Require TOTP MFA
    # For GitHub SSO:
    # type: github

  # Session recording
  session_recording: node  # Record at the node (default)

proxy_service:
  enabled: yes
  listen_addr: 0.0.0.0:3023
  tunnel_listen_addr: 0.0.0.0:3024
  web_listen_addr: 0.0.0.0:3080
  # Public address users connect to
  public_addr: teleport.example.com:3080
  ssh_public_addr: teleport.example.com:3023
  tunnel_public_addr: teleport.example.com:3024
  kube_public_addr: teleport.example.com:3026

ssh_service:
  enabled: yes
  labels:
    role: teleport-server
    env: homelab

# Optionally enable on this same host:
# kubernetes_service:
#   enabled: yes
#   kube_cluster_name: homelab

Start and create initial admin user

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
docker compose up -d

# Wait for startup
docker compose logs -f teleport

# Create admin user (generates signup URL)
docker compose exec teleport tctl users add admin \
  --roles=editor,access \
  --logins=ubuntu,root

# Visit the URL printed — sets up password + TOTP
# Then login:
tsh login --proxy=teleport.example.com --user=admin

Enrolling Linux Nodes

Agents run on every server you want to protect. They open outbound tunnels to the proxy — no inbound firewall rules, no SSH port exposed to the internet.

Generate a join token

1
2
3
4
5
6
# On the Teleport server
docker compose exec teleport tctl tokens add \
  --type=node \
  --ttl=30m \
  --format=text
# Prints: abc123def456...

Install and configure on the node

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
# Install on the target server
curl https://cdn.teleport.dev/install-linux.sh | sudo bash

# Create /etc/teleport.yaml
sudo tee /etc/teleport.yaml <<'EOF'
version: v3
teleport:
  nodename: web-01
  data_dir: /var/lib/teleport
  auth_token: "abc123def456..."       # Token from above
  proxy_server: teleport.example.com:3080

auth_service:
  enabled: no

proxy_service:
  enabled: no

ssh_service:
  enabled: yes
  listen_addr: 127.0.0.1:3022        # Local only — proxy handles routing
  labels:
    env: production
    team: platform
    role: web-server
EOF

sudo systemctl enable --now teleport
sudo systemctl status teleport

Verify enrollment

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# Back on any machine with tsh logged in
tsh ls
# NAME      ADDRESS        LABELS
# web-01    127.0.0.1:3022  env=production,team=platform,role=web-server

# Connect
tsh ssh ubuntu@web-01

# Filter by label
tsh ls env=production
tsh ssh ubuntu@env=production  # Connects to first matching node

Kubernetes Access

The Kubernetes Service acts as a proxy between kubectl and your K8s API servers. Users get short-lived kubeconfig credentials that expire automatically.

Configure on an existing K8s cluster

Deploy the Teleport agent into the cluster:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
# teleport-kube-agent.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: teleport-kube-agent
  namespace: teleport
spec:
  replicas: 1
  selector:
    matchLabels:
      app: teleport-kube-agent
  template:
    metadata:
      labels:
        app: teleport-kube-agent
    spec:
      serviceAccountName: teleport-kube-agent
      containers:
        - name: teleport
          image: public.ecr.aws/gravitational/teleport:16
          args: ["start", "-c", "/etc/teleport/teleport.yaml"]
          volumeMounts:
            - name: config
              mountPath: /etc/teleport
            - name: data
              mountPath: /var/lib/teleport
      volumes:
        - name: config
          configMap:
            name: teleport-kube-agent-config
        - name: data
          emptyDir: {}
---
apiVersion: v1
kind: ConfigMap
metadata:
  name: teleport-kube-agent-config
  namespace: teleport
data:
  teleport.yaml: |
    version: v3
    teleport:
      auth_token: "JOIN_TOKEN_HERE"
      proxy_server: teleport.example.com:443
    auth_service:
      enabled: no
    proxy_service:
      enabled: no
    ssh_service:
      enabled: no
    kubernetes_service:
      enabled: yes
      kube_cluster_name: prod-cluster
      labels:
        env: production
        region: us-east-1

Or use Helm:

1
2
3
4
5
6
7
8
9
helm repo add teleport https://charts.releases.teleport.dev

helm install teleport-kube-agent teleport/teleport-kube-agent \
  --namespace teleport \
  --create-namespace \
  --set roles=kube \
  --set proxyAddr=teleport.example.com:443 \
  --set authToken=JOIN_TOKEN_HERE \
  --set kubeClusterName=prod-cluster

User workflow

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
# List available clusters
tsh kube ls
# Name           Labels
# ----           ------
# prod-cluster   env=production,region=us-east-1
# staging        env=staging

# Login — updates ~/.kube/config with a short-lived cert
tsh kube login prod-cluster

# kubectl now works transparently through the Teleport proxy
kubectl get pods -n production
kubectl logs deployment/api-server -f
kubectl exec -it pod/api-server-xxx -- bash

# Switch clusters
tsh kube login staging
kubectl get nodes

# View what you have access to
tsh status

Database Access

The Database Service proxies connections to your databases. Users never get direct network access to the database host — everything goes through Teleport.

Database Service configuration

Add to your teleport.yaml (or a dedicated agent):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
database_service:
  enabled: yes
  databases:
    - name: prod-postgres
      description: "Production PostgreSQL"
      protocol: postgres
      uri: postgres.prod.internal:5432
      tls:
        mode: verify-full
      static_labels:
        env: production
        team: platform

    - name: staging-mysql
      protocol: mysql
      uri: mysql.staging.internal:3306
      tls:
        mode: verify-ca
      static_labels:
        env: staging

    - name: prod-redis
      protocol: redis
      uri: redis.prod.internal:6379
      static_labels:
        env: production

proxy_service:
  enabled: yes
  # Database protocol ports
  postgres_public_addr: teleport.example.com:5432
  mysql_public_addr: teleport.example.com:3306
  mongo_public_addr: teleport.example.com:27017

Teleport supports: PostgreSQL, MySQL/MariaDB, MongoDB, Redis, ElasticSearch, CockroachDB, Cassandra, Snowflake, DynamoDB, and more.

User workflow

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
# List databases you have access to
tsh db ls
# Name             Description          Labels
# ----             -----------          ------
# prod-postgres    Production PostgreSQL env=production,team=platform
# staging-mysql    Staging MySQL         env=staging

# Connect — tsh launches psql/mysql/mongosh automatically
tsh db connect prod-postgres --db-user=alice --db-name=myapp
# psql opens; Teleport handled auth entirely

# Local tunnel (for GUI clients like DBeaver, TablePlus)
tsh db connect prod-postgres --db-user=alice --tunnel
# Listening on 127.0.0.1:PORT...
# Now connect DBeaver to localhost:PORT, no password needed

# Check active DB sessions
tsh db ls --active

No database password is ever issued to the user. Teleport authenticates on their behalf using certificates it manages between the proxy and the database.


Application Access

Protect internal web apps without changing a line of their code. Teleport acts as an authenticating reverse proxy, injecting JWT tokens so apps can know who the user is.

App Service configuration

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
app_service:
  enabled: yes
  apps:
    - name: grafana
      description: "Production Grafana"
      uri: http://grafana.monitoring.svc.cluster.local:3000
      public_addr: grafana.teleport.example.com
      labels:
        env: production

    - name: argocd
      uri: https://argocd-server.argocd.svc.cluster.local:443
      public_addr: argocd.teleport.example.com
      insecure_skip_verify: true   # If ArgoCD uses self-signed cert
      labels:
        env: production

    - name: internal-api
      uri: http://api.internal:8080
      public_addr: api.teleport.example.com
      # Rewrite headers: inject user identity
      rewrite:
        headers:
          - name: X-Teleport-User
            value: "{{internal.logins}}"

User workflow

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
# List available apps
tsh app ls
# Name       Public Address                   Labels
# ----       --------------                   ------
# grafana    grafana.teleport.example.com     env=production
# argocd     argocd.teleport.example.com      env=production

# Open app in browser (sets a session cookie with Teleport JWT)
tsh app login grafana

# Apps receive a JWT in the Teleport-Jwt-Assertion header:
# {
#   "sub": "alice",
#   "roles": ["developer"],
#   "username": "alice",
#   "traits": { "logins": ["ubuntu"] }
# }

Apps can read this JWT to implement their own authorization — e.g., map roles: ["admin"] to Grafana’s admin role.


RBAC: Roles and Access Control

Every access decision in Teleport flows through roles. Roles define what resources you can reach, as which user, with which constraints.

Role YAML structure

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
kind: role
version: v7
metadata:
  name: developer
  description: "Standard developer — dev/staging access only"

spec:
  options:
    max_session_ttl: 8h
    record_session:
      default: best_effort
    # Uncomment to require MFA for every new session:
    # require_session_mfa: true

  allow:
    # OS users this role can log in as
    logins:
      - ubuntu
      - "{{internal.logins}}"   # Expands to logins set on the user account

    # Which nodes (matched by label)
    node_labels:
      env: ["dev", "staging"]
      team: "{{external.team}}"  # Pulled from SSO claim

    # Kubernetes clusters and RBAC groups
    kubernetes_labels:
      env: ["dev", "staging"]
    kubernetes_groups:
      - developers
    kubernetes_resources:
      - kind: pod
        namespace: "*"
        name: "*"
        verbs: ["get", "list", "exec"]

    # Databases
    db_labels:
      env: ["dev", "staging"]
    db_names: ["*"]
    db_users:
      - "{{internal.logins}}"

    # Apps
    app_labels:
      env: ["dev", "staging"]

    # Can request elevated roles
    request:
      roles: [oncall, admin]
      max_duration: 4h
      reason_required: true

  deny:
    node_labels:
      env: production      # Explicit deny overrides any allow
    db_labels:
      env: production

Production-only admin role

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
kind: role
version: v7
metadata:
  name: admin
spec:
  options:
    max_session_ttl: 4h
    require_session_mfa: true    # Hardware key required per session
    record_session:
      default: strict            # Recording mandatory; deny if unavailable

  allow:
    logins: [root, ubuntu, admin]
    node_labels:
      "*": "*"                   # All nodes
    kubernetes_labels:
      "*": "*"
    kubernetes_groups: [system:masters]
    db_labels:
      "*": "*"
    db_names: ["*"]
    db_users: ["*"]

Apply roles

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
# Create/update role
tctl create -f developer-role.yaml

# Assign to a user
tctl users update alice --set-roles=developer

# Assign multiple roles
tctl users update alice --set-roles=developer,db-reader

# View effective permissions
tctl users get alice

# List all roles
tctl get roles

Access Requests: Just-in-Time Elevation

Rather than giving people standing access to production, Access Requests let developers request elevated roles temporarily with an approval workflow.

Configuration

On the base role (developer), allow requesting admin:

1
2
3
4
5
6
spec:
  allow:
    request:
      roles: [admin, oncall]
      max_duration: 2h
      reason_required: true

On the admin role, mark it as requestable:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
kind: role
version: v7
metadata:
  name: admin
spec:
  allow:
    node_labels:
      "*": "*"
  # Who can review requests for this role
  review_requests:
    roles: [admin-reviewer]
    where: >
      contains(request.system_annotations["teams"], reviewer.traits["team"])

Workflow

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
# Developer needs production access for an incident
tsh request create \
  --roles=admin \
  --reason="P1 incident: API server OOM, need to check pod logs in prod"

# Request ID: a1b2c3d4-...
# Status: PENDING

# Team lead sees it in Slack (if integrated) or via CLI
tctl request ls
tctl request approve a1b2c3d4 \
  --reason="Approved for incident response"

# Developer re-logins to pick up the new certificate
tsh login
# Certificate now includes: Roles: developer, admin (expires in 2h)

# After the incident, access expires automatically
# No manual cleanup required

Teleport integrates with Slack, PagerDuty, Jira, and Mattermost for approval notifications in Enterprise/Cloud editions.


Audit Log and Session Recording

Every action flows through the proxy. Teleport records all of it.

What gets logged

user.login           — successful authentication
user.logout          — session ended
auth.attempt         — failed login
certificate_issued   — new cert issued
session.start        — SSH/DB/K8s session opened
session.end          — session closed
exec                 — command run in a session
scp                  — file transferred
db.session.start     — database session opened
db.session.end       — database session closed
db.session.query     — SQL query executed
kube.request         — kubectl API call
app.session.start    — app session started
access_request.create — access request submitted
access_request.review — request approved/denied

Querying audit events

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
# Recent events (Web UI: https://teleport.example.com/web/audit)
tctl audit events

# Filter by user
tctl audit events --user=alice

# Filter by type
tctl audit events --event=session.start

# Events in a time range
tctl audit events \
  --from=2026-04-01 \
  --to=2026-04-02 \
  --event=exec

Session recordings

Full terminal I/O is recorded for SSH sessions. You can replay them exactly as they happened.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
# List recorded sessions
tctl recordings ls

# Replay in terminal
tsh play <session-id>
# Arrow keys scrub through; space to pause

# Export as JSON (for log aggregation)
tsh play --format=json <session-id>

# Download recording
tctl recordings export <session-id> --output=/tmp/session.tar.gz

Export to S3

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# teleport.yaml
teleport:
  storage:
    type: s3
    region: us-east-1
    bucket: my-teleport-audit

auth_service:
  audit_events_uri:
    - "s3://my-teleport-audit/events?region=us-east-1"
  audit_sessions_uri: "s3://my-teleport-sessions?region=us-east-1"

Machine ID: CI/CD Without Stored Credentials

Machine ID replaces the SSH keys and API tokens you store in CI/CD secrets with short-lived certificates generated at job run time. The bot’s identity is proven via the CI platform’s OIDC token — no stored secrets at all.

GitHub Actions (no stored credentials)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
# .github/workflows/deploy.yml
name: Deploy

on:
  push:
    branches: [main]

jobs:
  deploy:
    runs-on: ubuntu-latest
    permissions:
      id-token: write    # Required for OIDC
      contents: read

    steps:
      - uses: actions/checkout@v4

      - name: Fetch Teleport binaries
        uses: teleport-actions/setup@v1
        with:
          version: "16"

      - name: Authenticate to Teleport
        uses: teleport-actions/auth@v2
        with:
          proxy: teleport.example.com:443
          token: github-actions-bot    # Token name, not value
          certificate-ttl: 30m

      # Credentials are now available — no secrets stored in GitHub
      - name: Deploy to Kubernetes
        run: |
          tsh kube login prod-cluster
          kubectl apply -f k8s/
          kubectl rollout status deployment/api-server

      - name: Run DB migration
        run: |
          tsh db connect prod-postgres \
            --db-user=deploy \
            --db-name=myapp < migrations/latest.sql

      - name: Restart app servers
        run: |
          tsh ssh deploy@app-server-01 "sudo systemctl restart myapp"

Bot token configuration on the Teleport side

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
# Create the role for the CI bot
tctl create -f - <<'EOF'
kind: role
version: v7
metadata:
  name: ci-deploy
spec:
  allow:
    logins: [deploy]
    node_labels:
      env: production
      role: app-server
    kubernetes_labels:
      env: production
    db_labels:
      env: production
    db_names: [myapp]
    db_users: [deploy]
EOF

# Create the bot (Machine ID identity)
tctl bots add github-actions-bot \
  --roles=ci-deploy

# Create the GitHub join token — identity proved by GitHub OIDC
tctl tokens add \
  --type=bot \
  --bot-name=github-actions-bot \
  --github-actions-allow='repo:myorg/myrepo:ref:refs/heads/main' \
  --format=text

The --github-actions-allow constraint means only the main branch of myorg/myrepo can use this token. A compromised fork or a PR branch cannot request credentials.


MFA Configuration

TOTP setup

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# Add TOTP device after login
tsh mfa add
# Choose: TOTP
# Scan QR code with Authenticator app
# Enter code to confirm

# List devices
tsh mfa ls

# Remove old device
tsh mfa rm old-phone

WebAuthn / hardware keys (YubiKey, etc.)

1
2
3
4
5
6
7
8
9
# Add hardware key
tsh mfa add
# Choose: HARDWARE KEY (WebAuthn)
# Touch key when prompted
# Name it: yubikey-5

# Subsequent logins require a key touch
tsh login --proxy=teleport.example.com
# Enter password → Touch your hardware key

Require MFA per session (for sensitive roles)

Add to role options:

1
2
3
spec:
  options:
    require_session_mfa: true   # Touch key before every SSH/DB/K8s session

With this enabled, even if Alice’s certificate is valid, connecting to any node under this role requires another hardware key touch. Certificates compromised outside the workstation cannot open sessions.


Self-Hosted vs Teleport Cloud

Community (self-hosted) Enterprise (self-hosted) Teleport Cloud
Cost Free (<100 employees) $$ per user/month $$$ per user/month
HA/clustering No Yes Yes (managed)
FIPS 140-2 No Yes Yes
Control plane ops You You Gravitational
Slack/PagerDuty approval No Yes Yes
Access Request UI Basic Full Full
Session recording Local disk S3/GCS Managed S3
Global proxy network No No Yes
Support Community SLA SLA

For homelabs and small teams: Community Edition self-hosted covers everything in this guide. For production at scale with compliance requirements, Cloud or Enterprise removes operational overhead on the control plane.


Practical: Quick Homelab Setup

The fastest path to useful security improvement:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
# 1. Start Teleport (single Docker Compose node)
mkdir /opt/teleport && cd /opt/teleport
# Create teleport.yaml (from above)
docker compose up -d

# 2. Create your admin user
docker compose exec teleport tctl users add yourname \
  --roles=editor,access \
  --logins=ubuntu,root

# 3. Enroll your most important server
tctl tokens add --type=node --ttl=30m --format=text
# → Install agent on that server with the token

# 4. Login and verify
tsh login --proxy=localhost:3080 --user=yourname
tsh ls
tsh ssh ubuntu@your-server

# 5. Watch the audit log
tctl audit events

From here, add nodes progressively, create per-person roles instead of sharing ubuntu broadly, and enable the database service as you go. You don’t have to do it all at once — even “every SSH login is logged with the user’s real identity” is a massive security improvement over shared keys.


Summary

Teleport removes the three worst things about infrastructure access:

  • Shared, static credentials → short-lived per-user certificates that expire automatically
  • No audit trail → every command, query, and kubectl call logged and recorded
  • Binary network access → fine-grained RBAC per resource, per protocol, per label

The same tool handles SSH, Kubernetes, databases, and web apps through a single proxy — so you get one place to manage access, one audit log to query, and one expiry model for all credentials. When someone leaves, you disable their account in Teleport and they lose access to everything immediately, with no SSH key hunting required.

For a homelab, Community Edition is free and handles everything covered here. Start with SSH access to your most critical servers, add the audit log to your monitoring stack, and expand from there.

Comments