Sourcing environment variables from .env in compose

I am a great fan of {podman,docker}-compose. Using compose I can whip up environments in no time, define dependencies, and do a lot of cool stuff I otherwise would have to script in Ansible or other tools. I really like developing software, tools and other things locally before showing them to the world. Using compose I can stand up an APEX environment based on Oracle Database 23ai Free with a simple podman-compose up -d. If you are curious how to do that, have a look at this blog post of mine.

By the way, this test locally then push upstream approach fits in nicely with SQLcl projects workflow. The Oracle SQLcl Projects feature allows users to manage the creation and administration of a database application. It is targeted towards enterprise-level applications that require structured release processes.

Recap: what is Docker Compose?

First of all, it’s pretty cool. The Docker Compose landing page reads:

Docker Compose is a tool for defining and running multi-container applications. It is the key to unlocking a streamlined and efficient development and deployment experience.

Compose simplifies the control of your entire application stack, making it easy to manage services, networks, and volumes in a single YAML configuration file. Then, with a single command, you create and start all the services from your configuration file.

This pretty much captures it for me.

Initial Example

Let’s consider the following file, suitable to be either run by docker-compose as well as podman-compose. It’s rather simple as it defines a single service, but that doesn’t matter for the purpose of this article:

# THIS IS NOT A PRODUCTION SETUP - LAB USE ONLY!
services:
    oracle:
        image: docker.io/gvenzl/oracle-free:23.8
        ports:
            - 1521:1521
        environment:
            ORACLE_PASSWORD: changeOnInstall
            APP_USER: demouser
            APP_USER_PASSWORD: demouser
        volumes:
            - oradata-vol:/opt/oracle/oradata
        networks:
            - backend
        healthcheck:
            test: [ "CMD", "healthcheck.sh" ]
            interval: 10s
            timeout: 5s
            retries: 10
            start_period: 5s

volumes:
    oradata-vol:

networks:
    backend:

Translated to plain English this file allows me to start an Oracle Database Free 23.8 container image, suitable for local development with a single command (docker-compose up -d).

There are issues …

After the initial elation wears off, reality sets in: the above file is far from ideal. As a security conscious person I don’t really like the use of plain text passwords. Compose files get checked into git, and a git push sends them to a potentially public repository. In the above case it’s all simple and innocent enough: the compose file creates a throw-away container instance for me to use. There are definitely cases where security can be compromised though.

If you use podman-compose there are better ways for providing passwords and other sensitive information: secrets. I wrote about the use of podman secrets in a previous article. Despite my best efforts I didn’t mange to use secrets in Docker successfully. There are alternatives though, one of them is the use of environment variables.

It didn’t occur to me that using a simple .env file might be available for Docker Compose, even though I use this method a lot in my JavaScript code.

Let’s revisit the compose file

Based on the documentation the compose file can be rewritten as follows:

# THIS IS NOT A PRODUCTION SETUP - LAB USE ONLY!
services:
    oracle:
        image: docker.io/gvenzl/oracle-free:23.8
        ports:
            - 1521:1521
        environment:
            ORACLE_PASSWORD: ${ORACLE_PASSWORD:?Please provide a password for the Oracle DBA accounts}
            APP_USER: ${APP_USER:?Please specify the username for the application user}
            APP_USER_PASSWORD: ${APP_USER_PASSWORD:?Please provide a password for the application user}
        volumes:
            - oradata-vol:/opt/oracle/oradata
        networks:
            - backend
        healthcheck:
            test: [ "CMD", "healthcheck.sh" ]
            interval: 10s
            timeout: 5s
            retries: 10
            start_period: 5s
 
volumes:
    oradata-vol:
 
networks:
    backend:

You’ll notice that the hard-coded values in lines 8 to 10 have been replaced by variables and a strange looking thing after a question mark. These variables are seeded from an .env file stored in the same directory as the compose file, containing key/value pairs defining the environment variables used in the compose file, namely:

  • ORACLE_PASSWORD
  • APP_USER
  • APP_USER_PASSWORD

IMPORTANT: don’t forget to add the .env file to your .gitignore or else you are merely shifting the problem from one place to another.

I opted for the ${<variable>:?<error message>} bash parameter expansion (🔗) where <variable> is the regular environment variable. However, the :? syntax does something interesting. It enforces the environment variable to be present and non-empty. If either of these cases are not true, the <error message> is written to stderr and a non-zero exit code returned. What does that mean in practice? Consider the APP_USER_PASSWORD environment variable definition in line 10:

APP_USER_PASSWORD: ${APP_USER_PASSWORD:?Please provide a password for the application user}

If the compose engine fails to find APP_USER_PASSWORD in the .env file, or the entry is present but undefined (= empty string), a non-zero exit code is returned and the error message “Please provide a password for the application user” is displayed. See below for a complete example.

Let’s see if that worked

With all that said and done, let’s try it all out! I used:

  • podman 4.9.3
  • podman-compose 1.5.0
  • docker-compose 2.19.0 just to make sure this approach of using .env works with Docker, too

The first step to validate your configuration is to run podman-compose config in the same directory where you stored your compose file. It should provide all the values in clear text in standard output.

What happens in case things are missing/undefined depends on your stack. As of podman-compose 1.5.0 a stack trace indicates a validation error, however it’s not particularly helpful. A future version might address this issue. Docker compose on the other hand shows the correct error message:

$ docker compose config
parsing /path/to/free/compose.yml: error while interpolating services.oracle.environment.ORACLE_PASSWORD: required variable ORACLE_PASSWORD is missing a value: Please provide a password for the Oracle DBA accounts

After fixing all the errors and double-checking the the podman compose config output closely, it’s time to bring the stack up like so:

$ podman-compose up     
a2d1e17b1f416093dc8e539ba77e5f1d7da5822d4c9cff04174eaba6cf448bb4
74dd35396b9e3bb3083bed195fac89924511cfab6181574f0a84e7d999fee219
[oracle] | CONTAINER: starting up...
[oracle] | CONTAINER: first database startup, initializing...
[oracle] | CONTAINER: uncompressing database data files, please wait...
[oracle] | CONTAINER: done uncompressing database data files, duration: 3 seconds.
[oracle] | CONTAINER: starting up Oracle Database...
[oracle] | 
[oracle] | LSNRCTL for Linux: Version 23.0.0.0.0 - Production on 30-JUL-2025 12:50:28
[oracle] | 
[oracle] | Copyright (c) 1991, 2025, Oracle.  All rights reserved.
[oracle] | 
[oracle] | Starting /opt/oracle/product/23ai/dbhomeFree/bin/tnslsnr: please wait...
[oracle] | 
[oracle] | TNSLSNR for Linux: Version 23.0.0.0.0 - Production
[oracle] | System parameter file is /opt/oracle/product/23ai/dbhomeFree/network/admin/listener.ora
[oracle] | Log messages written to /opt/oracle/diag/tnslsnr/74dd35396b9e/listener/alert/log.xml
[oracle] | Listening on: (DESCRIPTION=(ADDRESS=(PROTOCOL=ipc)(KEY=EXTPROC_FOR_FREE)))
[oracle] | Listening on: (DESCRIPTION=(ADDRESS=(PROTOCOL=tcp)(HOST=0.0.0.0)(PORT=1521)))
[oracle] | 
[oracle] | Connecting to (DESCRIPTION=(ADDRESS=(PROTOCOL=IPC)(KEY=EXTPROC_FOR_FREE)))
[oracle] | STATUS of the LISTENER
[oracle] | ------------------------
[oracle] | Alias                     LISTENER
[oracle] | Version                   TNSLSNR for Linux: Version 23.0.0.0.0 - Production
[oracle] | Start Date                30-JUL-2025 12:50:28
[oracle] | Uptime                    0 days 0 hr. 0 min. 0 sec
[oracle] | Trace Level               off
[oracle] | Security                  ON: Local OS Authentication
[oracle] | SNMP                      OFF
[oracle] | Default Service           FREE
[oracle] | Listener Parameter File   /opt/oracle/product/23ai/dbhomeFree/network/admin/listener.ora
[oracle] | Listener Log File         /opt/oracle/diag/tnslsnr/74dd35396b9e/listener/alert/log.xml
[oracle] | Listening Endpoints Summary...
[oracle] |   (DESCRIPTION=(ADDRESS=(PROTOCOL=ipc)(KEY=EXTPROC_FOR_FREE)))
[oracle] |   (DESCRIPTION=(ADDRESS=(PROTOCOL=tcp)(HOST=0.0.0.0)(PORT=1521)))
[oracle] | The listener supports no services
[oracle] | The command completed successfully
[oracle] | ORACLE instance started.
[oracle] | 
[oracle] | Total System Global Area 1603287928 bytes
[oracle] | Fixed Size		    4922232 bytes
[oracle] | Variable Size		  570425344 bytes
[oracle] | Database Buffers	 1023410176 bytes
[oracle] | Redo Buffers		    4530176 bytes
[oracle] | Database mounted.
[oracle] | Database opened.
[oracle] | 
[oracle] | CONTAINER: Resetting SYS and SYSTEM passwords.
[oracle] | 
[oracle] | User altered.
[oracle] | 
[oracle] | 
[oracle] | User altered.
[oracle] | 
[oracle] | CONTAINER: Creating app user for default pluggable database.
[oracle] | 
[oracle] | Session altered.
[oracle] | 
[oracle] | 
[oracle] | User created.
[oracle] | 
[oracle] | 
[oracle] | Grant succeeded.
[oracle] | 
[oracle] | CONTAINER: DONE: Creating app user for default pluggable database.
[oracle] | 
[oracle] | #########################
[oracle] | DATABASE IS READY TO USE!
[oracle] | #########################
[oracle] | 
[oracle] | ####################################################################
[oracle] | CONTAINER: The following output is now from the alert_FREE.log file:
[oracle] | ####################################################################
[oracle] | ===========================================================
[oracle] | 2025-07-30T12:50:47.346458+00:00
[oracle] | PDB$SEED(2):Opening pdb with Resource Manager plan: DEFAULT_PLAN
[oracle] | (3):--ATTENTION--
[oracle] | (3):PARALLEL_MAX_SERVERS (with value 1) is insufficient. This may affect transaction recovery performance.
[oracle] | Modify PARALLEL_MAX_SERVERS parameter to a value > 4 (= parallel servers count computed from parameter FAST_START_PARALLEL_ROLLBACK) in PDB ID 3
[oracle] | FREEPDB1(3):Autotune of undo retention is turned on. 
[oracle] | FREEPDB1(3):Opening pdb with Resource Manager plan: DEFAULT_PLAN
[oracle] | Completed: Pluggable database FREEPDB1 opened read write 
[oracle] | Completed: ALTER DATABASE OPEN

Great news! The database started successfully, allowing applications to connect, for example as the APP_USER (set to demouser in the .env file):

/opt/oracle/sqlcl/bin/sql demouser@localhost/freepdb1 

SQLcl: Release 25.2 Production on Wed Jul 30 14:53:54 2025

Copyright (c) 1982, 2025, Oracle.  All rights reserved.

Password? (**********?) ********
Connected to:
Oracle Database 23ai Free Release 23.0.0.0.0 - Develop, Learn, and Run for Free
Version 23.8.0.25.04

SQL> sho user
USER is "DEMOUSER"

It worked!

Summary

Storing credentials in plain sight is never a good idea, quite to the contrary. There are many alternatives for providing sensitive information to compose files, some of which have been described earlier. Using a .env file is another solution to this tricky problem, however you must ensure not to include the .env file in your version control system. Git for example allows you to define exclusions in the .gitignore configuration file.

Happy testing!