Edge Proxy
    TLS Termination

    Deploy Envoy (Standalone) on a VPS

    Run Envoy as a standalone edge proxy on a RamNode VPS with a static bootstrap config, Let's Encrypt TLS, and a hardened systemd service.

    Envoy is a high performance edge and service proxy originally built at Lyft and now hosted by the CNCF. This guide covers running Envoy as a standalone edge proxy driven by a static bootstrap configuration, terminating TLS and routing to one or more backend applications. It does not use Envoy Gateway, a control plane, or any Kubernetes sidecar model. This is the classic "single binary reverse proxy in front of your app" pattern.

    Everything here stays within RamNode's acceptable use policy. Envoy does not run or relay mail, and the only email address used anywhere is the one Let's Encrypt keeps on file for expiry notices, which involves no outbound SMTP from your VPS.

    Prerequisites

    • A RamNode VPS running Ubuntu 24.04 LTS (Debian 12 works with minor path differences)
    • Root or a sudo-enabled user
    • A domain or subdomain with an A record (and AAAA if you use IPv6) pointing at your VPS
    • A backend service to proxy, for example an app listening on 127.0.0.1:8080

    Throughout the guide, replace app.example.com with your real hostname and 1.38.3 with the current stable release from the Envoy releases page.

    1. Prepare the system

    shell
    sudo apt update && sudo apt upgrade -y
    sudo apt install -y curl ca-certificates

    Create a dedicated unprivileged system user for the proxy so Envoy never runs as root:

    shell
    sudo useradd --system --no-create-home --shell /usr/sbin/nologin envoy

    2. Install the Envoy binary

    Envoy publishes statically linked Linux binaries with each release. Pin the version so upgrades are deliberate rather than accidental:

    shell
    ENVOY_VERSION=1.38.3
    sudo curl -fL \
      "https://github.com/envoyproxy/envoy/releases/download/v${ENVOY_VERSION}/envoy-${ENVOY_VERSION}-linux-x86_64" \
      -o /usr/local/bin/envoy
    sudo chmod +x /usr/local/bin/envoy
    envoy --version

    If your VPS is ARM based, substitute linux-aarch64 for linux-x86_64.

    3. Create the directory layout

    shell
    sudo mkdir -p /etc/envoy/certs /var/log/envoy /var/www/acme
    sudo chown -R envoy:envoy /var/log/envoy /etc/envoy/certs

    4. Issue the TLS certificate

    Because Envoy will own ports 80 and 443, issue the first certificate before Envoy is running, using Certbot in standalone mode.

    shell
    sudo apt install -y certbot
    sudo certbot certonly --standalone \
      -d app.example.com \
      --agree-tos --no-eff-email \
      -m you@example.com

    Envoy runs as an unprivileged user and cannot read files under /etc/letsencrypt/archive, so copy the active certificate into a location it owns. This deploy hook runs on every successful issuance and renewal:

    shell
    sudo tee /etc/letsencrypt/renewal-hooks/deploy/envoy-certs.sh >/dev/null <<'EOF'
    #!/bin/bash
    DOMAIN=app.example.com
    DEST=/etc/envoy/certs
    install -o envoy -g envoy -m 644 \
      /etc/letsencrypt/live/${DOMAIN}/fullchain.pem ${DEST}/fullchain.pem
    install -o envoy -g envoy -m 600 \
      /etc/letsencrypt/live/${DOMAIN}/privkey.pem ${DEST}/privkey.pem
    systemctl restart envoy
    EOF
    sudo chmod +x /etc/letsencrypt/renewal-hooks/deploy/envoy-certs.sh
    sudo /etc/letsencrypt/renewal-hooks/deploy/envoy-certs.sh

    Renewals need port 80 free for a moment. Configure Certbot to stand Envoy down and back up around the standalone renewal:

    shell
    sudo tee /etc/letsencrypt/cli.ini >/dev/null <<'EOF'
    pre-hook = systemctl stop envoy
    post-hook = systemctl start envoy
    EOF

    The renewal window is a few seconds roughly once a month. If you want zero downtime instead, use a DNS-01 challenge with a provider plugin (for example certbot-dns-cloudflare), which never touches ports 80 or 443. In that case drop the cli.ini hooks above and keep only the deploy hook.

    5. Write the Envoy configuration

    Create /etc/envoy/envoy.yaml. The admin interface binds to localhost only, port 80 redirects everything to HTTPS, and port 443 terminates TLS and forwards to the backend cluster.

    shell
    admin:
      address:
        socket_address:
          address: 127.0.0.1
          port_value: 9901
    
    static_resources:
      listeners:
      - name: http_listener
        address:
          socket_address: { address: 0.0.0.0, port_value: 80 }
        filter_chains:
        - filters:
          - name: envoy.filters.network.http_connection_manager
            typed_config:
              "@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager
              stat_prefix: ingress_http
              route_config:
                name: redirect_route
                virtual_hosts:
                - name: redirect_all
                  domains: ["*"]
                  routes:
                  - match: { prefix: "/" }
                    redirect: { https_redirect: true, port_redirect: 443 }
              http_filters:
              - name: envoy.filters.http.router
                typed_config:
                  "@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router
    
      - name: https_listener
        address:
          socket_address: { address: 0.0.0.0, port_value: 443 }
        filter_chains:
        - filters:
          - name: envoy.filters.network.http_connection_manager
            typed_config:
              "@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager
              stat_prefix: ingress_https
              codec_type: AUTO
              route_config:
                name: local_route
                virtual_hosts:
                - name: app
                  domains: ["app.example.com"]
                  routes:
                  - match: { prefix: "/" }
                    route: { cluster: backend_app }
              http_filters:
              - name: envoy.filters.http.router
                typed_config:
                  "@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router
          transport_socket:
            name: envoy.transport_sockets.tls
            typed_config:
              "@type": type.googleapis.com/envoy.extensions.transport_sockets.tls.v3.DownstreamTlsContext
              common_tls_context:
                tls_params:
                  tls_minimum_protocol_version: TLSv1_2
                tls_certificates:
                - certificate_chain: { filename: /etc/envoy/certs/fullchain.pem }
                  private_key: { filename: /etc/envoy/certs/privkey.pem }
    
      clusters:
      - name: backend_app
        type: STRICT_DNS
        connect_timeout: 5s
        lb_policy: ROUND_ROBIN
        load_assignment:
          cluster_name: backend_app
          endpoints:
          - lb_endpoints:
            - endpoint:
                address:
                  socket_address: { address: 127.0.0.1, port_value: 8080 }

    To add more backends, define additional clusters and add matching virtual_hosts on the HTTPS listener. To load balance, list multiple lb_endpoints inside one cluster.

    Validate the configuration before you ever start the service:

    shell
    sudo -u envoy /usr/local/bin/envoy --mode validate -c /etc/envoy/envoy.yaml

    6. Create the systemd service

    Envoy runs unprivileged but still needs to bind the low ports 80 and 443, so grant it the single capability required rather than running as root:

    shell
    sudo tee /etc/systemd/system/envoy.service >/dev/null <<'EOF'
    [Unit]
    Description=Envoy Proxy
    After=network-online.target
    Wants=network-online.target
    
    [Service]
    User=envoy
    Group=envoy
    AmbientCapabilities=CAP_NET_BIND_SERVICE
    ExecStart=/usr/local/bin/envoy -c /etc/envoy/envoy.yaml --log-path /var/log/envoy/envoy.log
    Restart=on-failure
    RestartSec=3
    LimitNOFILE=1048576
    
    [Install]
    WantedBy=multi-user.target
    EOF
    
    sudo systemctl daemon-reload
    sudo systemctl enable --now envoy
    sudo systemctl status envoy --no-pager

    7. Configure the firewall

    RamNode ships a clean VPS, so the firewall is yours to define. Allow SSH and the web ports, and keep the admin interface off the public internet (it already binds to localhost, and UFW gives you a second layer):

    shell
    sudo apt install -y ufw
    sudo ufw default deny incoming
    sudo ufw default allow outgoing
    sudo ufw allow 22/tcp
    sudo ufw allow 80/tcp
    sudo ufw allow 443/tcp
    sudo ufw enable
    sudo ufw status verbose

    Port 9901 is never opened. If you need to reach the admin panel, tunnel it over SSH:

    shell
    ssh -L 9901:127.0.0.1:9901 youruser@your-vps-ip

    Then open http://127.0.0.1:9901 in your local browser.

    8. Verify

    shell
    curl -I http://app.example.com          # expect a 301 redirect to https
    curl -I https://app.example.com         # expect your backend response
    curl -s 127.0.0.1:9901/server_info | head

    Live statistics are available at http://127.0.0.1:9901/stats and cluster health at http://127.0.0.1:9901/clusters (reachable through the SSH tunnel above).

    9. Backups

    The entire state you care about lives in /etc/envoy. Certbot state lives in /etc/letsencrypt. Back both up:

    shell
    sudo tee /usr/local/bin/backup-envoy.sh >/dev/null <<'EOF'
    #!/bin/bash
    STAMP=$(date +%F)
    DEST=/var/backups/envoy
    mkdir -p "$DEST"
    tar czf "$DEST/envoy-${STAMP}.tar.gz" /etc/envoy /etc/letsencrypt
    find "$DEST" -name 'envoy-*.tar.gz' -mtime +14 -delete
    EOF
    sudo chmod +x /usr/local/bin/backup-envoy.sh
    echo "0 3 * * * root /usr/local/bin/backup-envoy.sh" | sudo tee /etc/cron.d/backup-envoy

    For offsite retention, push /var/backups/envoy to RamNode S3 compatible storage or another remote target with a tool such as rclone or restic.

    10. Updating Envoy

    Envoy issues security releases regularly, and several CVEs in the proxy stack land each quarter, so treat updates as routine:

    shell
    ENVOY_VERSION=1.38.3   # set to the new release
    sudo systemctl stop envoy
    sudo curl -fL \
      "https://github.com/envoyproxy/envoy/releases/download/v${ENVOY_VERSION}/envoy-${ENVOY_VERSION}-linux-x86_64" \
      -o /usr/local/bin/envoy
    sudo chmod +x /usr/local/bin/envoy
    sudo -u envoy /usr/local/bin/envoy --mode validate -c /etc/envoy/envoy.yaml
    sudo systemctl start envoy
    envoy --version

    Always validate before restarting so a bad config never takes the listener down.

    Security checklist

    • Admin interface bound to 127.0.0.1:9901 and never exposed through UFW
    • Envoy runs as an unprivileged system user with only CAP_NET_BIND_SERVICE
    • Private key stored 600 and owned by the envoy user
    • tls_minimum_protocol_version set to TLS 1.2
    • Version pinned and updated deliberately, tracking the releases page for CVE fixes
    • Config validated with --mode validate before every restart
    • No mail services involved, consistent with RamNode's AUP

    Where to go next

    Common additions to a standalone Envoy edge include rate limiting via the local rate limit filter, request and response header manipulation, gRPC and HTTP/2 upstream routing, access logging to a structured format, and health checks on your backend clusters. Each is a filter or cluster field added to the same static config, and every change should pass --mode validate before you restart.