Goal
You need a shell on a compute instance, or a SQL client connection to a database, in a private subnet. There is no public IP, no VPN, and you don't want to run a jump host. The OCI Bastion service gives you short-lived, audited SSH sessions into that subnet instead.
There are two session types:
| Session type | What you get | Needs on the target |
|---|---|---|
| Managed SSH | An SSH shell on a Linux instance | OpenSSH server, Oracle Cloud Agent running, Bastion plugin enabled |
| Port forwarding | A local port tunnelled to any private IP and port (SSH, RDP, 1521, 5432, 443) | Only that the port is listening and reachable from the bastion |
If the target isn't a Linux compute instance (a DB system, an Autonomous Database private endpoint, a Windows host), use port forwarding.
1. Check the prerequisites
- An IAM policy for the people who will open sessions (see below).
- The target subnet's security list or NSG allows ingress from the bastion's private endpoint IP on the target port (22 for managed SSH).
- For managed SSH: the target subnet has a route to a service gateway (All region Services in Oracle Services Network), NAT gateway or internet gateway, so the Oracle Cloud Agent can reach OCI.
- For managed SSH: the Bastion plugin is enabled on the instance. It is disabled by default.
- An SSH key pair on your workstation. The public key goes into the session; the private key stays with you.
A minimal policy for an operations group:
Allow group OpsAdmins to use bastion in compartment Ops
Allow group OpsAdmins to manage bastion-session in compartment Ops
Allow group OpsAdmins to read instances in compartment Ops
Allow group OpsAdmins to read instance-agent-plugins in compartment Ops
Allow group OpsAdmins to read virtual-network-family in compartment Ops
Only the people who create and delete bastions need manage bastion.
Tip
You can limit which OS user a group may log in as by adding a condition to the session statement, for example
where target.bastion-session.username = 'appadmin'. The defaultopcuser has passwordless sudo on platform images, so restrict it where you can.
2. Create the bastion
Create one bastion per target subnet or VCN, in the same region as the targets. The bastion is regional.
oci bastion bastion create \
--bastion-type standard \
--compartment-id ocid1.compartment.oc1..xxxx \
--target-subnet-id ocid1.subnet.oc1..xxxx \
--name ops-bastion \
--client-cidr-list '["203.0.113.10/32"]' \
--max-session-ttl 10800 \
--wait-for-state SUCCEEDED
--client-cidr-listis the allowlist of client IPs that may connect. Keep it as narrow as possible and don't use0.0.0.0/0.--max-session-ttlcaps every session on this bastion. Allowed values run from 30 minutes to 3 hours; check the current limits in the Bastion docs.--dns-proxy-status ENABLEDlets port forwarding sessions target an FQDN instead of an IP.
Get the bastion's private IP, then add it as the ingress source on the target subnet's security list or NSG:
oci bastion bastion get --bastion-id ocid1.bastion.oc1..xxxx \
--query 'data."private-endpoint-ip-address"' --raw-output
If your public IP changes (home broadband, a new office egress), update the allowlist:
oci bastion bastion update --bastion-id ocid1.bastion.oc1..xxxx \
--client-cidr-list '["198.51.100.25/32"]'
3. Enable the Bastion plugin (managed SSH only)
Console: Compute → Instances → your instance → Oracle Cloud Agent tab, set Bastion to Enabled. The change can take up to 10 minutes to apply.
Check it from the CLI:
oci instance-agent plugin list \
--compartment-id ocid1.compartment.oc1..xxxx \
--instanceagent-id ocid1.instance.oc1..xxxx \
--name Bastion
You want status RUNNING. INVALID or a missing plugin usually means the agent can't reach OCI. Fix the route to the service gateway first.
4. Open a managed SSH session
oci bastion session create-managed-ssh \
--bastion-id ocid1.bastion.oc1..xxxx \
--target-resource-id ocid1.instance.oc1..xxxx \
--target-os-username opc \
--ssh-public-key-file ~/.ssh/id_ed25519.pub \
--session-ttl 3600 \
--display-name "inc-4821-opc" \
--wait-for-state SUCCEEDED
When the session is ACTIVE, get the connection command OCI generates for it:
oci bastion session get --session-id ocid1.bastionsession.oc1..xxxx \
--query 'data."ssh-metadata".command' --raw-output
It has this shape. Replace <privateKey> with the path to your private key:
ssh -i ~/.ssh/id_ed25519 \
-o ProxyCommand="ssh -i ~/.ssh/id_ed25519 -W %h:%p -p 22 ocid1.bastionsession.oc1..xxxx@host.bastion.eu-frankfurt-1.oci.oraclecloud.com" \
-p 22 opc@10.0.2.15
5. Open a port forwarding session
Example: reach a Base Database listener on 10.0.3.20:1521 from your laptop.
oci bastion session create-port-forwarding \
--bastion-id ocid1.bastion.oc1..xxxx \
--target-private-ip 10.0.3.20 \
--target-port 1521 \
--ssh-public-key-file ~/.ssh/id_ed25519.pub \
--session-ttl 3600 \
--display-name "inc-4821-db" \
--wait-for-state SUCCEEDED
Start the tunnel and leave it running in its own terminal:
ssh -i ~/.ssh/id_ed25519 -N -L 1521:10.0.3.20:1521 -p 22 \
ocid1.bastionsession.oc1..xxxx@host.bastion.eu-frankfurt-1.oci.oraclecloud.com
Then point your client at localhost:1521. The same pattern works for RDP: forward local port 3389 (or 13389 if it's in use) to the Windows host's port 3389.
6. Clean up
Sessions expire at their TTL, but delete them when the work is done:
oci bastion session list --bastion-id ocid1.bastion.oc1..xxxx --all \
--query 'data[?"lifecycle-state"==`ACTIVE`].{name:"display-name",id:id}' --output table
oci bastion session delete --session-id ocid1.bastionsession.oc1..xxxx --force
When it doesn't connect
| Symptom | Likely cause | Fix |
|---|---|---|
Session goes to FAILED on create (managed SSH) | Bastion plugin not running on the target | Enable the plugin, wait up to 10 minutes, check oci instance-agent plugin list |
Connection timed out to host.bastion... | Your public IP isn't in client-cidr-list, or the hostname has the wrong region | Update the allowlist; use the bastion's region in the hostname |
Permission denied (publickey) on the first hop | Private key doesn't match the public key in the session | Use the matching key; chmod 600 the private key |
| First hop works, second hop times out | Target security list or NSG doesn't allow the bastion's private IP on the port | Add an ingress rule from the bastion IP/32 on 22 (or the target port) |
Permission denied on the second hop | Wrong OS user, or the user can't log in with keys | Check --target-os-username; check sshd_config and authorized_keys on the target |
channel 0: open failed on the tunnel | Nothing is listening on the target port, or a host firewall blocks it | Check the service with ss -tlnp; open the port in firewalld |
| Tunnel drops after a while | Session TTL reached | Create a new session; raise --session-ttl up to the bastion's max |
Note
Bastion session create and delete calls are recorded in the OCI Audit log, so you can see who opened a session, when, and to which target.
Prevent it next time
- Create bastions as part of the network build (Terraform
oci_bastion_bastion), not during an incident. - Add the bastion's private IP to the target NSGs when you create them, and enable the Bastion plugin in your instance image or launch template.
- Keep
client-cidr-listlimited to your office and VPN egress ranges, and review it when those change. - Use short TTLs and include the ticket number in
--display-nameso the audit trail makes sense. - Give people
manage bastion-sessionbut notmanage bastion, so they can't widen the allowlist themselves.