Skip to content

Docker Compose on one host

Authority, Proxy and PostgreSQL as three containers on one Docker host, from the release image. Connectors on other hosts. Profile: pilot.

Placeholder Meaning
gate.example.com The name clients and connectors dial; resolves to the Docker host.
registry.example.com/gdsgate:v0.10.0 The release image. See Running the image.
https://idp.example.com/realms/gdsgate The OIDC issuer.

Before you start

Item How to check Reference
Docker Engine with Compose v2 docker compose version
The image docker image inspect registry.example.com/gdsgate:v0.10.0 Running the image
Ports 50061–50063 free ss -ltn \| grep 5006 prints nothing Connector routes
The same ports open inbound before you install. The administrator opens them; without them join and connect from other hosts fail from outside, after start: nc -vz gate.example.com 50061 Open the ports
The name resolves to the host getent hosts gate.example.com Certificates
A certificate pair for gate.example.com (Let's Encrypt or your own CA) openssl x509 -in fullchain.pem -noout -ext subjectAltName Certificates
OIDC client gdsgate as on the single host [oidc]
production only: wrapped keyrings [security.integrity]

For an IP-only entry, Authority can issue registration TLS from the cluster CA. Use these changes to the DNS commands below:

  1. Use --domain 203.0.113.10 and --join-relay on both setup prepare runs.
  2. Omit --enroll-cert, --enroll-key, --tls-cert and --tls-key; skip public certificate installation. Keep the Authority state-volume mount shown below.
  3. Publish "50064:50064" under proxy.ports. Set Proxy's enroll.transport_sni=enroll.gdsgate.internal with setup change.
  4. Record the printed cluster CA fingerprint. Connector joins through https://203.0.113.10:50064 with the approved pin and internal TLS name.

The TOML snapshots below show DNS mode. Keep the files generated for the selected mode. IP-only registration and trust.

Connector routes

Route Ports published Here
tunneled 50061–50063 Used below. Connectors register and renew on 50061. Authority publishes nothing.
tunneled, IP address only 50061–50064 --join-relay on both setup prepare runs, and "50064:50064" under proxy.ports.
fronted 50061–50063 As tunneled. Each connector joins with --enroll-endpoint https://gate.example.com:50061 --route fronted.
direct Authority's 50050, 50051 as well Not with this layout.

Full reference: split deployment, routes.

Open the ports

Open the published ports before you install: 50061, 50062, 50063, and 50064 for an IP address only. The host firewall, and a network firewall in front of the host such as a cloud security group, are the administrator's responsibility; compose up does not change them. While a port is closed, gdsgate join on a connector host and gdsgate connect on a workstation fail. What each port carries: Endpoints.

Firewall Command
ufw ufw allow 50061:50063/tcp
firewalld firewall-cmd --permanent --add-port=50061-50063/tcp && firewall-cmd --reload
nftables, the inet filter table of /etc/nftables.conf nft insert rule inet filter input tcp dport 50061-50063 accept, and the same rule in /etc/nftables.conf

Configure

  1. The directory, the store password and the certificate pair. The image runs as uid 10001; the files it reads belong to it:

    install -d -m 0755 /opt/gdsgate && cd /opt/gdsgate
    printf 'IMAGE=registry.example.com/gdsgate:v0.10.0\n' > .env
    install -d -m 0700 secrets
    openssl rand -hex 24 | tr -d '\n' > secrets/store_password
    openssl rand -hex 24 > secrets/postgres_password
    chmod 0600 secrets/postgres_password
    install -m 0600 -o 999 -g 999 secrets/store_password secrets/postgres_store_password
    chown 10001:10001 secrets/store_password
    chmod 0600 secrets/store_password
    curl -fsS https://gate.gds.dev/docs/deploy/files/cluster-ha/postgres-init.sh -o postgres-init.sh
    chmod 0755 postgres-init.sh
    install -d -m 0750 -o 10001 -g 10001 config config/tls
    install -m 0644 -o 10001 -g 10001 fullchain.pem config/tls/fullchain.pem
    install -m 0600 -o 10001 -g 10001 privkey.pem config/tls/privkey.pem
    install -m 0600 /dev/null proxy.env
    

    PostgreSQL initializes with its separate administrator password. The init script creates non-superuser role gdsgate with the application password. The store has no published host port; verify store isolation.

  2. /opt/gdsgate/compose.yaml:

    name: gdsgate
    
    x-gdsgate: &gdsgate
      image: ${IMAGE}                       # from .env; change it to upgrade
      read_only: true
      cap_drop: [ALL]
      security_opt: [no-new-privileges:true]
      tmpfs: [/tmp]
      restart: unless-stopped
    
    services:
      postgres:
        image: postgres:17
        environment:
          POSTGRES_USER: postgres
          POSTGRES_DB: gdsgate
          POSTGRES_PASSWORD_FILE: /run/secrets/postgres_password
        secrets:
          - postgres_password
          - {source: postgres_store_password, target: store_password, uid: "999", mode: 0400}
        volumes:
          - pgdata:/var/lib/postgresql/data
          - ./postgres-init.sh:/docker-entrypoint-initdb.d/10-gdsgate.sh:ro
        healthcheck:
          test: ["CMD", "pg_isready", "-h", "127.0.0.1", "-U", "gdsgate", "-d", "gdsgate"]
          interval: 5s
          retries: 20
        restart: unless-stopped
    
      authority:
        <<: *gdsgate
        command: ["--config", "/etc/gdsgate/authority.toml", "authority"]
        environment:
          GDSGATE_ADMIN_IDENTITY_DIR: /var/lib/gdsgate/admin    # for the admin CLI in this container
          GDSGATE_ADMIN_ENDPOINT: https://authority:50051
        secrets: [store_password]
        volumes:
          - ./config:/etc/gdsgate:ro
          - authority-state:/var/lib/gdsgate
        depends_on:
          postgres: { condition: service_healthy }
    
      proxy:
        <<: *gdsgate
        command: ["--config", "/etc/gdsgate/proxy.toml", "proxy"]
        env_file: [./proxy.env]             # the one-time token, first start only
        volumes:
          - ./config:/etc/gdsgate:ro
          - proxy-state:/var/lib/gdsgate
        ports:
          - "50061:50061"                   # clients
          - "50062:50062"                   # connector tunnels
          - "50063:50063"                   # connector tunnels, WebSocket fallback
        depends_on:
          authority: { condition: service_healthy }
    
    secrets:
      postgres_store_password:
        file: ./secrets/postgres_store_password
      postgres_password:
        file: ./secrets/postgres_password
      store_password:
        file: ./secrets/store_password
    
    volumes:
      pgdata:
      authority-state:
      proxy-state:
    

    The image's health check requests /readyz on 127.0.0.1:9090, the metrics_listen both files below set. depends_on waits for it.

  3. Start PostgreSQL and write both configurations with the image. The first run joins the Compose network to reach the store:

    docker compose up -d postgres
    docker run --rm --network gdsgate_default \
      -v gdsgate_authority-state:/var/lib/gdsgate \
      -v "$PWD/config:/etc/gdsgate" \
      -v "$PWD/secrets/store_password:/run/secrets/store_password:ro" \
      registry.example.com/gdsgate:v0.10.0 setup prepare \
      --domain gate.example.com --security-profile pilot \
      --role authority --authority-bind authority \
      --store-url postgres://gdsgate@postgres:5432/gdsgate \
      --store-password-file /run/secrets/store_password \
      --enroll-cert /etc/gdsgate/tls/fullchain.pem --enroll-key /etc/gdsgate/tls/privkey.pem \
      --oidc-issuer https://idp.example.com/realms/gdsgate --oidc-client-id gdsgate \
      --unit-dir /tmp/units
    docker run --rm -v "$PWD/config:/etc/gdsgate" \
      registry.example.com/gdsgate:v0.10.0 setup prepare \
      --domain gate.example.com --security-profile pilot \
      --role proxy --authority-host authority --state-dir /var/lib/gdsgate/proxy-state \
      --enroll-cert /etc/gdsgate/tls/fullchain.pem \
      --tls-cert /etc/gdsgate/tls/fullchain.pem --tls-key /etc/gdsgate/tls/privkey.pem \
      --oidc-issuer https://idp.example.com/realms/gdsgate --oidc-client-id gdsgate \
      --unit-dir /tmp/units
    docker run --rm -v "$PWD/config:/etc/gdsgate" \
      registry.example.com/gdsgate:v0.10.0 setup change \
      --set authority:audit.export_path=/var/lib/gdsgate/audit.jsonl
    

    --authority-bind authority: Authority binds the address its service name resolves to. --unit-dir /tmp/units keeps the systemd units inside the throwaway container. The Proxy dials authority, so its run writes transport_sni = "gate.example.com", the name on the certificate.

  4. The files as written, comments shortened. config/authority.toml:

    store_url           = "postgres://gdsgate@postgres:5432/gdsgate"  # the postgres service
    store_password_file = "/run/secrets/store_password"               # the Compose secret
    
    [security]
    profile = "pilot"
    
    [security.integrity]
    control_keyring = "/etc/gdsgate/control.keys"
    audit_keyring   = "/etc/gdsgate/audit.keys"
    
    [endpoints]
    authority        = "authority:50051"            # bound inside the network
    authority_enroll = "authority:50050"
    proxy_public     = "gate.example.com:50061"     # told to joining nodes
    proxy_internal   = "gate.example.com:50062"
    proxy_ws         = "gate.example.com:50063"
    
    [telemetry]
    metrics_listen = "127.0.0.1:9090"               # the health check's address
    
    [proxy]
    public_join = true                              # connectors renew at proxy_public
    
    [connector]
    authority_transport = "tunneled"
    
    [authority]
    enroll_cert_file = "/etc/gdsgate/tls/fullchain.pem"
    enroll_key_file  = "/etc/gdsgate/tls/privkey.pem"
    
    [policy]
    path = "/etc/gdsgate/policy.cedar"
    
    [oidc]
    issuer    = "https://idp.example.com/realms/gdsgate"
    client_id = "gdsgate"
    
    [audit]
    export_path = "/var/lib/gdsgate/audit.jsonl"    # in the authority-state volume
    

    config/proxy.toml. The three listeners bind every interface of the container (0.0.0.0:50061 to 0.0.0.0:50063); Compose publishes them:

    [endpoints]
    # proxy_public, proxy_internal, proxy_ws: 0.0.0.0:50061 to 0.0.0.0:50063
    authority        = "authority:50051"
    authority_enroll = "authority:50050"            # where registration is forwarded
    
    [proxy]
    public_cert_file = "/etc/gdsgate/tls/fullchain.pem"
    public_key_file  = "/etc/gdsgate/tls/privkey.pem"
    public_join      = true                         # connectors register and renew on 50061
    
    [enroll]
    endpoint      = "https://authority:50050"
    state_dir     = "/var/lib/gdsgate/proxy-state"  # in the proxy-state volume
    transport_sni = "gate.example.com"
    
    [oidc]
    issuer    = "https://idp.example.com/realms/gdsgate"
    client_id = "gdsgate"
    
    [security]
    profile = "pilot"
    
    [telemetry]
    metrics_listen = "127.0.0.1:9090"
    
  5. Check Authority's file:

    docker run --rm -v "$PWD/config:/etc/gdsgate:ro" \
      -v "$PWD/secrets/store_password:/run/secrets/store_password:ro" \
      registry.example.com/gdsgate:v0.10.0 config check /etc/gdsgate/authority.toml --role authority
    

    Expected: 0 error(s), 0 warning(s). The Proxy's file is checked once its token is in proxy.env (Start).

Start

docker compose up -d authority
docker compose exec -T authority gdsgate --config /etc/gdsgate/authority.toml \
  authority bootstrap-admin --output-dir /var/lib/gdsgate/admin
printf 'GDSGATE_ENROLL_TOKEN=%s\n' "$(docker compose exec -T authority \
  gdsgate --config /etc/gdsgate/authority.toml authority create-token --role proxy)" > proxy.env
docker run --rm --env-file proxy.env -v "$PWD/config:/etc/gdsgate:ro" \
  registry.example.com/gdsgate:v0.10.0 config check /etc/gdsgate/proxy.toml --role proxy
docker compose up -d proxy

The Proxy check reads the token from its environment, as the proxy service does. Expected: 0 error(s), 0 warning(s).

The administrative identity lasts one day; the same bootstrap-admin command renews it. Administrative commands run as docker compose exec -T authority gdsgate --config /etc/gdsgate/authority.toml authority .... In a script, give each docker compose exec < /dev/null: it reads standard input otherwise.

A connector on another host: the catalogue row and the token from docker compose exec -T authority ..., then the tunneled tab of VPS, a connector on another host.

Upgrade

sed -i 's|^IMAGE=.*|IMAGE=registry.example.com/gdsgate:v0.10.1|' .env
docker compose pull authority proxy
docker compose up -d

Authority and Proxy are recreated; the volumes stay. The Proxy starts with the identity in proxy-state and does not use the token again. Read the upgrade guide first.

Backup

What How
The store docker compose exec -T postgres pg_dump -U gdsgate -Fc gdsgate > gdsgate.dump
The keyrings, the configuration, the certificate pair /opt/gdsgate/config, kept apart from the store dump
Database administrator and application passwords /opt/gdsgate/secrets; protected storage

A store dump without the keyrings cannot be verified. See Keeping keyrings out of the database backup.

Verify

Command Expected If not: cause → fix
docker compose ps authority, postgres, proxy each Up ... (healthy) docker compose logs authority; see the rows below.
docker compose logs authority auth service serving gRPC ... enroll_mode="TLS from the configured certificate" Permission denied (os error 13) → a file in config/ or secrets/ not owned by uid 10001.
docker compose logs authority no readable beyond its owner warning a secret file this configuration names is readable beyond its owner → chmod 0600 secrets/store_password.
docker compose logs proxy enrolled and persisted a new transport identity, then proxy serving gRPC (mTLS (enrolled), public TLS from the configured certificate) certificate refused (...); checked for the name ... → the certificate does not carry gate.example.com, or the name in transport_sni differs. unknown token → a new token into proxy.env, then docker compose up -d proxy.
docker compose exec -T authority gdsgate --config /etc/gdsgate/authority.toml authority list-nodes admin, proxy rows, active
On the connector host: gdsgate join gate.example.com ... --enroll-endpoint https://gate.example.com:50061 ok: joined gate.example.com as conn-1 · role connector See VPS verify.
docker compose exec -T authority gdsgate --config /etc/gdsgate/authority.toml authority list-offered demo-ssh ssh ... yes
Workstation: gdsgate connect gate.example.com ok: wrote ~/.gdsgate/config.toml See Certificates.
After an upgrade: docker compose logs proxy reusing persisted transport identity enrolled and persisted a new transport identity → the proxy-state volume was lost; revoke the old Proxy row.

After these checks pass, complete the access checks from a client workstation.

Profile

pilot, the same matrix rows as the single host.